#!/bin/sh read -r _ _ ZM_NEW_VER <<'ZM_VERSION_EOF' # Version: 4.22 ZM_VERSION_EOF 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" _zmi_say() { echo -e "${CYAN}==>${NC} $*"; } _zmi_ok() { echo -e " ${GREEN}✓${NC} $*"; } _zmi_step() { echo -e " → $*"; } _zmi_warn() { echo -e " ${YELLOW}!${NC} $*"; } if command -v apk >/dev/null 2>&1; then PM="apk"; INSTALL="apk add" else PM="opkg"; INSTALL="opkg install"; fi _zmi_has() { if [ "$PM" = apk ]; then apk info -e "$1" >/dev/null 2>&1 else opkg list-installed 2>/dev/null | grep -q "^$1 "; fi } _zmi_pkg() { if _zmi_has "$1"; then _zmi_ok "Пакет $1 уже установлен"; return 0; fi _zmi_step "Устанавливаем пакет $1" $INSTALL "$1" >/dev/null 2>&1 || true if ! _zmi_has "$1"; then _zmi_step "Не получилось — обновляем список пакетов и пробуем ещё раз" $PM update >/dev/null 2>&1 || true $INSTALL "$1" >/dev/null 2>&1 || true fi if _zmi_has "$1"; then _zmi_ok "Пакет $1 установлен"; return 0; fi _zmi_warn "Пакет $1 не установился — проверьте интернет на роутере. $2" return 1 } clear echo -e "${MAGENTA}Zapret Manager для LuCI и Web $ZM_NEW_VER — установка${NC}\n" _zmi_say "Проверяем, что панель сейчас ничем не занята" 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|fk-guard) 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 _zm_n=0 while [ -f /tmp/zm_uninstall_panel.sh ] && ps w 2>/dev/null | grep -q '[z]m_uninstall_panel\.sh'; do [ "$_zm_n" = 0 ] && _zmi_step "Панель сейчас удаляется — ждём окончания" if [ "$_zm_n" -ge 60 ]; then echo -e "${YELLOW}Удаление панели не закончилось за минуту — запустите установку чуть позже${NC}" exit 1 fi sleep 1; _zm_n=$((_zm_n + 1)) done _zmi_ok "Можно ставить" _zmi_say "Убираем файлы прежней версии панели" if [ -s /tmp/zapret-manager-luci/resolv.conf.zm-bak ]; then if [ -e /tmp/resolv.conf ]; then cat /tmp/zapret-manager-luci/resolv.conf.zm-bak > /tmp/resolv.conf 2>/dev/null || true else cat /tmp/zapret-manager-luci/resolv.conf.zm-bak > /etc/resolv.conf 2>/dev/null || true; fi fi rm -rf \ /tmp/zapret-manager-luci* \ /tmp/zm_uninstall_panel.sh \ /tmp/luci-indexcache* \ /tmp/luci-modulecache/* 2>/dev/null _zmi_ok "Готово" _zmi_say "Записываем файлы панели" mkdir -p /opt/zapret-manager-luci chmod 0755 /opt/zapret-manager-luci _zm_old_ver="$(sed -n 's/^ZM_VERSION="\([0-9][0-9.]*\)"$/\1/p' /opt/zapret-manager-luci/backend.sh 2>/dev/null | head -n 1)" cat > '/opt/zapret-manager-luci/backend.sh.zm-new' << 'ZM_INSTALLER_EOF' #!/bin/sh umask 022 if [ "$1" = zm_watch ]; then [ -z "$ZM_WATCH_FORCE" ] && [ -f /opt/zapret-manager-luci/state/watch.off ] && exit 0 mkdir -p /tmp/zapret-manager-luci 2>/dev/null; : > /tmp/zapret-manager-luci/watch.last 2>/dev/null _zw_idle() { local d=/tmp/zapret-manager-luci s=/opt/zapret-manager-luci/state e=/etc/zm-steer local pl=/usr/libexec/rpcd/zapret-manager want have t v st=0 pidof rpcd >/dev/null 2>&1 || return 1 ubus -t 5 list session >/dev/null 2>&1 || return 1 ubus -t 15 call session access '{"ubus_rpc_session":"00000000000000000000000000000000","scope":"ubus","object":"session","function":"access"}' >/dev/null 2>&1 [ "$?" = 7 ] && return 1 if [ -x "$pl" ]; then ubus -t 5 list zapret-manager >/dev/null 2>&1 || return 1 want="$(grep -c 'json_add_object' "$pl" 2>/dev/null)" have="$(ubus -v list zapret-manager 2>/dev/null | grep -c '^[[:space:]]*"')" if [ "${want:-0}" -gt 0 ] 2>/dev/null && [ "${have:-0}" -gt 0 ] 2>/dev/null && [ "$have" != "$want" ]; then return 1; fi fi [ -s "$s/strategy_test.backup" ] && return 1 [ -f "$d/resolv.conf.zm-bak" ] && return 1 [ -f "$s/tg.watch" ] && return 1 if grep -qx 'ru=isp' /opt/zapret-manager-luci/dns_net 2>/dev/null; then for t in /tmp/resolv.conf.d/resolv.conf.auto /tmp/resolv.conf.auto; do [ -s "$t" ] || continue v="$(awk '$1 == "nameserver" && ($2 ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ || ($2 ~ /^[0-9A-Fa-f:]+$/ && $2 ~ /:.*:/)) && $2 !~ /^(127\.|0\.0\.0\.0$|::1$|[Ff][Ee]80:)/ && !u[$2]++ { print $2 }' "$t" | head -n 4 | tr '\n' ',' | sed 's/,$//')" [ -n "$v" ] && ! grep -qxF "ispips=$v" /opt/zapret-manager-luci/dns_net 2>/dev/null && return 1 break done fi { [ -e /usr/bin/netshift ] || [ -e /usr/lib/netshift ]; } && return 1 if command -v steer >/dev/null 2>&1 && grep -qxF -e engine -e 'pkg steer' -e 'net zmwarp' -e 'net zmwarp4' "$e/owned" 2>/dev/null; then [ ! -f "$e/stopped" ] && [ -s "$e/sub.txt" ] && grep -qxF steer-spec "$e/owned" 2>/dev/null && return 1 if [ ! -f "$e/stopped" ] && [ -s "$e/warp.up" ]; then [ -f "$d/steer/colo.last" ] || return 1 t="$(date -r "$d/steer/colo.last" +%s 2>/dev/null || echo 0)" [ $(( $(date +%s) - t )) -lt 300 ] || return 1 fi fi if grep -qx 'on=1' "$s/adblock/settings" 2>/dev/null; then for t in $(sed -n 's/^conf-dir=\([^,]*\).*/\1/p' /var/etc/dnsmasq.conf.* 2>/dev/null | sort -u); do [ -s "$t/zm-adblock.conf" ] || return 1 grep -qx 'stats=1' "$s/adblock/settings" 2>/dev/null && { [ -s "$t/zm-adblock-stats.conf" ] || return 1; } done if [ -s "$s/adblock/free.txt" ]; then t="$(cat /var/run/zm-adblock-free.pid 2>/dev/null)" [ -n "$t" ] && kill -0 "$t" 2>/dev/null || return 1 nft list table inet zm_adblock_free >/dev/null 2>&1 || return 1 for v in /var/etc/dnsmasq.conf.*; do [ "$v" -nt /var/etc/zm-adblock-free.conf ] && return 1; done fi elif [ -e /var/run/zm-adblock-free.pid ]; then return 1 fi rm -f /tmp/zm-rpcd-slow /tmp/zm-rpcd-plugin.stamp "$s/fk.watch" "$d/steer.vpnwatch" return 0 } _zw_idle && exit 0 fi CONF="/etc/config/zapret" ZM_VERSION="@ZM_VERSION@" 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" ZAPRET_VER="72.20260307" 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" RAZ="ipk" else PKG="apk"; PKG_ADD="apk add --allow-untrusted"; PKG_DEL="apk del"; PKG_UPD="apk update" RAZ="apk" fi esc() { case "$1" in *[!A-Za-z0-9\ ._:/@+=,%-]*) ;; *) printf '%s' "$1"; return 0 ;; esac 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' ' ' } esc_ml() { printf '%s' "$1" | tr -d '\000-\010\013\014\016-\037' | sed 's/\r$//; s/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g; s/\r/\\r/g' | sed ':a;N;$!ba;s/\n/\\n/g' } _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_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__')")" } _confz() { if [ "$PKG" = "apk" ]; then echo "/etc/apk/repositories.d/distfeeds.list" else echo "/etc/opkg/distfeeds.conf"; fi } ZM_MIRROR_STATE="$ZM_STATE_DIR/mirror.state" _mirror_host() { grep -v '^[[:space:]]*#' "$(_confz)" 2>/dev/null | grep -oE 'https?://[^ ]+/(releases|snapshots)/' | head -n1 | sed -E 's#^https?://##; s#/(releases|snapshots)/$##' } _mirror_name() { local f url f="$(_confz)" [ -f "$f" ] || { echo "файл не найден"; return; } url="$(_mirror_host)" [ -n "$url" ] || 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 "неизвестное" ;; *) echo "$url" ;; esac } _mirror_info() { local h how="" ts="" h="$(_mirror_host)" if [ -s "$ZM_MIRROR_STATE" ] && [ "$(cut -f1 "$ZM_MIRROR_STATE")" = "$h" ]; then how="$(cut -f3 "$ZM_MIRROR_STATE")"; ts="$(cut -f2 "$ZM_MIRROR_STATE")" fi [ "$h" = downloads.openwrt.org ] && how="" printf '"mirror":"%s","mirror_host":"%s","mirror_how":"%s","mirror_at":%s' "$(esc "$(_mirror_name)")" "$(esc "$h")" "$(esc "$how")" "${ts:-0}" } _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 p now="$(_cpu_stat)" [ -n "$now" ] || return 0 if [ -s "$f" ] && [ -z "$(find "$f" -mmin +1 2>/dev/null)" ]; then read -r prev 2>/dev/null < "$f" fi if [ -z "$prev" ]; then 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 p=""; read -r p 2>/dev/null < "$f.pct" [ -n "$p" ] && { echo "$p"; return 0; } fi [ "$t2" -gt "$t1" ] 2>/dev/null || { echo 0; return 0; } echo "$now" > "$f" p=$(( (100 * ((t2 - t1) - (i2 - i1)) + (t2 - t1) / 2) / (t2 - t1) )) echo "$p" > "$f.pct" echo "$p" } _cpu_temp() { local z t typ best="" v for z in /sys/class/thermal/thermal_zone*; do [ -r "$z/temp" ] || continue t=""; read -r t 2>/dev/null < "$z/temp" case "$t" in ''|*[!0-9-]*) continue ;; esac typ=""; read -r typ 2>/dev/null < "$z/type" 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=""; read -r t 2>/dev/null < "$z" 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 for h in ya.ru vk.ru; do ms=$(curl -s -o /dev/null --connect-timeout 4 --max-time 6 -w '%{time_namelookup} %{time_connect}' "http://$h/" 2>/dev/null) || continue echo "$(date +%s) ok $(echo "$ms" | awk '{ v = ($2 - $1) * 1000; if (v < 1) v = 1; printf "%d", v }')" return 0 done for h in 77.88.8.8 1.1.1.1; do ping -4 -c 1 -W 2 "$h" >/dev/null 2>&1 && { echo "$(date +%s) nodns"; return 0; } done for h in 1.1.1.1 8.8.8.8; do curl -s -o /dev/null --connect-timeout 4 --max-time 6 "http://$h/" >/dev/null 2>&1 && { echo "$(date +%s) nodns"; 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" } ZM_SI_STATIC="$JOBS_DIR/si.static" _si_static() { local f="$ZM_SI_STATIC" line="" model arch owrt target if [ -s "$f" ] && [ ! "$(_confz)" -nt "$f" ] && [ ! "$ZM_MIRROR_STATE" -nt "$f" ] && [ -z "$(find "$f" -mmin +10 2>/dev/null)" ]; then IFS= read -r line 2>/dev/null < "$f" [ -n "$line" ] && { printf '%s\n' "$line"; return 0; } fi 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) target=$(grep '^DISTRIB_TARGET=' /etc/openwrt_release 2>/dev/null | cut -d"'" -f2) line="$(printf '%s,"model":"%s","arch":"%s","openwrt":"%s","target":"%s","hostname":"%s","kernel":"%s"' "$(_mirror_info)" \ "$(esc "$model")" "$(esc "$arch")" "$(esc "$owrt")" "$(esc "$target")" "$(esc "$(cat /proc/sys/kernel/hostname 2>/dev/null)")" "$(esc "$(uname -r 2>/dev/null)")")" printf '%s\n' "$line" > "$f.$$" 2>/dev/null && mv -f "$f.$$" "$f" 2>/dev/null || rm -f "$f.$$" printf '%s\n' "$line" } system_info() { local df_j inet inet_ms df_j=$(df -h /tmp / 2>/dev/null | awk 'function c(x) { gsub(/["\\]/, "", x); return x } NR == 2 { a = $3; b = $4 } NR == 3 { u = $3; v = $4 } END { printf "\"tmp_used\":\"%s\",\"tmp_free\":\"%s\",\"root_used\":\"%s\",\"root_free\":\"%s\"", c(a), c(b), c(u), c(v) }') [ -n "$df_j" ] || df_j='"tmp_used":"","tmp_free":"","root_used":"","root_free":""' set -- $(_inet_state) inet="$1"; inet_ms="$2" printf '{%s,%s,"cpu_temp":"%s","cpu_load":"%s","inet":"%s","inet_ms":"%s"}\n' "$(_si_static)" "$df_j" "$(_cpu_temp)" "$(_cpu_load)" "$inet" "$inet_ms" } _mon_dev() { local f="$JOBS_DIR/mon.dev.$1" d="" if [ -s "$f" ] && [ -z "$(find "$f" -mmin +1 2>/dev/null)" ]; then read -r d 2>/dev/null < "$f"; printf '%s' "$d"; return 0; fi d="$(ubus -t 2 call "network.interface.$1" status 2>/dev/null | jsonfilter -e '@.l3_device' 2>/dev/null)" [ -n "$d" ] || d="$(uci -q get "network.$1.device" 2>/dev/null)" case "$d" in *[!A-Za-z0-9_.@-]*) d="" ;; esac printf '%s\n' "$d" > "$f" 2>/dev/null printf '%s' "$d" } # Сети для «Сеть сейчас»: «имя устройство интернет(1/0)» по строке, кэш на минуту. # Интернет — устройство маршрута по умолчанию с наименьшей метрикой (wan, wwan, модем…). _mon_ifs() { local f="$JOBS_DIR/mon.ifs" dr o n d if [ -s "$f" ] && [ -z "$(find "$f" -mmin +1 2>/dev/null)" ]; then cat "$f"; return 0; fi dr="$(awk 'NR > 1 && $2 == "00000000" && $8 == "00000000" { print $7 + 0, $1 }' /proc/net/route 2>/dev/null | sort -n | awk 'NR == 1 { print $2 }')" { ubus -t 2 call network.interface dump 2>/dev/null | jsonfilter -e '@.interface[@.up=true]' 2>/dev/null | while IFS= read -r o; do n="$(jsonfilter -s "$o" -e '@.interface' 2>/dev/null)" d="$(jsonfilter -s "$o" -e '@.l3_device' 2>/dev/null)" [ -n "$n" ] && [ -n "$d" ] && [ "$n" != loopback ] && [ -d "/sys/class/net/$d" ] || continue case "$n$d" in *[!A-Za-z0-9_.@-]*) continue ;; esac echo "$n $d" done [ -n "$dr" ] && echo "$dr $dr" } | awk -v dr="$dr" ' { l[NR] = $0; nm[NR] = $1; dv[NR] = $2 } END { for (p = 0; p < 2; p++) for (i = 1; i <= NR; i++) { six = (nm[i] ~ /6$/) ? 1 : 0 if (six != p || seen[dv[i]]++) continue print nm[i], dv[i], (dv[i] == dr ? 1 : 0) } }' > "$f.$$" 2>/dev/null && mv -f "$f.$$" "$f" 2>/dev/null cat "$f" 2>/dev/null } # Вентилятор: обороты (hwmon fanN_input), мощность (hwmon pwmN, 0–255) или ступень охлаждения # (thermal cooling_device с «fan» в типе). Нет вентилятора — ничего не выводит. _mon_fan() { local h n v rpm=-1 pwm=-1 st=-1 mx=-1 c t y="${ZM_SYSFS:-/sys}" for h in "$y"/class/hwmon/hwmon*; do [ -d "$h" ] || continue n=""; read -r n 2>/dev/null < "$h/name" for v in "$h"/fan[0-9]_input; do [ -r "$v" ] || continue t=""; read -r t 2>/dev/null < "$v"; case "$t" in ''|*[!0-9]*) ;; *) [ "$rpm" -lt 0 ] && rpm="$t" ;; esac done case "$n" in *fan*|*FAN*) ;; *) [ -e "$h/fan1_input" ] || continue ;; esac for v in "$h"/pwm[0-9]; do [ -r "$v" ] || continue t=""; read -r t 2>/dev/null < "$v"; case "$t" in ''|*[!0-9]*) ;; *) [ "$pwm" -lt 0 ] && pwm=$(( (t * 100 + 127) / 255 )) ;; esac done done for c in "$y"/class/thermal/cooling_device*; do [ -r "$c/type" ] || continue t=""; read -r t 2>/dev/null < "$c/type" case "$t" in *fan*|*Fan*|*FAN*) ;; *) continue ;; esac read -r st 2>/dev/null < "$c/cur_state"; read -r mx 2>/dev/null < "$c/max_state" case "$st$mx" in *[!0-9]*|'') st=-1; mx=-1 ;; *) break ;; esac done [ "$rpm" -ge 0 ] || [ "$pwm" -ge 0 ] || [ "$st" -ge 0 ] || return 0 printf '{"rpm":%s,"pwm":%s,"st":%s,"mx":%s}' "$rpm" "$pwm" "$st" "$mx" } _mon_static() { local f="$JOBS_DIR/mon.static" soc="" v c n part cpu="" mx="" line if [ -s "$f" ] && [ -z "$(find "$f" -mmin +10 2>/dev/null)" ]; then cat "$f"; return 0; fi soc="$( { tr '\000' '\n' < /proc/device-tree/compatible; } 2>/dev/null | tail -n1)" case "$soc" in *,*) v="${soc%%,*}"; c="$(printf '%s' "${soc#*,}" | tr 'a-z' 'A-Z')" case "$v" in mediatek) v=MediaTek ;; qcom) v=Qualcomm ;; brcm) v=Broadcom ;; ralink) v=Ralink ;; realtek) v=Realtek ;; rockchip) v=Rockchip ;; allwinner) v=Allwinner ;; marvell) v=Marvell ;; airoha|econet) v=Airoha ;; amlogic) v=Amlogic ;; nxp|fsl) v=NXP ;; esac soc="$v $c" ;; *) soc="" ;; esac n="$(grep -c '^processor' /proc/cpuinfo 2>/dev/null)" part="$(sed -n 's/^CPU part[[:space:]]*:[[:space:]]*//p' /proc/cpuinfo 2>/dev/null | head -n1)" case "$part" in 0xd03) cpu=Cortex-A53 ;; 0xd04) cpu=Cortex-A35 ;; 0xd05) cpu=Cortex-A55 ;; 0xd07) cpu=Cortex-A57 ;; 0xd08) cpu=Cortex-A72 ;; 0xd09) cpu=Cortex-A73 ;; 0xd0b) cpu=Cortex-A76 ;; 0xc05) cpu=Cortex-A5 ;; 0xc07) cpu=Cortex-A7 ;; 0xc09) cpu=Cortex-A9 ;; 0xc0f) cpu=Cortex-A15 ;; esac [ -n "$cpu" ] || cpu="$(sed -En 's/^(cpu model|model name)[[:space:]]*:[[:space:]]*//p' /proc/cpuinfo 2>/dev/null | head -n1)" read -r mx 2>/dev/null < /sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq case "$mx" in ''|*[!0-9]*) mx=0 ;; esac line="$(printf '"soc":"%s","cpu_model":"%s","ncpu":%s,"fmax":%s,"model":"%s","kernel":"%s","openwrt":"%s"' "$(esc "$soc")" "$(esc "$cpu")" "${n:-1}" "$((mx / 1000))" \ "$(esc "$(cat /tmp/sysinfo/model 2>/dev/null)")" "$(esc "$(uname -r 2>/dev/null)")" \ "$(esc "$(sed -n "s/^DISTRIB_RELEASE='\(.*\)'/\1/p" /etc/openwrt_release 2>/dev/null)")")" printf '%s\n' "$line" > "$f.$$" 2>/dev/null && mv -f "$f.$$" "$f" 2>/dev/null printf '%s\n' "$line" } _mon_c() { local t="$1" v; if [ "$t" -gt 1000 ] 2>/dev/null; then v=$(( (t + 50) / 100 )); echo "$((v / 10)).$((v % 10))"; else echo "$t"; fi; } _mon_tlabel() { local n="$1" l="$2" i b case "$n" in *phy[0-9]*) i="$(printf '%s' "$n" | sed 's/.*phy\([0-9]*\).*/\1/')"; b="$(uci -q get "wireless.radio$i.band" 2>/dev/null)" case "$b" in 2g) echo "Wi-Fi 2,4 ГГц" ;; 5g) echo "Wi-Fi 5 ГГц" ;; 6g) echo "Wi-Fi 6 ГГц" ;; *) echo "Wi-Fi (phy$i)" ;; esac ;; *cpu*|*CPU*|*soc*|*SOC*|*tsens*) echo "Процессор" ;; *ddr*|*DDR*) echo "Память" ;; *nvme*|*mmc*) echo "Накопитель" ;; *ath*|*mt76*|*mt79*|*wifi*|*wlan*) echo "Wi-Fi" ;; *) if [ -n "$l" ]; then echo "$n · $l"; else echo "$n"; fi ;; esac } _mon_temps() { local z t typ sep="" h n i lbl seen=" " for z in /sys/class/thermal/thermal_zone*; do [ -r "$z/temp" ] || continue t=""; read -r t 2>/dev/null < "$z/temp" case "$t" in ''|*[!0-9-]*) continue ;; esac typ=""; read -r typ 2>/dev/null < "$z/type" seen="$seen$(printf '%s' "$typ" | tr '-' '_') " printf '%s{"id":"%s","label":"%s","c":%s}' "$sep" "$(esc "$typ")" "$(esc "$(_mon_tlabel "$typ")")" "$(_mon_c "$t")"; sep="," done for h in /sys/class/hwmon/hwmon*; do n=""; read -r n 2>/dev/null < "$h/name" case "$seen" in *" $(printf '%s' "$n" | tr '-' '_') "*) continue ;; esac for i in "$h"/temp*_input; do [ -r "$i" ] || continue t=""; read -r t 2>/dev/null < "$i" case "$t" in ''|*[!0-9-]*) continue ;; esac lbl=""; read -r lbl 2>/dev/null < "${i%_input}_label" printf '%s{"id":"%s","label":"%s","c":%s}' "$sep" "$(esc "$n")" "$(esc "$(_mon_tlabel "$n" "$lbl")")" "$(_mon_c "$t")"; sep="," done done } monitor_snapshot() { local wan lan fl f t ifs fan wan="$(_mon_dev wan)"; lan="$(_mon_dev lan)" ifs="$(_mon_ifs | tr '\n' ';')" fan="$(_mon_fan)" fl="/proc/uptime /proc/loadavg /proc/stat /proc/meminfo /proc/net/dev" for f in /proc/sys/net/netfilter/nf_conntrack_count /proc/sys/net/netfilter/nf_conntrack_max; do [ -r "$f" ] && fl="$fl $f"; done t=""; read -r t 2>/dev/null < /sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq case "$t" in ''|*[!0-9]*) t=0 ;; esac printf '{' awk -v wan="$wan" -v lan="$lan" -v ifs="$ifs" ' BEGIN { k = split(ifs, il, ";"); for (i = 1; i <= k; i++) if (split(il[i], q, " ") >= 2) { ifn[q[2]] = q[1]; ifi[q[2]] = q[3] + 0; ifo[++ifc] = q[2] } } FILENAME == "/proc/uptime" { up = $1 + 0 } FILENAME == "/proc/loadavg" { ld = ($1 + 0) "," ($2 + 0) "," ($3 + 0); split($4, pr, "/"); run = pr[1] + 0; tot = pr[2] + 0 } FILENAME == "/proc/stat" && $1 ~ /^cpu/ { s = ""; for (i = 2; i <= 9; i++) s = s (i > 2 ? "," : "") ($i + 0) if ($1 == "cpu") all = s; else cores = cores (cores != "" ? "," : "") "[" s "]" } FILENAME == "/proc/meminfo" { k = $1; sub(/:$/, "", k); m[k] = $2 + 0 } FILENAME == "/proc/net/dev" && index($0, ":") { l = $0; sub(/^[ \t]+/, "", l); n = substr(l, 1, index(l, ":") - 1); split(substr(l, index(l, ":") + 1), v, " ") if (n == wan) w = (v[1] + 0) "," (v[9] + 0) if (n == lan) la = (v[1] + 0) "," (v[9] + 0) if (n in ifn) ifc_v[n] = (v[1] + 0) "," (v[9] + 0) } FILENAME ~ /_count$/ { cc = $1 + 0 } FILENAME ~ /_max$/ { cm = $1 + 0 } END { av = ("MemAvailable" in m) ? m["MemAvailable"] : m["MemFree"] + m["Cached"] + m["Buffers"] printf "\"t\":%s,\"load\":[%s],\"run\":%d,\"tasks\":%d,\"cpu\":[%s],\"cores\":[%s],", up + 0, (ld != "" ? ld : "0,0,0"), run, tot, all, cores printf "\"mem\":{\"total\":%d,\"free\":%d,\"avail\":%d,\"buffers\":%d,\"cached\":%d,\"shmem\":%d,\"sreclaim\":%d,\"swap\":%d,\"swapfree\":%d},", m["MemTotal"], m["MemFree"], av, m["Buffers"], m["Cached"], m["Shmem"], m["SReclaimable"], m["SwapTotal"], m["SwapFree"] printf "\"net\":{\"wan\":[%s],\"lan\":[%s],\"wan_dev\":\"%s\",\"lan_dev\":\"%s\",\"ifs\":[", w, la, wan, lan for (i = 1; i <= ifc; i++) { d = ifo[i]; if (!(d in ifc_v)) continue; printf "%s{\"n\":\"%s\",\"d\":\"%s\",\"i\":%d,\"c\":[%s]}", (o++ ? "," : ""), ifn[d], d, ifi[d], ifc_v[d] } printf "]},\"conn\":[%d,%d]", cc, cm }' $fl 2>/dev/null df -k / /tmp 2>/dev/null | awk 'NR == 2 { a = $2 + 0; b = $3 + 0 } NR == 3 { c = $2 + 0; d = $3 + 0 } END { printf ",\"flash\":[%d,%d],\"tmp\":[%d,%d]", a, b, c, d }' printf ',"fan":%s' "${fan:-null}" printf ',"freq":%s,"leases":%s,"temps":[%s],"htop":{"inst":%s,"ttyd":%s,"open":%s},%s}\n' \ "$((t / 1000))" "$(cat /tmp/dhcp.leases 2>/dev/null | grep -c .)" "$(_mon_temps)" \ "$([ -x /usr/bin/htop ] && echo true || echo false)" "$([ -x /usr/bin/ttyd ] && echo true || echo false)" \ "$([ -s "$JOBS_DIR/htop.pid" ] && kill -0 "$(cat "$JOBS_DIR/htop.pid" 2>/dev/null)" 2>/dev/null && echo true || echo false)" "$(_mon_static)" } monitor_procs() { local tot tot="$(awk '$1 == "cpu" { t = 0; for (i = 2; i <= NF; i++) t += $i; printf "%.0f", t; exit }' /proc/stat 2>/dev/null)" printf '{"total":%s,"p":[' "${tot:-0}" { ps w 2>/dev/null; echo '@@ZM@@'; cat /proc/[0-9]*/stat 2>/dev/null; } | awk ' function j(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 < " ") o = o " "; else o = o c } return o } !body && $0 == "@@ZM@@" { body = 1; next } !body && !hdr { hdr = 1; ci = index($0, "COMMAND"); next } !body { if ($1 ~ /^[0-9]+$/) { u[$1] = $2; c = ci ? substr($0, ci) : ""; sub(/^\{[^}]*\} /, "", c); cmd[$1] = c }; next } { l = $0; a = index(l, "("); b = 0 for (i = length(l); i > a; i--) if (substr(l, i, 1) == ")") { b = i; break } if (!a || !b) next p = substr(l, 1, a - 2) + 0; nm = substr(l, a + 1, b - a - 1) split(substr(l, b + 2), f, " ") printf "%s[%d,%d,\"%s\",\"%s\",%.0f,%.0f,%d,\"%s\",\"%s\"]", (n++ ? "," : ""), p, f[2], j(nm), f[1], f[12] + f[13], f[22] * 4, f[18], j(u[p]), j(substr(cmd[p], 1, 240)) }' printf ']}\n' } case "$1" in system_info) system_info; exit ;; monitor) monitor_snapshot; exit ;; monitor_procs) monitor_procs; exit ;; job_status) job_status "$2"; exit ;; log_tail) log_tail "$2"; exit ;; esac INSTALL="_zm_pkg_add"; DELETE="_zm_pkg_del"; UPDATE="_zm_pkg_update" TG_ARCH="" _tg_arch_init() { [ -n "$TG_ARCH" ] && return 0 if [ "$PKG" = "opkg" ]; then TG_ARCH="$(opkg print-architecture 2>/dev/null | awk '{print $2}' | tail -n1)" else TG_ARCH="$(apk --print-arch 2>/dev/null)"; fi } ZM_NL=' ' _zm_re() { case "$1" in *"$ZM_NL"*) return 1 ;; esac; printf '%s\n' "$1" | grep -qE -- "$2"; } ZM_TMO="" _zm_tmo_ok() { [ -n "$ZM_TMO" ] || { if timeout 1 true >/dev/null 2>&1; then ZM_TMO=1; else ZM_TMO=0; fi; } [ "$ZM_TMO" = 1 ] } _zm_tmo() { local s="$1" p w rc shift case "$(type "$1" 2>/dev/null)" in *function*) ;; *) if _zm_tmo_ok; then timeout "$s" "$@"; return; fi ;; esac case "$s" in ''|*[!0-9]*) s=60 ;; esac { "$@" <&3 3<&- & } 3<&0 p=$! ( i=0 while [ "$i" -lt "$s" ] && kill -0 "$p" 2>/dev/null; do sleep 1; i=$((i + 1)); done if kill -0 "$p" 2>/dev/null; then kill "$p" 2>/dev/null; sleep 1; kill -9 "$p" 2>/dev/null; fi ) >/dev/null 2>&1 /dev/null; rc=$? wait "$w" 2>/dev/null case "$rc" in 137|143) rc=124 ;; esac return $rc } 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 } ' _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 c="$JOBS_DIR/lanzone" v="" if [ -s "$c" ] && { [ ! -e /etc/config/firewall ] || [ "$c" -nt /etc/config/firewall ]; }; then read -r v < "$c"; [ -n "$v" ] && { echo "$v"; return 0; }; fi v="$(_zm_lan_zone_find)" [ -n "$v" ] && { printf '%s\n' "$v" > "$c.$$" 2>/dev/null && mv -f "$c.$$" "$c" 2>/dev/null; } echo "${v:-lan}" } _zm_lan_zone_find() { 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 ip="$(ifstatus lan 2>/dev/null | jsonfilter -e "@['ipv4-address'][0].address" 2>/dev/null)" [ -n "$ip" ] && { echo "$ip"; return 0; } for n in $(_zm_lan_nets); do [ "$n" = lan ] && continue 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 elif [ -f /usr/lib/opkg/status ]; then awk -v p="$1" '$1 == "Package:" { x = ($2 == p) } x && $1 == "Status:" && / (installed|unpacked)$/ { f = 1; exit } END { exit !f }' /usr/lib/opkg/status else opkg list-installed 2>/dev/null | grep -q "^$1 "; fi } _apk_ver() { local v="" [ -f /lib/apk/db/installed ] && v="$(awk -v p="$1" '/^P:/ { x = (substr($0, 3) == p) } x && /^V:/ { print substr($0, 3); exit }' /lib/apk/db/installed)" [ -n "$v" ] || v="$(apk info -v 2>/dev/null | sed -n "s/^$1-\([0-9][^ ]*\)\$/\1/p" | head -n1)" [ -n "$v" ] && printf '%s\n' "$v" } _kv_last() { local l v="" [ -f "$1" ] || return 0 while IFS= read -r l || [ -n "$l" ]; do case "$l" in "$2="*) v="${l#"$2="}" ;; esac; done < "$1" printf '%s' "$v" } _opkg_ver() { if [ -f /usr/lib/opkg/status ]; then awk -v p="$1" 'function out() { if (x && ok && v != "" && !d) { print v; d = 1 } } $1 == "Package:" { out(); if (d) exit; x = ($2 == p); v = ""; ok = 0; next } x && $1 == "Version:" { v = $2 } x && $1 == "Status:" && / (installed|unpacked)$/ { ok = 1 } END { out() }' /usr/lib/opkg/status else opkg list-installed 2>/dev/null | awk -v p="$1" '$1 == p { print $3; exit }'; 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 } /(write error|[Ww]rite error): [Bb]roken pipe|^[^ ]*: [Bb]roken pipe$/ { next } # tailscaled --cleanup при установке: на firewall4 нет iptables, а tailscale0 ещё не создан — это не ошибки /linuxfw: clear ip6?tables: exec: "ip6?tables": executable file not found/ { next } /router: enumerating tailscale0 addresses for cleanup failed/ { 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 bad h h="$(_mirror_host)" while :; do t0="$(date +%s)" echo " → Скачиваем свежий список пакетов из репозитория" _zm_run 150 $PKG_UPD > "/tmp/zm-pkg.$$" 2>&1 rc=$? _zm_pkg_fmt < "/tmp/zm-pkg.$$" bad=""; [ -n "$h" ] && _mirror_upd_bad "/tmp/zm-pkg.$$" "$h" && bad=1 rm -f "/tmp/zm-pkg.$$" if [ "$rc" = 0 ] && [ -z "$bad" ]; then echo " ✓ Список пакетов обновлён за $(( $(date +%s) - t0 )) с" return 0 fi _zm_pkg_unlock [ "$try" -ge 2 ] && break _zm_dns_ok "$(h="${h:-downloads.openwrt.org}"; echo "${h%%/*}")" || _zm_net_heal echo " ↻ $([ "$rc" = 0 ] && echo "Обновился не полностью" || echo "Не получилось") — пробуем ещё раз" try=$((try + 1)) sleep 3 done if { [ -n "$bad" ] || [ "$rc" != 0 ]; } && [ -n "$h" ]; then if [ -n "$bad" ] || ! _mirror_probe "$h" "$(_mirror_feed1)" >/dev/null; then _mirror_auto && return 0 fi fi if [ "$rc" = 0 ]; then echo " ! Список пакетов обновился не полностью — часть репозиториев не ответила" return 0 fi echo " ✗ Список пакетов не обновился — проверьте интернет и доступ к ${h:-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_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; _bk_auto "$@" >>"$log" 2>&1; "$@" >>"$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" | tr ' ' '\n' | grep -v '^/')" ] && [ "$(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%s\n' "$1" "$(date +%s)" > "$ZM_REBOOT_HINT" } _zm_reboot_hint_get() { local ts now [ -s "$ZM_REBOOT_HINT" ] || return 0 ts="$(sed -n 2p "$ZM_REBOOT_HINT" 2>/dev/null)" now="$(date +%s)" case "$ts" in ''|*[!0-9]*) rm -f "$ZM_REBOOT_HINT"; return 0 ;; *) [ $((now - ts)) -gt 120 ] && { rm -f "$ZM_REBOOT_HINT"; return 0; } ;; esac head -n1 "$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_ip_ok() { ping -4 -c 1 -W 2 77.88.8.8 >/dev/null 2>&1 || ping -4 -c 1 -W 2 1.1.1.1 >/dev/null 2>&1 || curl -s -o /dev/null --connect-timeout 4 --max-time 6 http://1.1.1.1/ >/dev/null 2>&1 } _zm_net_why() { if _zm_ip_ok; then echo " · Связь по IP есть, а имена сайтов роутер не находит — не работает DNS роутера. Откройте вкладку «DNS over HTTPS» (другой сервер или выключить), не поможет — перезагрузите роутер" else echo " · Роутер не достаёт до интернета даже по IP-адресу — перезагрузите роутер; не поможет — проверьте кабель и настройки провайдера (Сеть → Интерфейсы → WAN)" fi } _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 if _zm_ip_ok; then _rb_warn "Связь есть, а DNS роутера не отвечает — перезапускаем службы DNS" [ -x /etc/init.d/https-dns-proxy ] && /etc/init.d/https-dns-proxy enabled 2>/dev/null && _zm_run 30 /etc/init.d/https-dns-proxy restart >/dev/null 2>&1 /etc/init.d/dnsmasq restart >/dev/null 2>&1 if _zm_inet_wait 20; then echo " ✓ После перезапуска DNS интернет на роутере работает" return 0 fi 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 "Интернет так и не появился — перезагрузите роутер" _zm_net_why return 1 } _zm_after_remove() { if _zm_net_recover; then _zm_reboot_hint "$1 удалён. Желательно перезагрузить роутер." ZM_RB_NOTE=" Желательно перезагрузить роутер — кнопка «Перезагрузить» появилась внизу страницы на 2 минуты" else _zm_reboot_hint "$1 удалён, но интернет на роутере не отвечает. Перезагрузите роутер." ZM_RB_NOTE="!! Перезагрузите роутер — кнопка «Перезагрузить» появилась внизу страницы на 2 минуты" fi } _zm_rb_note() { [ -n "$ZM_RB_NOTE" ] && echo "$ZM_RB_NOTE" ZM_RB_NOTE="" return 0 } reboot_hint_clear() { rm -f "$ZM_REBOOT_HINT" printf '{"ok":true}\n' } 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_feed_host() { local h h="$(_mirror_host)" h="${h%%/*}" echo "${h:-downloads.openwrt.org}" } _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_GUARD_PID="$JOBS_DIR/fk-guard.pid" _fk_guard_on() { grep -q "option zm_ut_guard '1'" /etc/config/netshift 2>/dev/null; } _fk_guard_pass() { _fk_up && ! _job_running forkop && _fk_uc guard >/dev/null 2>&1; return 0; } _fk_guard() { local i=0 _fk_guard_on || { rm -f "$FK_GUARD_PID"; return 0; } [ -f "$FK_GUARD_PID" ] && kill -0 "$(cat "$FK_GUARD_PID" 2>/dev/null)" 2>/dev/null && return 0 mkdir -p "$JOBS_DIR" ( _fk_guard_pass while [ "$i" -lt 5 ]; do sleep 20 _fk_guard_on && _fk_ours || break _fk_guard_pass i=$((i + 1)) done rm -f "$FK_GUARD_PID" ) >/dev/null 2>&1 "$FK_GUARD_PID" 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; } _fk_engine_busy && return 0 if _fk_up; then rm -f "$f" _job_running forkop || _fk_heal _job_running forkop || _fk_zash_ensure quiet _job_running forkop || _fk_dns_watch _fk_guard 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 [ -s "$ZM_STATE_DIR/fk.home.$s" ] && _fk_home_sec "$s" >/dev/null 2>&1 & if [ "$(uci -q get netshift."$s".zm_auto_ping)" = 1 ] && [ "$(uci -q get netshift."$s".zm_sub_priority)" != 1 ]; then /opt/zapret-manager-luci/backend.sh forkop_ping_burst "$s" >/dev/null 2>&1 & _fk_fallback_sec "$s" >/dev/null 2>&1 & continue fi if [ "$(uci -q get netshift."$s".zm_direct_fallback)" = 1 ] || [ "$(uci -q get netshift."$s".zm_sub_priority)" = 1 ]; then _fk_fallback_sec "$s" >/dev/null 2>&1 & continue fi if [ "$first" = 1 ]; then _fk_heal_sec "$s" ""; first=0; else _fk_heal_sec "$s" ".$s"; fi done return 0 } _fk_ping_burst() { local sec="$1" lock="/tmp/zm-fk-pingloop.$1" pid i=0 case "$sec" in *[!A-Za-z0-9_]*|'') return 1 ;; esac if [ -d "$lock" ]; then pid="$(cat "$lock/pid" 2>/dev/null)" case "$pid" in ''|*[!0-9]*) ;; *) kill -0 "$pid" 2>/dev/null && return 0 ;; esac rm -rf "$lock" fi mkdir "$lock" 2>/dev/null || return 0 echo "$$" > "$lock/pid" trap "rm -rf '$lock'" EXIT while [ "$i" -lt 5 ]; do sleep 20 _fk_up || return 0 [ "$(uci -q get netshift."$sec".zm_auto_ping)" = 1 ] || return 0 _fk_fallback_sec "$sec" >/dev/null 2>&1 i=$((i + 1)) done return 0 } _fk_fallback_sec() ( local sec="$1" lock="/tmp/zm-fk-fallback.$1.lock" at=0 now pid r to priority case "$sec" in *[!A-Za-z0-9_]*|'') exit 1 ;; esac now="$(date +%s)" if [ -d "$lock" ]; then pid="$(cat "$lock/pid" 2>/dev/null)" case "$pid" in ''|*[!0-9]*) ;; *) kill -0 "$pid" 2>/dev/null && exit 0 ;; esac at="$(cat "$lock/at" 2>/dev/null)" case "$at" in ''|*[!0-9]*) at="$(stat -c %Y "$lock" 2>/dev/null)"; at="${at:-$now}" ;; esac [ $((now - at)) -gt 600 ] && rm -rf "$lock" fi mkdir "$lock" 2>/dev/null || exit 0 echo "$now" > "$lock/at" trap 'rm -rf "$lock"' EXIT r="$(_fk_uc fallwatch "$sec" 2>/dev/null)" if [ "$(printf '%s' "$r" | jsonfilter -e '@.manual' 2>/dev/null)" = true ]; then _fk_heal_sec "$sec" ".$sec"; exit 0; fi [ "$(printf '%s' "$r" | jsonfilter -e '@.changed' 2>/dev/null)" = true ] || exit 0 to="$(printf '%s' "$r" | jsonfilter -e '@.direct' 2>/dev/null)" priority="$(printf '%s' "$r" | jsonfilter -e '@.priority' 2>/dev/null)" if [ "$to" = true ]; then logger -t zapret-manager "Forkozz: VPN секции $sec недоступны — её правила временно идут напрямую" elif [ "$priority" = true ]; then logger -t zapret-manager "Forkozz: секция $sec выбрала первую доступную подписку по заданному порядку" else logger -t zapret-manager "Forkozz: VPN секции $sec снова доступен — возвращаем её правила через VPN"; fi ) _fk_home_sec() ( local sec="$1" lock="/tmp/zm-fk-home.$1" case "$sec" in *[!A-Za-z0-9_]*|'') exit 1 ;; esac if [ -d "$lock" ]; then [ -n "$(find "$lock" -maxdepth 0 -mmin +5 2>/dev/null)" ] && rm -rf "$lock" fi mkdir "$lock" 2>/dev/null || exit 0 trap 'rm -rf "$lock"' EXIT _fk_uc home "$sec" >/dev/null 2>&1 ) _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" "$f.hold"; return 0; } if [ "$(printf '%s' "$r" | jsonfilter -e '@.hold' 2>/dev/null)" = true ]; then name="$(printf '%s' "$r" | jsonfilter -e '@.name' 2>/dev/null)" if [ "$(cat "$f.hold" 2>/dev/null)" != "$name" ]; then printf '%s\n' "$name" > "$f.hold" logger -t zapret-manager "Forkozz: выбранный сервер «$name» не отвечает — держим его, как задано в настройке" fi return 0 fi _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)" if [ "$(printf '%s' "$r" | jsonfilter -e '@.back' 2>/dev/null)" = true ]; then logger -t zapret-manager "Forkozz: сервер «$name» не отвечает — переключились на ${to:-автовыбор}, вернёмся на него, когда он ответит" else logger -t zapret-manager "Forkozz: сервер «$name» не отвечает — переключились на ${to:-автовыбор}"; fi rm -f "$f" fi return 0 } _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 _job_running backup && return 0 _test_recover >/dev/null 2>&1 _zm_net_orphan && _zm_net_restore force >/dev/null 2>&1 if ! _fk_installed || ! _fk_foreign; then _fk_installed && [ ! -d /etc/sing-box ] && mkdir -p /etc/sing-box _fk_watch _fk_fast_patch _fk_mlkem_patch _fk_serial_patch _fk_listsvia_patch fi _st_colo_guard _st_vpn_watch _st_secs_vpn_watch _tg_watch _ab_watch _dn_watch _zo_doh_follow return 0 } _zm_job_skip() { case "$1" in versions|sysinfo|*_download|fk-guard) 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" ;; steer) rm -f "$ST_PHASE_FILE" 2>/dev/null; rmdir "$ST_RUN/cat.lock" 2>/dev/null; _st_warp_pre_clean ;; strategy_test) rm -f "$ZA_RUN" ;; 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" ;; backup) rm -rf "$BK_RUN".op.* "$BK_RUN".mk.* "$BK_DIR"/.*.part "$BK_DIR"/.mk.*; rmdir "$BK_RUN.lock" 2>/dev/null; /etc/init.d/cron start >/dev/null 2>&1 ;; 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 if [ -z "$busy" ]; then for f in /tmp/zm-run.* /tmp/zm-wait.* /tmp/zm-quiet.* /tmp/zm-pkg.*; do [ -e "$f" ] || continue _zm_tmp_live "$f" && continue rm -f "$f" 2>/dev/null done fi _zm_net_orphan && _zm_net_restore force >/dev/null 2>&1 _zm_pkg_unlock printf '{"ok":true,"stopped":[%s]}\n' "$list" } _zm_rpcd_kick() { local i=0 /etc/init.d/rpcd reload >/dev/null 2>&1 while [ "$i" -lt 8 ]; do sleep 1; i=$((i + 1)) if [ -n "$1" ]; then eval "$1" >/dev/null 2>&1 && return 0 else ubus -t 3 list zapret-manager >/dev/null 2>&1 && return 0; fi done /etc/init.d/rpcd restart >/dev/null 2>&1 return 1 } rpcd_watch() { local rc mark="/tmp/zm-rpcd-slow" 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 if ! ubus -t 5 list session >/dev/null 2>&1; then if [ -n "$(find "$mark" -mmin -5 2>/dev/null)" ]; then logger -t zapret-manager "rpcd не отвечает второй раз подряд — перезапускаем" rm -f "$mark" _zm_rpcd_kick 'ubus -t 3 list session' else touch "$mark"; fi return 0 fi 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 if [ -n "$(find "$mark" -mmin -5 2>/dev/null)" ]; then logger -t zapret-manager "rpcd завис второй раз подряд — перезапускаем, чтобы вернуть вход в LuCI" rm -f "$mark" _zm_rpcd_kick 'ubus -t 5 call session access "{\"ubus_rpc_session\":\"00000000000000000000000000000000\",\"scope\":\"ubus\",\"object\":\"session\",\"function\":\"access\"}"' else touch "$mark"; fi return 0 fi rm -f "$mark" _rpcd_plugin_watch return 0 } _rpcd_plugin_watch() { local pl=/usr/libexec/rpcd/zapret-manager 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 _zm_rpcd_kick '_rpcd_plugin_state /usr/libexec/rpcd/zapret-manager' 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_set() { case "$1" in micro|light|dark|ink|bento|minimal|retro|depth|graphite|oled|matrix|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' } # Скрытые пункты меню Web UI: id разделов через пробел. Хранится на роутере — одинаково # на всех устройствах. Пусто — показывать всё. UI_NAV_FILE="$ZM_STATE_DIR/ui.nav.hidden" ui_nav_get() { [ -s "$UI_NAV_FILE" ] || return 0; tr '\n' ' ' < "$UI_NAV_FILE" | sed 's/ */ /g; s/^ //; s/ $//'; } ui_nav_set() { local id out="" n=0 for id in $(printf '%s' "$1" | tr ',' ' '); do printf '%s' "$id" | grep -qE '^[a-z0-9_]{1,32}$' || { echo '{"error":"неверный раздел"}'; return 1; } [ "$id" = dashboard ] && continue case " $out " in *" $id "*) continue ;; esac out="$out $id"; n=$((n + 1)) [ "$n" -le 64 ] || { echo '{"error":"слишком много разделов"}'; return 1; } done mkdir -p "$ZM_STATE_DIR" if [ -n "$out" ]; then printf '%s\n' $out > "$UI_NAV_FILE"; else rm -f "$UI_NAV_FILE"; fi printf '{"ok":true,"hidden":"%s"}\n' "$(ui_nav_get)" } ui_flags_get() { local f="$ZM_STATE_DIR/ui.flags" out="" sep="" k if [ -s "$f" ]; then for k in $(grep -E '^[a-z0-9_]{1,48}$' "$f" | sort -u); do out="$out$sep\"$k\":1"; sep=","; done fi printf '{"flags":{%s}}\n' "$out" } ui_flag_set() { local f="$ZM_STATE_DIR/ui.flags" printf '%s' "$1" | grep -qE '^[a-z0-9_]{1,48}$' || { echo '{"error":"неверное имя"}'; return 1; } mkdir -p "$ZM_STATE_DIR" grep -qx "$1" "$f" 2>/dev/null || echo "$1" >> "$f" printf '{"ok":true}\n' } zapret_restart() { [ -n "$ZM_NO_RESTART" ] && return 0 [ -s "$ZC_FILE" ] && _zc_drop _yv_ipid_sync _zo_doh_sync 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 } # Название текущей стратегии по блоку NFQWS_OPT файла $CONF: «v7 Yv08 Dv1 QUIC», «своя»… _strat_label() { local s s=$(_nfq_names | sed 's/^#[[:space:]]*//; s/[[:space:]]*$//; /^udp443$/d; /^DoH$/d; /^[Nn][Oo][Cc][Hh][Aa][Nn][Gg][Ee]$/d' | tr '\n' ' ' | sed 's/ $//') _yv_ipid_on && s="$(printf '%s' "$s" | sed -E 's/(^| )(Yv[0-9]+)( |$)/\1\2z\3/')" _udp443_on && s="${s:+$s }QUIC" _zo_doh_on && s="${s:+$s }DoH" { _zc_has || [ -s "$ZC_FILE" ]; } && s="${s:+$s }Custom" # стратегию правили вручную и убрали строки-названия (#v7 и т.п.) — она есть, просто своя [ -z "$s" ] && _nfq_opt_body | grep -q -- '^[[:space:]]*--' && s="своя" printf '%s' "$s" } status() { local zr="not_installed" zr_running="false" zr_ver="" _test_recover if [ -f /etc/init.d/zapret ]; then zr="installed" zr_ver="$ZAPRET_VER" pgrep -f "/opt/zapre[t]/" >/dev/null 2>&1 && zr_running="true" fi local zr2="not_installed" zr2_running="false" zr2_luci="" if [ -f /etc/init.d/zapret2 ]; then zr2="installed" /etc/init.d/zapret2 status >/dev/null 2>&1 && zr2_running="true" zr2_luci="$(_z2_luci_path)" fi local strat="" fs_marker="" testing=false test_now="" ZM_NFQ_BODY="" zdd="" zdo=false ZM_YS="" ZM_UDP="" ZM_DOH="" ZM_RKN="" ZM_FITS="" if [ "$zr" = installed ]; then zdd="$(_zo_doh_hosts)" [ -n "$zdd" ] && zdo=true zdd="${zdd:-$ZO_DOH_DEF}" fi if [ -f "$CONF" ]; then ZM_NFQ_BODY="$(_nfq_opt_body)" ZM_YS="$(_yv_ipid_scan)" if _udp443_on; then ZM_UDP=1; else ZM_UDP=0; fi if _zo_doh_on; then ZM_DOH=1; else ZM_DOH=0; fi if _zo_rkn_on; then ZM_RKN=1; else ZM_RKN=0; fi if _zo_rkn_fits; then ZM_FITS=1; else ZM_FITS=0; fi strat=$(_strat_label) fs_marker=$(sed -n '/^# ZMFS:/{s/^# ZMFS://p;q}' "$CONF") # идёт «Тест стратегий»: в конфиге временно стоит проверяемая — показываем сохранённую стратегию пользователя if [ -s "$TEST_BACKUP" ] && _test_running; then testing=true; test_now="$strat" strat=$(CONF="$TEST_BACKUP"; ZM_NFQ_BODY=""; ZM_YS=""; ZM_UDP=""; ZM_DOH=""; ZM_RKN=""; ZM_FITS=""; _strat_label) fs_marker=$(sed -n '/^# ZMFS:/{s/^# ZMFS://p;q}' "$TEST_BACKUP") [ "$test_now" = "$strat" ] && test_now="" fi 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,"rkn":%s,"rkn_on":%s,"rkn_fits":%s,"rkn_count":%s,"wssize":%s,"ulist":%s,"ulist_on":%s,"ulist_count":%s,"testing":%s,"test_now":"%s","ipid":%s,"ipid_want":"%s","yt_tcp":%s,"z2_luci":"%s","zdoh":%s,"zdoh_on":%s,"zdoh_doms":"%s","zdoh_own":%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)" \ "$([ -f "$ZO_RKN_FLAG" ] && echo true || echo false)" \ "$([ -f "$CONF" ] && _zo_rkn_on && echo true || echo false)" \ "$([ -f "$CONF" ] && { _zo_rkn_fits || _zo_rkn_on; } && echo true || echo false)" \ "$(awk 'NF { n++ } END { print n + 0 }' "$ZO_RKN_FILE" 2>/dev/null | grep . || echo 0)" \ "$([ -f "$CONF" ] && _zo_wss_on && echo true || echo false)" \ "$([ -f "$ZO_USER_FLAG" ] && echo true || echo false)" \ "$([ -f "$CONF" ] && _zo_user_on && echo true || echo false)" \ "$(_zo_user_count)" "$testing" "$(esc "$test_now")" \ "$([ -f "$CONF" ] && _yv_ipid_on && echo true || echo false)" "$(_yv_ipid_want)" \ "$([ -f "$CONF" ] && [ "${ZM_YS%% *}" -gt 0 ] 2>/dev/null && echo true || echo false)" "$(esc "$zr2_luci")" \ "$([ -f "$ZO_DOH_FLAG" ] && echo true || echo false)" "$([ -f "$CONF" ] && _zo_doh_on && echo true || echo false)" \ "$(esc "$zdd")" "$zdo" } _z2_luci_path() { grep -ho '"admin/[A-Za-z0-9_/-]*zapret2[A-Za-z0-9_/-]*"' /usr/share/luci/menu.d/*.json 2>/dev/null | tr -d '"' | grep -v 'zapret-manager' | awk '{ print length($0) "\t" $0 }' | sort -n | head -n 1 | cut -f 2 } _do_install_zapret_core() { _ensure_deps if [ -f /etc/init.d/zapret2 ] && [ ! -f "$EXPERT_MODE_FILE" ]; then echo "ОШИБКА: установлен Zapret2, он несовместим с Zapret — сначала удалите Zapret2" return 1 fi local ver="$ZAPRET_VER" arch url tmp echo "==> Ставим Zapret $ver" 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/zapre[t]/" 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 _zc_lift echo "==> Применяем базовую стратегию v7" strategy_set_v v7 >/dev/null fi if ! _nochange_strategy; then echo "==> Настраиваем игровую стратегию Gv1" game_set 1 >/dev/null fi _zc_drop echo "==> Готово, Zapret установлен и настроен" } do_remove_zapret() { local rc=0 echo "==> Останавливаем Zapret" _zm_svc_off zapret for p in $(pgrep -f "/opt/zapre[t]/" 2>/dev/null); do kill -9 "$p" 2>/dev/null; done nft delete table inet zapret >/dev/null 2>&1 echo " ✓ Процессы nfqws остановлены, правила файрвола сняты" echo "==> Удаляем пакеты Zapret" _zm_pkg_wipe luci-i18n-zapret-ru luci-app-zapret zapret || rc=1 echo "==> Удаляем файлы, стратегии и списки" _zm_wipe /opt/zapret "$CONF" "$CONF-opkg" "$CONF.apk-new" /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" "$ZO_RKN_FLAG" "$ZO_USER_FLAG" "$ZO_IPID_FLAG" "$ZO_DOH_FLAG" && echo " ✓ Файлы удалены, остатков нет" || rc=1 crontab -l 2>/dev/null | awk '{ l = tolower($0) } l ~ /zapret/ && l !~ /zapret-manager|zapret2/ { next } { print }' | crontab - 2>/dev/null sed -i "\\|$ZA_TAG|d" "$CRON_FILE" 2>/dev/null echo " ✓ Задания Zapret убраны из расписания" _zm_rm_listed zapret || rc=1 /etc/init.d/firewall reload >/dev/null 2>&1 _zm_rm_done "$rc" "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 ;; restart) [ -x /etc/init.d/zapret ] || { echo '{"error":"Zapret не установлен"}'; return 1; } zapret_restart for p in 1 2 3 4 5; do pgrep -f "/opt/zapre[t]/" >/dev/null 2>&1 && break; sleep 1; done status ;; stop) /etc/init.d/zapret stop >/dev/null 2>&1 for p in $(pgrep -f "/opt/zapre[t]/" 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 _nochange_fetch /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() { local rc=0 echo "==> Останавливаем Zapret2" _zm_svc_off zapret2 for p in $(pgrep -f "/opt/zapret[2]/" 2>/dev/null); do kill -9 "$p" 2>/dev/null; done nft delete table inet zapret2 >/dev/null 2>&1 echo " ✓ Процессы остановлены, правила файрвола сняты" echo "==> Удаляем пакеты Zapret2" _zm_pkg_wipe luci-i18n-zapret2-ru luci-app-zapret2 zapret2 || rc=1 echo "==> Удаляем файлы и настройки" _zm_wipe /etc/config/zapret2 /etc/config/zapret2-opkg /etc/config/zapret2.apk-new /opt/zapret2 /etc/init.d/zapret2 /etc/rc.d/*zapret2 /etc/hotplug.d/*/*zapret2 /tmp/zapret2* && echo " ✓ Файлы удалены, остатков нет" || rc=1 _zm_cron_drop 'zapret2' 'Zapret2' _zm_rm_listed zapret2 || rc=1 /etc/init.d/firewall reload >/dev/null 2>&1 _zm_rm_done "$rc" "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 ;; restart) [ -x /etc/init.d/zapret2 ] || { echo '{"error":"Zapret2 не установлен"}'; return 1; } /etc/init.d/zapret2 restart >/dev/null 2>&1 for p in 1 2 3 4 5; do /etc/init.d/zapret2 status >/dev/null 2>&1 && break; sleep 1; done 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 } _zm_dl_sane() { [ -s "$1" ] && ! head -c 1024 "$1" | grep -qiE '<(!doctype|html|head|body)'; } _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 && _zm_dl_sane "$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" \ "cloudfunctions.net" } | 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]*$|DoH[ \t]*$)' UDP443_MARK_RX='^[ \t]*#[ \t]*udp443[ \t]*$' _nfq_drop_profiles() { local rc zl="" if [ ! -s "$ZC_FILE" ] && _zc_has; then _zc_lift; zl=1; fi _nfq_drop_profiles_raw "$@"; rc=$? [ -n "$zl" ] && _zc_drop return $rc } _nfq_drop_profiles_raw() { 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() { if [ -n "$ZM_NFQ_BODY" ]; then printf '%s\n' "$ZM_NFQ_BODY"; return 0; fi sed -n "/^[[:space:]]*option NFQWS_OPT '\$/,/^[[:space:]]*'\$/p" "$CONF" 2>/dev/null } ZC_FILE="$ZM_STATE_DIR/zapret.custom.lift" ZC_BAK="$ZM_STATE_DIR/zapret.custom.conf" ZC_AFTER="$ZM_STATE_DIR/zapret.custom.after" ZC_START_RX='^[ \t]*#[ \t]*customstart[ \t]*$' ZC_END_RX='^[ \t]*#[ \t]*customend[ \t]*$' _zc_has() { _nfq_opt_body | awk -v S="$ZC_START_RX" -v E="$ZC_END_RX" 'tolower($0) ~ S { s = 1 } s && tolower($0) ~ E { f = 1 } END { exit !f }'; } _zc_norm() { awk -v q="'" ' function flush( i) { while (nb > 0 && b[nb] == "--new") nb--; for (i = 1; i <= nb; i++) print b[i]; nb = 0 } !inb && /^[ \t]*option NFQWS_OPT \047[ \t]*$/ { print; inb = 1; nb = 0; next } inb && $0 ~ ("^[ \t]*" q "[ \t]*$") { flush(); print; inb = 0; next } inb { if ($0 == "--new" && (nb == 0 || b[nb] == "--new")) next; b[++nb] = $0; next } { print } END { if (inb) flush() }' "$CONF" > "$CONF.zc" && [ -s "$CONF.zc" ] && cat "$CONF.zc" > "$CONF" rm -f "$CONF.zc" } _zc_lift() { [ -f "$CONF" ] || return 0 [ -s "$ZC_FILE" ] && _zc_drop _zc_has || return 0 mkdir -p "$ZM_STATE_DIR" cp -f "$CONF" "$ZC_BAK" awk -v F="$ZC_FILE" -v q="'" -v S="$ZC_START_RX" -v E="$ZC_END_RX" ' !inb && /^[ \t]*option NFQWS_OPT \047[ \t]*$/ { inb = 1; print; next } inb && $0 ~ ("^[ \t]*" q "[ \t]*$") { inb = 0; print; next } inb && !c && tolower($0) ~ S { c = 1 } c { print > F; if (tolower($0) ~ E) c = 0; next } { print }' "$CONF" > "$CONF.zc" || { rm -f "$CONF.zc" "$ZC_FILE" "$ZC_BAK"; return 0; } if [ ! -s "$ZC_FILE" ] || [ ! -s "$CONF.zc" ]; then rm -f "$CONF.zc" "$ZC_FILE" "$ZC_BAK"; return 0; fi cat "$CONF.zc" > "$CONF"; rm -f "$CONF.zc" awk -v S="$ZC_START_RX" -v E="$ZC_END_RX" '{ a[NR] = $0 } END { for (i = 1; i <= NR; i++) { if (a[i] == "--new" && (i == 1 || i == NR || tolower(a[i - 1]) ~ S || tolower(a[i + 1]) ~ E)) continue; print a[i] } }' "$ZC_FILE" > "$ZC_FILE.n" && [ -s "$ZC_FILE.n" ] && mv -f "$ZC_FILE.n" "$ZC_FILE" rm -f "$ZC_FILE.n" _zc_norm cp -f "$CONF" "$ZC_AFTER" ZC_LIFTED=1 } _zc_drop() { [ -s "$ZC_FILE" ] || { rm -f "$ZC_FILE" "$ZC_BAK" "$ZC_AFTER"; ZC_LIFTED=""; return 0; } [ -f "$CONF" ] && grep -q "^[[:space:]]*option NFQWS_OPT '[[:space:]]*\$" "$CONF" || return 0 if _zc_has; then rm -f "$ZC_FILE" "$ZC_BAK" "$ZC_AFTER"; ZC_LIFTED=""; return 0; fi if [ -s "$ZC_BAK" ] && [ -f "$ZC_AFTER" ] && cmp -s "$CONF" "$ZC_AFTER"; then cat "$ZC_BAK" > "$CONF" else awk -v F="$ZC_FILE" ' !d && /^[ \t]*option NFQWS_OPT \047[ \t]*$/ { print; while ((getline l < F) > 0) print l; print "--new"; d = 1; next } { print }' "$CONF" > "$CONF.zc" && [ -s "$CONF.zc" ] && cat "$CONF.zc" > "$CONF" rm -f "$CONF.zc" _zc_norm fi rm -f "$ZC_FILE" "$ZC_BAK" "$ZC_AFTER" ZC_LIFTED="" } _nfq_names() { _nfq_opt_body | awk -v S="$ZC_START_RX" -v E="$ZC_END_RX" 'tolower($0) ~ S { c = 1; next } c { if (tolower($0) ~ E) c = 0; next } /^#/ { print }' } _udp443_on() { case "$ZM_UDP" in 1) return 0 ;; 0) return 1 ;; esac; _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; } ZM_TS_RX='fooling=([a-z0-9]+,)*ts(,|$)' _ts_has() { grep -qE -- "$ZM_TS_RX"; } _ts_parts() { awk -v pu="--filter-udp=$PORTS_UDP" -v pt="--filter-tcp=$PORTS_TCP" -v rx="$ZM_TS_RX" ' function flush( lab) { if (has) { seen[g ? "YouTube" : dc ? "Discord" : gm ? "игры" : "основная"] = 1 } has = 0; g = 0; dc = 0; gm = 0 } { sub(/^[ \t]+/, ""); sub(/[ \t\r]+$/, "") } $0 == "--new" { flush(); next } /^#/ || $0 == "" || $0 == "'"'"'" { next } $0 ~ rx { has = 1 } /zapret-hosts-google|^--filter-udp=443$/ { g = 1 } /discord|19294-19344|2053,2083/ { dc = 1 } $0 == pu || $0 == pt || /^--filter-(udp|tcp)=80,88,444-65535$/ { gm = 1 } END { flush(); n = split("основная YouTube Discord игры", O, " "); for (i = 1; i <= n; i++) if (O[i] in seen) print O[i] }' } _ts_yv_text() { [ -s "$(_yv_file)" ] && awk -v n="#$1" '$0 == n { f = 1; next } /^#Yv[0-9]/ { f = 0 } f' "$(_yv_file)"; } ts_check() { local kind="${1%%:*}" id="${1#*:}" out="" yv dv gv l _ts_add() { out="$out${out:+,}\"$(esc "$1")\""; } case "$kind" in v) _zm_re "$id" '^v([1-9]|10)$' || { echo '{"error":"некорректная версия"}'; return 1; } strategy_"$id" | _ts_has && _ts_add "основная · $id" if [ ! -f "$YV_OFF_FLAG" ] && [ -f "$CONF" ]; then yv="$(grep -oE '^#[[:space:]]*Yv[0-9]+$' "$CONF" | head -n1 | sed 's/^#[[:space:]]*//')" if [ -n "$yv" ] && [ "$yv" != Yv08 ] && [ -n "$(_ts_yv_text "$yv")" ]; then _ts_yv_text "$yv" | _ts_has && _ts_add "YouTube · $yv" else printf '%s\n' '--dpi-desync-fooling=ts' | _ts_has && _ts_add "YouTube · Yv08" fi fi if [ ! -f "$DV_OFF_FLAG" ] && [ -f "$CONF" ]; then dv="$(grep -oE '^#[[:space:]]*Dv[0-9]+$' "$CONF" | head -n1 | sed 's/^#[[:space:]]*Dv//')" _zm_re "${dv:-1}" '^(1[0-7]|[1-9])$' && Dv"${dv:-1}" | _ts_has && _ts_add "Discord · Dv${dv:-1}" fi gv="$(grep -oE '^#Gv[1-4](Xtreme)?$' "$CONF" 2>/dev/null | head -n1 | sed 's/^#Gv//; s/Xtreme$//')" if [ -n "$gv" ]; then { if [ "$gv" = 1 ]; then strategy_Gv1; else strategy_Gv "$gv"; fi; strategy_TCP_common; } | _ts_has && _ts_add "игры · Gv$gv" fi ;; flowseal) [ -s "$(_flowseal_file)" ] || { echo '{"ts":false,"parts":[]}'; return 0; } awk -v n="#$id" '$0 == n { f = 1; next } /^#/ && f { exit } f' "$(_flowseal_file)" | _ts_parts > "$JOBS_DIR/ts.parts" while IFS= read -r l; do _ts_add "$l"; done < "$JOBS_DIR/ts.parts" ;; youtube) _ts_yv_text "$id" | _ts_has && _ts_add "YouTube · $id" ;; dv) _zm_re "$id" '^(1[0-7]|[1-9])$' && Dv"$id" | _ts_has && _ts_add "Discord · Dv$id" ;; gv) _zm_re "$id" '^[1-4]$' && { if [ "$id" = 1 ]; then strategy_Gv1; else strategy_Gv "$id"; fi; strategy_TCP_common; } | _ts_has && _ts_add "игры · Gv$id" ;; text) printf '%s\n' "$id" | _ts_parts > "$JOBS_DIR/ts.parts" while IFS= read -r l; do _ts_add "$l"; done < "$JOBS_DIR/ts.parts" ;; current) [ -f "$CONF" ] || { echo '{"ts":false,"parts":[]}'; return 0; } _nfq_opt_body | _ts_parts > "$JOBS_DIR/ts.parts" while IFS= read -r l; do _ts_add "$l"; done < "$JOBS_DIR/ts.parts" ;; *) echo '{"error":"неизвестный вид стратегии"}'; return 1 ;; esac rm -f "$JOBS_DIR/ts.parts" printf '{"ts":%s,"parts":[%s]}\n' "$([ -n "$out" ] && echo true || echo false)" "$out" } 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 w _zm_re "$version" '^v([1-9]|10)$' || { echo '{"error":"некорректная версия"}'; return 1; } [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } _udp443_on && q=1 _zo_wss_on && w=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 _zo_reapply "$w" 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"; } # fake-файлы Flowseal, которых нет в пакете zapret (ACTIVE_*.bin — заглушки Windows-версии, не нужны) FLOWSEAL_FAKES="stun2.bin quic_initial_4pda_to.bin quic_initial_5ka_ru.bin quic_initial_lemana_pro.bin \ quic_initial_rutube_ru.bin quic_initial_steam_chat_com.bin quic_initial_steamcommunity_com.bin \ quic_initial_steamstatic_com.bin quic_initial_tencent_com.bin tls_clienthello_4pda_to.bin \ tls_clienthello_5ka_ru.bin tls_clienthello_awswaf_com.bin tls_clienthello_max_ru.bin \ tls_clienthello_sochi_park.bin tls_clienthello_steam_chat_com.bin tls_clienthello_steamstatic_com.bin \ tls_clienthello_teamspeak_com.bin tls_clienthello_www_sferum_ru.bin" do_add_fake_flow() { [ -d /opt/zapret ] || return 0 [ -f "$CONF" ] || return 0 local d=/opt/zapret/files/fake f msg=0 extra="" mkdir -p "$d" # плюс всё, на что ссылаются уже загруженные стратегии Flowseal [ -s "$(_flowseal_file)" ] && extra=$(grep -oE "$d/[A-Za-z0-9_.-]+\.bin" "$(_flowseal_file)" | sed "s|^$d/||" | sort -u) for f in $FLOWSEAL_FAKES $extra; do case "$f" in ACTIVE_*) continue ;; esac [ -s "$d/$f" ] && continue [ "$msg" = 0 ] && { echo "==> Скачиваем недостающие fake-файлы"; msg=1; } rm -f "$d/$f.zmdl" if wget -q --timeout=20 -U "Mozilla/5.0" -O "$d/$f.zmdl" "${FLOWSEAL_FAKE_RAW}/$f" 2>/dev/null \ && _zm_dl_sane "$d/$f.zmdl"; then mv -f "$d/$f.zmdl" "$d/$f" else rm -f "$d/$f.zmdl"; echo "!! Не удалось загрузить файл $f"; fi done } do_flowseal_download() { _ensure_deps local out zip tmp attempt=1 max_attempts=5 dst dst="$(_flowseal_file)"; out="$dst.new"; 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" rm -f "$out" "$zip" [ -s "$dst" ] && echo " · Остался прежний список Flowseal — его стратегии можно выбирать" return 1 fi mkdir -p "$tmp"; unzip -oq "$zip" -d "$tmp" || { echo "ОШИБКА распаковки"; rm -rf "$out" "$zip" "$tmp"; 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" # ACTIVE_*.bin — заглушки Windows-версии (winws подставляет туда активный fake), на роутере — реальные файлы 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" # остальные fake — под теми же именами в /opt/zapret/files/fake (недостающие докачиваются ниже) sed -i 's|"%BIN%\([A-Za-z0-9_.-]*\.bin\)"|/opt/zapret/files/fake/\1|g; s|%BIN%\([A-Za-z0-9_.-]*\.bin\)|/opt/zapret/files/fake/\1|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" local left left=$(grep -oE '%[A-Za-z_]+%' "$out" | sort -u | tr '\n' ' ') [ -n "$left" ] && echo "!! В стратегиях остались неизвестные плейсхолдеры: $left" if [ -d /opt/zapret ]; then echo "==> Добавляем fake-файлы из архива Flowseal" mkdir -p /opt/zapret/files/fake local fb fn for fb in "$base"/bin/*.bin; do [ -s "$fb" ] || continue fn=$(basename "$fb") case "$fn" in ACTIVE_*) continue ;; esac if [ ! -s "/opt/zapret/files/fake/$fn" ]; then cp -f "$fb" "/opt/zapret/files/fake/$fn" else case " $FLOWSEAL_FAKES " in *" $fn "*) cmp -s "$fb" "/opt/zapret/files/fake/$fn" 2>/dev/null || cp -f "$fb" "/opt/zapret/files/fake/$fn" ;; esac; fi done fi rm -rf "$tmp" "$zip" do_add_fake_flow local miss miss=$(grep -oE '/opt/zapret/files/fake/[A-Za-z0-9_.-]+\.bin' "$out" | sort -u | while read -r fb; do [ -s "$fb" ] || printf '%s ' "${fb##*/}"; done) [ -n "$miss" ] && [ -d /opt/zapret ] && echo "!! Нет fake-файлов: $miss— стратегии с ними работать не будут" if ! grep -q '^#' "$out"; then rm -f "$out" echo "ОШИБКА: в архиве Flowseal не нашлось ни одной стратегии" return 1 fi mv -f "$out" "$dst" echo "==> Готово, стратегий: $(grep -c '^#' "$dst")" } strategy_list_flowseal() { local f; f="$(_flowseal_file)" if [ "$1" = cached ] && [ ! -s "$f" ]; then printf '{"items":[],"none":true,"running":%s}\n' "$(_job_running flowseal_download && echo true || echo false)" return 0 fi if [ ! -s "$f" ] || [ "$1" = refresh ]; then job_start flowseal_download do_flowseal_download return fi printf '{"running":%s,"items":[%s]}\n' "$(_job_running flowseal_download && echo true || echo false)" "$( 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 w f="$(_flowseal_file)" [ -s "$f" ] || { echo '{"error":"список не загружен — сначала обновите"}'; return 1; } [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } case "$name" in *"$ZM_NL"*|*\\*) echo '{"error":"стратегия не найдена"}'; return 1 ;; esac 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 _zo_wss_on && w=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 _zo_reapply "$w" 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" case "$name" in *"$ZM_NL"*) echo '{"error":"стратегия не найдена"}'; return 1 ;; esac 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 '{"script":"%s","finland":%s,"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' "$(_zo_script)" "$(_hosts_block_status finland)" "$(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; } _zm_re "$num" '^(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_openai) printf '%s\n' \ "#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" ;; ai_claude) printf '%s\n' \ "#Claude" \ "45.155.204.190 claude.ai console.anthropic.com api.anthropic.com" ;; ai_gemini) printf '%s\n' \ "#Gemini" \ "45.155.204.190 gemini.google.com" ;; ai_aistudio) printf '%s\n' \ "#AIStudio" \ "45.155.204.190 aistudio.google.com generativelanguage.googleapis.com aitestkitchen.withgoogle.com aisandbox-pa.googleapis.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" ;; ai_grok) printf '%s\n' \ "#Grok" \ "45.155.204.190 grok.com accounts.x.ai assets.grok.com" ;; ai_copilot) printf '%s\n' \ "#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" \ "45.155.204.190 xboxdesignlab.xbox.com" ;; ai_deepl) printf '%s\n' \ "#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" ;; ai_elevenlabs) printf '%s\n' \ "#ElevenLabs" \ "45.155.204.190 elevenlabs.io api.us.elevenlabs.io elevenreader.io api.elevenlabs.io help.elevenlabs.io" ;; ai_windsurf) printf '%s\n' \ "#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" ;; ai_trae) printf '%s\n' \ "#Trae.ai" \ "45.155.204.190 trae-api-sg.mchost.guru api.trae.ai api-sg-central.trae.ai api16-normal-alisg.mchost.guru" ;; ai_manus) printf '%s\n' \ "#Manus" \ "45.155.204.190 manus.im api.manus.im" ;; ai_notion) printf '%s\n' \ "#Notion" \ "45.155.204.190 www.notion.so calendar.notion.so" ;; 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" ;; roblox) printf '%s\n' \ "#tr.rbxcdn.com" \ "108.156.22.8 tr.rbxcdn.com" \ "108.157.32.114 tr.rbxcdn.com" \ "18.65.147.108 tr.rbxcdn.com" \ "18.65.147.112 tr.rbxcdn.com" \ "13.224.181.18 tr.rbxcdn.com" \ "13.224.181.74 tr.rbxcdn.com" \ "54.230.253.22 tr.rbxcdn.com" \ "54.230.253.81 tr.rbxcdn.com" \ "54.230.253.48 tr.rbxcdn.com" \ "54.230.253.59 tr.rbxcdn.com" \ "143.204.214.34 tr.rbxcdn.com" \ "143.204.214.67 tr.rbxcdn.com" \ "143.204.214.92 tr.rbxcdn.com" \ "99.84.181.25 tr.rbxcdn.com" \ "99.84.181.63 tr.rbxcdn.com" \ "65.8.158.45 tr.rbxcdn.com" \ "65.8.158.112 tr.rbxcdn.com" ;; 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" ;; finland) echo "#Discord Finland"; awk 'BEGIN { for (i = 10000; i <= 10199; i++) print "104.25.158.178 finland" i ".discord.media" }' ;; *) 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_append() { [ -s "$HOSTS_FILE" ] && [ -n "$(tail -c 1 "$HOSTS_FILE" 2>/dev/null)" ] && echo >> "$HOSTS_FILE" echo "$1" >> "$HOSTS_FILE" } _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_BLOCKS="nalog ntc instagram librusec ai_openai ai_claude ai_gemini ai_aistudio ai_grok ai_copilot ai_deepl ai_elevenlabs ai_windsurf ai_trae ai_manus ai_notion twitch telegram spotify spotifyext rutor rutracker scell roblox githubraw github tapeop finland" hosts_status() { local blocks="$HOSTS_BLOCKS" b local geohide="" if grep -q '^### dns.malw.link: hosts file' "$HOSTS_FILE" 2>/dev/null; then geohide="malw" elif 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 echo "@@ZMB $b"; _hosts_block "$b"; done | awk -v hf="$HOSTS_FILE" 'function n(s) { gsub(/\r/, "", s); gsub(/[ \t]+/, " ", s); sub(/^ /, "", s); sub(/ $/, "", s); return s } function flush() { if (id == "") return; printf "%s{\"id\":\"%s\",\"enabled\":%s}", (cnt++ ? "," : ""), id, (total > 0 && found == total) ? "true" : "false" } BEGIN { while ((getline l < hf) > 0) have[n(l)] = 1; close(hf) } /^@@ZMB / { flush(); id = substr($0, 7); total = 0; found = 0; split("", seen); next } { k = n($0); if (k == "" || (k in seen)) next; seen[k] = 1; total++; if (k in have) found++ } END { flush() }' printf '],"extra":%s}\n' "$(_hosts_extra)" } 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" || _hosts_append "$line"; 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" ;; malw) url="${GH_RAW}/ImMALWARE/dns.malw.link/refs/heads/master/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" printf '{"error":"%s"}\n' "$([ "$region" = malw ] && echo "не удалось скачать hosts dns.malw.link" || echo "не удалось скачать GeoHide hosts")" return 1 fi if [ "$(grep -cE '^[[:space:]]*[0-9a-fA-F.:]+[[:space:]]+[A-Za-z0-9]' "$tmp")" -lt 20 ]; then rm -f "$tmp" echo '{"error":"скачался не файл hosts — попробуйте ещё раз позже"}' return 1 fi if ! mv "$tmp" "$HOSTS_FILE" 2>/dev/null; then rm -f "$tmp" echo '{"error":"не удалось записать /etc/hosts — проверьте свободное место на роутере"}' return 1 fi /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.zm-bak" 2>/dev/null printf '%s' "$content" > "$HOSTS_FILE" [ -n "$(tail -c 1 "$HOSTS_FILE" 2>/dev/null)" ] && echo >> "$HOSTS_FILE" /etc/init.d/dnsmasq restart >/dev/null 2>&1 printf '{"ok":true}\n' } ZM_UP_FILE="$JOBS_DIR/upload.part" ZM_UP_MAX=4194304 upload_part() { local part="$1" sz mkdir -p "$JOBS_DIR" case "$part" in first) : > "$ZM_UP_FILE" ;; next) [ -f "$ZM_UP_FILE" ] || { echo '{"error":"загрузка прервалась — сохраните ещё раз"}'; return 1; } ;; *) echo '{"error":"неверная часть файла"}'; return 1 ;; esac printf '%s' "$2" >> "$ZM_UP_FILE" sz="$(wc -c < "$ZM_UP_FILE" | tr -d ' ')" [ "${sz:-0}" -gt "$ZM_UP_MAX" ] && { rm -f "$ZM_UP_FILE"; echo '{"error":"текст слишком большой"}'; return 1; } printf '{"ok":true,"size":%s}\n' "${sz:-0}" } _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 _zm_re "$choice" '^([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 } # DISABLE_IPV6 может быть записан как '0', "0" или 0 (так его сохраняет и LuCI самого Zapret); нет строки — IPv6 выключен ZM_V6_OPT_RX="^[[:space:]]*option[[:space:]]+DISABLE_IPV6[[:space:]]+['\"]?" _ipv6_enabled_in_zapret() { [ -f "$CONF" ] && grep -Eq "${ZM_V6_OPT_RX}0['\"]?[[:space:]]*\$" "$CONF" && echo true || echo false } _zapret_v6_set() { if grep -Eq "${ZM_V6_OPT_RX}[^[:space:]]*[[:space:]]*\$" "$CONF"; then sed -i -E "s/${ZM_V6_OPT_RX}[^[:space:]]*[[:space:]]*\$/\toption DISABLE_IPV6 '$1'/" "$CONF" else sed -i "/^[[:space:]]*config[[:space:]]\{1,\}main[[:space:]]\{1,\}'\{0,1\}config'\{0,1\}[[:space:]]*\$/a\\ option DISABLE_IPV6 '$1'" "$CONF" fi } _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",%s}\n' \ "$(_quic_blocked)" "$(_ipv6_enabled_in_zapret)" "$(_flow_offloading_fix_applied)" "$(_expert_mode)" "$PKG" "$(_zt_json)" } # цели проверки IPv6: сначала по имени, потом по адресам DNS Google, Cloudflare и Яндекса ZM_V6_ADDRS="2001:4860:4860::8888 2606:4700:4700::1111 2a02:6b8::feed:0ff" ZM_V6_STATE="$JOBS_DIR/v6.state" _zm_v6_ms() { local a t for a in "$@"; do t="$(ping -6 -c1 -W2 "$a" 2>/dev/null | sed -n 's/.*time=\([0-9.]*\).*/\1/p' | head -n1)" [ -n "$t" ] && { echo "$t"; return 0; } done return 1 } _zm_v6_dns() { nslookup google.com 127.0.0.1 2>/dev/null | awk '/^Name:/ { f = 1 } f && /^Address/ && /:[0-9A-Fa-f]*:/ { ok = 1 } END { exit !ok }' } # Единая проверка IPv6 для баннера, «Проверки сети» и включения в Zapret: «мс цель» или ошибка _zm_v6_reach() { local a t for a in google.com $ZM_V6_ADDRS; do t="$(_zm_v6_ms "$a")" && { echo "$t $a"; return 0; } done return 1 } # состояние по результату _zm_v6_reach: live — работает, nodns — работает, но DNS не отдаёт IPv6-адреса, dead — не работает _zm_v6_state_of() { [ -n "$1" ] || { echo dead; return; } if [ "${1#* }" = google.com ] || _zm_v6_dns; then echo live; else echo nodns; fi } _zm_v6_save() { mkdir -p "$JOBS_DIR"; echo "$1" > "$ZM_V6_STATE"; } _zm_v6_probe() { _zm_v6_state_of "$(_zm_v6_reach)" } _zm_v6_cached() { local f="$ZM_V6_STATE" if [ ! -s "$f" ] || [ -n "$(find "$f" -mmin +10 2>/dev/null)" ]; then if mkdir "$f.lock" 2>/dev/null; then ( _zm_v6_probe > "$f.new" 2>/dev/null; [ -s "$f.new" ] && mv -f "$f.new" "$f"; rmdir "$f.lock" ) >/dev/null 2>&1 /dev/null)" ]; then rmdir "$f.lock" 2>/dev/null fi fi cat "$f" 2>/dev/null } system_check_connectivity() { local t4 t6 r dns=true st t4=$(ping -4 -c1 -W2 google.com 2>/dev/null | grep 'time=' | sed -E 's/.*time=([0-9.]+).*/\1/') r="$(_zm_v6_reach)"; t6="${r%% *}" st="$(_zm_v6_state_of "$r")" [ "$st" = nodns ] && dns=false _zm_v6_save "$st" printf '{"ipv4_ok":%s,"ipv4_ms":"%s","ipv6_ok":%s,"ipv6_ms":"%s","ipv6_dns":%s}\n' \ "$([ -n "$t4" ] && echo true || echo false)" "$(esc "$t4")" \ "$([ -n "$t6" ] && echo true || echo false)" "$(esc "$t6")" "$dns" } 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() { local r [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } if [ "$(_ipv6_enabled_in_zapret)" = true ]; then _zapret_v6_set 1 [ "$(_ipv6_enabled_in_zapret)" = false ] || { echo '{"error":"не удалось изменить настройку DISABLE_IPV6 в конфиге Zapret — ничего не изменено"}'; return 1; } zapret_restart printf '{"ok":true,"ipv6_enabled":false}\n' return fi if ! r="$(_zm_v6_reach)"; then _zm_v6_save dead echo '{"error":"IPv6 на роутере сейчас не работает: не ответили ни google.com, ни DNS Google, Cloudflare и Яндекса по IPv6. Включать его в Zapret не нужно"}' return 1 fi _zm_v6_save "$(_zm_v6_state_of "$r")" _zapret_v6_set 0 [ "$(_ipv6_enabled_in_zapret)" = true ] || { echo '{"error":"не удалось изменить настройку DISABLE_IPV6 в конфиге Zapret — ничего не изменено"}'; return 1; } 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" if ! grep -q 'ct original packets ge 30 flow offload @ft;' "$template"; then echo '{"error":"в шаблоне firewall4 нет ожидаемой строки flow offload — исправление не применилось, ничего не изменено"}' return 1 fi fw4 restart >/dev/null 2>&1 printf '{"ok":true,"flow_offloading_fix":true}\n' } ZM_FLASH_PATHS="/opt/zapret-manager-luci /usr/libexec/rpcd/zapret-manager /usr/share/rpcd/acl.d/luci-app-zapret-manager.json /usr/share/luci/menu.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 /www/zm /www/zm-webui.html /usr/share/zm-redbtn" _zm_kb() { [ $# -gt 0 ] || { echo 0; return 0; } du -sk "$@" 2>/dev/null | awk '{ s += $1 } END { print s + 0 }' } _zm_aged() { [ -e "$1" ] || [ -L "$1" ] || return 1 [ "${2:-0}" -gt 0 ] || return 0 [ -n "$(find "$1" -maxdepth 0 -mmin +"$2" 2>/dev/null)" ] } _zm_engine_refs() { local f for f in "$STL_ETC"/*.json "$STL_ETC"/*.yaml "$STL_ETC"/*.yml $( [ "$1" = engine ] || echo "$STL_KEEP $ST_DIR/model.svc $STL_TMP/spec.prev $ST_RUN/model" ); do [ -s "$f" ] && sed 's|\\/|/|g' "$f" done | tr '",[]{} \t\r' '\n\n\n\n\n\n\n\n\n' | sed "s/^'//; s/'\$//" | grep '^/' | sort -u } _zm_junk() { local refs="$JOBS_DIR/cleanup.refs.$$" mkdir -p "$JOBS_DIR" _zm_engine_refs > "$refs" 2>/dev/null if [ -s "$refs" ]; then _zm_junk_raw "$1" | awk -v refs="$refs" 'BEGIN { while ((getline l < refs) > 0) r[++n] = l; close(refs) } { for (i = 1; i <= n; i++) if (r[i] == $0 || index(r[i], $0 "/") == 1) next; print }' else _zm_junk_raw "$1" fi rm -f "$refs" return 0 } _zm_tmp_live() { local p="${1##*/}" p="${p#*.}"; p="${p%%.*}" case "$p" in ''|*[!0-9]*) return 1 ;; esac [ "$p" != "$$" ] && [ -d "/proc/$p" ] } _zm_junk_raw() { local age=0 f n d foreign busy="" foreign="$(_st_blocker 2>/dev/null)" _zm_busy_job >/dev/null 2>&1 && busy=1 for f in /tmp/zm-run.* /tmp/zm-wait.* /tmp/zm-quiet.* /tmp/zm-pkg.* /tmp/ytb-ct.* /tmp/ytb-diag.* /tmp/ytb-ins.* /tmp/ytb-dl /tmp/zm-zash /tmp/netshift-sbext.* /tmp/netshift-sblite.* \ /tmp/mihomo.gz /tmp/zashboard.zip /tmp/zashboard /tmp/metacubexd.tgz /tmp/metacubexd /tmp/tg-ws-proxy.ipk /tmp/tg-ws-proxy.apk /tmp/zm_uninstall_panel.sh /tmp/zm-backup-*.tar.gz \ "$JOBS_DIR"/install_tmp "$JOBS_DIR"/install_z2_tmp "$JOBS_DIR"/flowseal.zip "$JOBS_DIR"/flowseal_src "$JOBS_DIR"/tg-ws-proxy-rs.tar.gz \ "$JOBS_DIR"/netshift-src "$JOBS_DIR"/zm_head.* "$JOBS_DIR"/steer-list.* "$JOBS_DIR"/tgws_install.* \ "$JOBS_DIR"/geohide_hosts.tmp "$JOBS_DIR"/cleanup.* "$ST_RUN"/*.src "$ST_RUN"/cat.flat "$BK_DIR"/.*.part "$BK_DIR"/.mk.* "$JOBS_DIR"/bk.upload* \ /opt/zapret-manager-luci/*.zm-new /www/luci-static/resources/zapret-manager/*.zm-new "$ZM_STATE_DIR"/*.tmp "$ST_DIR"/*.tmp "$ST_DIR"/lists/*.tmp \ "$ST_USER_DIR"/*.tmp "$ST_USER_DIR"/*.tmp.* "$ST_SEC_DIR"/*/custom.*.tmp "$HOSTS_FILE.zmtmp" "$HOSTS_FILE.zmdrop"; do [ "$f" = "$JOBS_DIR/cleanup.$$" ] || [ "$f" = "$JOBS_DIR/cleanup.refs.$$" ] && continue _zm_tmp_live "$f" && continue if [ -n "$busy" ]; then case "$f" in *.zm-new|/tmp/zm_uninstall_panel.sh) ;; *) continue ;; esac; fi _zm_aged "$f" "$age" && echo "$f" done [ "$1" = install ] || { [ -e /tmp/zm_update_install.log ] && echo /tmp/zm_update_install.log; } [ -e "$ZM_UP_FILE" ] && echo "$ZM_UP_FILE" for f in /tmp/zm-rpc.*; do _zm_tmp_live "$f" && continue; _zm_aged "$f" 10 && echo "$f"; done if ! _job_running strategy_test; then for f in "$JOBS_DIR"/strategy_test/results_*.txt "$JOBS_DIR"/strategy_test/detail_*.txt "$JOBS_DIR"/strategy_test/log_*.txt "$JOBS_DIR"/strategy_test/urls.txt \ "$JOBS_DIR"/strategy_test/candidates.txt "$JOBS_DIR"/strategy_test/block.txt "$JOBS_DIR"/strategy_test/sort.* "$JOBS_DIR"/strategy_test/ok.* "$JOBS_DIR"/strategy_test/pids.*; do [ -e "$f" ] && echo "$f" done fi n="$(cat /tmp/bytetube-test/job.pid 2>/dev/null)" case "$n" in ''|*[!0-9]*) n="" ;; esac if [ -z "$n" ] || ! kill -0 "$n" 2>/dev/null; then for f in /tmp/bytetube-test/results.txt /tmp/bytetube-test/results.raw /tmp/bytetube-test/job.log /tmp/bytetube-test/job.pid /tmp/bytetube-test/stop; do [ -e "$f" ] && echo "$f" done fi for f in "$JOBS_DIR"/*.pid; do [ -f "$f" ] || continue n="$(basename "$f" .pid)" _job_running "$n" || [ -f "$JOBS_DIR/$n.log" ] || echo "$f" done for f in "$JOBS_DIR"/install_z* "$JOBS_DIR"/sysinfo.json "$JOBS_DIR"/sysinfo.res "$JOBS_DIR"/sysinfo.urls "$JOBS_DIR"/tzdet.* "$JOBS_DIR"/excl_arp_tmp \ "$JOBS_DIR"/yv_* "$JOBS_DIR"/nfq_drop_old "$JOBS_DIR"/nfq_drop_new "$JOBS_DIR"/steer.hide.in "$JOBS_DIR"/mixomo.hide.in "$JOBS_DIR"/forkop-svc.out "$JOBS_DIR"/forkop-lists.out \ "$ST_RUN"/dl.* "$ST_RUN"/used.* "$ST_RUN"/srs.*.done "$ST_RUN"/lists.json "$ZM_STATE_DIR"/latest.* "$ZM_STATE_DIR"/versions.json "$ZM_STATE_DIR"/steer.latest; do case "$f" in "$JOBS_DIR"/sysinfo.*) _job_running sysinfo && continue ;; esac if [ -n "$busy" ]; then case "$f" in "$JOBS_DIR"/*) continue ;; esac; fi [ -e "$f" ] && echo "$f" done for f in "$JOBS_DIR"/*.log; do [ -f "$f" ] || continue n="$(basename "$f" .log)" _job_running "$n" && continue echo "$f" [ -f "$JOBS_DIR/$n.pid" ] && echo "$JOBS_DIR/$n.pid" done if [ -d "$ZM_LISTS_DIR" ]; then for f in "$ZM_LISTS_DIR"/.*; do [ -f "$f" ] || continue [ -n "$busy" ] && continue echo "$f" done if [ -s "$RB_SHARE/services.conf" ] && [ -z "$foreign" ]; then d=" $(grep -v '^#' "$RB_SHARE/services.conf" | cut -d'|' -f3,4 | tr ',|\n' ' ') " for f in "$ZM_LISTS_DIR"/*.lst; do [ -f "$f" ] || continue case "$d" in *" ${f##*/} "*) ;; *) echo "$f" ;; esac done fi fi if [ -d "$ST_DIR/lists" ] && [ -s "$RB_SHARE/services.conf" ] && [ -z "$foreign" ] && ! _st_running; then ls "$ST_DIR/lists" 2>/dev/null | awk -F'|' -v sel="$ST_SEL" -v conf="$RB_SHARE/services.conf" -v rem="$ST_CAT_SVC" -v dir="$ST_DIR/lists" ' function load(file, l, a, c, m, i) { while ((getline l < file) > 0) { if (l ~ /^#/) continue; split(l, a, "|"); if (!(a[1] in on)) continue; m = split(a[8], c, ","); for (i = 1; i <= m; i++) if (c[i] != "") keep[c[i]] = 1 } close(file) } BEGIN { while ((getline l < sel) > 0) if (l != "") { on[l] = 1; keep[l] = 1 } close(sel); load(conf); load(rem) } { n = $0; sub(/\..*$/, "", n); if (n == "" || (n in keep)) next; print dir "/" $0 }' | while IFS= read -r f; do echo "$f" done fi return 0 } _zm_refs_missing() { local f stl_present || return 0 _zm_engine_refs engine 2>/dev/null | grep -E "^($ZM_LISTS_DIR|$ST_DIR/lists)/" | while IFS= read -r f; do [ -e "$f" ] || echo "$f" done } zm_refs_heal() { local f b id set n=0 m=0 left="" miss eng="" miss="$(_zm_refs_missing)" if [ "$1" = check ] || [ -z "$miss" ]; then printf '{"ok":true,"missing":%s,"restored":0,"left":0,"files":""}\n' "$(printf '%s' "$miss" | grep -c .)" return 0 fi if _st_running; then echo '{"error":"идёт операция Steer — списки проверим после неё"}' return 1 fi mkdir -p "$ST_RUN" for f in $miss; do m=$((m + 1)) b="${f##*/}" case "$b" in *[!A-Za-z0-9._-]*|.*) left="$left $f"; continue ;; esac case "$f" in "$ZM_LISTS_DIR"/*.lst) _rb_list_get "$b" >/dev/null 2>&1 ;; "$ST_DIR"/lists/*.base.dom) id="${b%.base.dom}" case "$id" in ''|*[!A-Za-z0-9_-]*) ;; *) 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.tmp" if [ -s "$f.tmp" ]; then mv -f "$f.tmp" "$f"; else rm -f "$f.tmp"; fi ;; esac ;; "$ST_DIR"/lists/*.srs|"$ST_DIR"/lists/*.dom|"$ST_DIR"/lists/*.pfx) set="${b%.*}" case "$set" in ''|*[!A-Za-z0-9_-]*) ;; *) rm -f "$ST_RUN/srs.$set.done" "$ST_RUN/dl.$set" _st_srs_get "$set" >/dev/null 2>&1 rm -f "$ST_RUN/srs.$set.done" "$ST_RUN/dl.$set" "$ST_RUN/$set.src" ;; esac ;; esac if [ -e "$f" ]; then n=$((n + 1)); else left="$left $f"; fi done if [ "$n" -gt 0 ]; then if stl_running; then _stl_t 60 steer reload >/dev/null 2>&1 eng=running elif [ -z "$(_st_blocker)" ] && _st_owns "steer-spec" && [ ! -f "$ST_OFF" ]; then if _zm_run 120 stl_restart >/dev/null 2>&1; then eng=started; else eng=stopped; fi else eng=stopped fi fi printf '{"ok":true,"missing":%s,"restored":%s,"left":%s,"engine":"%s","files":"%s"}\n' "$m" "$n" "$((m - n))" "$eng" "$(esc "$(echo $left)")" } _zm_mem_kb() { awk '/^MemAvailable:/ { print $2; f = 1; exit } /^MemFree:/ { m = $2 } END { if (!f) print m + 0 }' /proc/meminfo 2>/dev/null; } _zm_df_kb() { df -kP "$1" 2>/dev/null | awk 'NR == 2 { print $4 + 0; f = 1 } END { if (!f) print 0 }'; } _zm_usage_json() { local list="$JOBS_DIR/cleanup.$$" jk=0 jn=0 mkdir -p "$JOBS_DIR" _zm_junk "$1" > "$list" 2>/dev/null if [ -s "$list" ]; then jn="$(grep -c . "$list")" jk="$(_zm_kb $(cat "$list"))" fi rm -f "$list" printf '"flash_kb":%s,"lists_kb":%s,"tmp_kb":%s,"junk_kb":%s,"junk_n":%s,"flash_free_kb":%s,"tmp_free_kb":%s,"mem_free_kb":%s' \ "$(_zm_kb $ZM_FLASH_PATHS)" "$(_zm_kb "$ST_DIR/lists" "$ZM_LISTS_DIR")" "$(_zm_kb "$JOBS_DIR")" "${jk:-0}" "${jn:-0}" \ "$(_zm_df_kb /opt/zapret-manager-luci)" "$(_zm_df_kb /tmp)" "$(_zm_mem_kb)" } zm_cleanup() { local mode="$1" j f k n=0 tk=0 fk=0 m0 m1 list="$JOBS_DIR/cleanup.$$" if [ "$mode" != install ]; then if j="$(_zm_busy_job)"; then printf '{"error":"%s"}\n' "$(esc "идёт операция «$j» — дождитесь её окончания и запустите очистку")" return 1 fi if [ -f /tmp/zm_update_install.sh ]; then echo '{"error":"идёт обновление панели — дождитесь его окончания"}' return 1 fi fi mkdir -p "$JOBS_DIR" m0="$(_zm_mem_kb)" _zm_junk "$mode" > "$list" 2>/dev/null while IFS= read -r f; do case "$f" in /tmp/zm_update_install.sh) continue ;; /tmp/zm[-_]*|/tmp/ytb-*|/tmp/bytetube-test/?*|/tmp/netshift-sbext.*|/tmp/netshift-sblite.*|/tmp/mihomo.gz|/tmp/zashboard|/tmp/zashboard.zip|/tmp/metacubexd|/tmp/metacubexd.tgz|/tmp/tg-ws-proxy.ipk|/tmp/tg-ws-proxy.apk) ;; /tmp/zapret-manager-luci/?*|/opt/zapret-manager-luci/?*|/etc/zm-steer/?*|/etc/hosts.zmtmp|/etc/hosts.zmdrop) ;; /www/luci-static/resources/zapret-manager/?*|/www/luci-static/resources/view/zapret-manager/?*) ;; *) continue ;; esac case "$f" in */../*|*/..) continue ;; esac [ -e "$f" ] || [ -L "$f" ] || continue k="$(_zm_kb "$f")" rm -rf "$f" 2>/dev/null [ -e "$f" ] && continue n=$((n + 1)) case "$f" in /tmp/*) tk=$((tk + k)) ;; *) fk=$((fk + k)) ;; esac done < "$list" rm -f "$list" for f in "$ZM_STATE_DIR"/*.lock; do [ -d "$f" ] && _zm_aged "$f" 5 && rmdir "$f" 2>/dev/null done _st_running || rm -f "$ST_RUN"/dl.* "$ST_RUN"/used.* "$ST_RUN"/srs.*.done 2>/dev/null rmdir "$ST_DIR/lists" "$ZM_LISTS_DIR" 2>/dev/null rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* 2>/dev/null if [ "$mode" != install ]; then sync echo 3 > /proc/sys/vm/drop_caches 2>/dev/null fi m1="$(_zm_mem_kb)" k=$(( ${m1:-0} - ${m0:-0} )); [ "$k" -gt 0 ] || k=0 printf '{"ok":true,"removed":%s,"freed_flash_kb":%s,"freed_tmp_kb":%s,"freed_mem_kb":%s,"refs_missing":%s,%s}\n' "$n" "$fk" "$tk" "$k" "$(_zm_refs_missing | grep -c .)" "$(_zm_usage_json "$mode")" } system_uninstall_panel() { local script="/tmp/zm_uninstall_panel.sh" j if [ -z "$ZM_PG_SELF" ] && 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 dns_net_gone >/dev/null 2>&1 if [ -s /tmp/zapret-manager-luci/resolv.conf.zm-bak ]; then if [ -e /tmp/resolv.conf ]; then cat /tmp/zapret-manager-luci/resolv.conf.zm-bak > /tmp/resolv.conf 2>/dev/null || true else cat /tmp/zapret-manager-luci/resolv.conf.zm-bak > /etc/resolv.conf 2>/dev/null || true; fi fi for zm_d in $(sed -n 's/^conf-dir=\([^,]*\).*/\1/p' /var/etc/dnsmasq.conf.* 2>/dev/null | sort -u) /tmp/dnsmasq.d /tmp/dnsmasq.*.d; do if [ -f "$zm_d/zm-adblock.conf" ]; then rm -f "$zm_d/zm-adblock.conf"; zm_dns=1; fi if [ -f "$zm_d/zm-adblock-stats.conf" ]; then rm -f "$zm_d/zm-adblock-stats.conf"; zm_dns=1; fi done nft delete table inet zm_adblock_free >/dev/null 2>&1 [ -s /var/run/zm-adblock-free.pid ] && kill "$(cat /var/run/zm-adblock-free.pid)" 2>/dev/null rm -f /var/run/zm-adblock-free.pid /var/etc/zm-adblock-free.conf /var/etc/zm-adblock-free.conf.macs [ -n "$zm_dns" ] && /etc/init.d/dnsmasq restart >/dev/null 2>&1 [ -s /tmp/zapret-manager-luci/htop.pid ] && kill "$(cat /tmp/zapret-manager-luci/htop.pid)" 2>/dev/null [ -z "$ZM_SAVE" ] && /opt/zapret-manager-luci/backend.sh htop_gone >/dev/null 2>&1 ZM_OPT="/opt/zapret-manager-luci" if [ -n "$ZM_SAVE" ] && [ -d "$ZM_OPT/state" ]; then grep -F '/opt/zapret-manager-luci/backend.sh' /etc/crontabs/root 2>/dev/null | grep -v '# zm-rpcd-watch$' > "$ZM_OPT/state/cron.saved" [ -s "$ZM_OPT/state/cron.saved" ] || rm -f "$ZM_OPT/state/cron.saved" rm -rf "$ZM_OPT"/state/*.lock "$ZM_OPT"/state/inet "$ZM_OPT"/state/inet.new 2>/dev/null fi ZM_KEEP="$(cat /etc/steer/*.json /etc/steer/*.yaml /etc/steer/*.yml 2>/dev/null | sed 's|\\/|/|g' | tr '",[]{} \t\r' '\n\n\n\n\n\n\n\n\n' | sed "s/^'//; s/'\$//" | grep "^$ZM_OPT/lists/" | sort -u)" if [ -n "$ZM_KEEP" ] || { [ -n "$ZM_SAVE" ] && { [ -d "$ZM_OPT/state" ] || [ -d "$ZM_OPT/backups" ]; }; }; then find "$ZM_OPT" \( -type f -o -type l \) 2>/dev/null | while IFS= read -r zm_f; do case "$zm_f" in "$ZM_OPT"/state/*|"$ZM_OPT"/backups/*) [ -n "$ZM_SAVE" ] && continue ;; esac printf '%s\n' "$ZM_KEEP" | grep -qxF "$zm_f" || rm -f "$zm_f" done find "$ZM_OPT" -depth -type d -exec rmdir {} \; 2>/dev/null ZM_OPT="" fi ZM_PATHS="$ZM_OPT /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/* \ /tmp/zm-run.* /tmp/zm-wait.* /tmp/zm-quiet.* /tmp/zm-pkg.* /tmp/zm-rpc.* /tmp/zm_update_install.log \ /tmp/zm-rpcd-plugin.stamp /tmp/zm-rpcd-slow /tmp/zm-zash /tmp/zm-doh-failover.* /tmp/zm-doh.* /tmp/zm-dohfo.* \ /tmp/zm-adblock-stats /tmp/zm-fk-* /tmp/zm-backup-*.tar.gz /var/run/dnsmasq/zm-queries.log \ /etc/hosts.zmtmp /etc/hosts.zmdrop /etc/hosts.zm-bak \ /www/zm /www/zm-webui.html /etc/zm-warp-own.conf /usr/bin/zmw /usr/bin/zms" zm_try=0 while :; do rm -rf $ZM_PATHS 2>/dev/null zm_left=0 for zm_f in $ZM_PATHS; do [ -e "$zm_f" ] || [ -L "$zm_f" ] && zm_left=1; done [ "$zm_left" = 0 ] && break [ "$zm_try" -ge 4 ] && break zm_try=$((zm_try + 1)) sleep 1 done [ -s /etc/zm-steer/owned ] || rm -rf /usr/share/zm-redbtn [ -z "$ZM_SAVE" ] && [ ! -s /etc/zm-steer/owned ] && rm -rf /etc/zm-steer [ -z "$ZM_SAVE" ] && rm -f /lib/upgrade/keep.d/zapret-manager 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 [ "$1" = wipe ] || sed -i '1i ZM_SAVE=1' "$script" [ -n "$ZM_PG_SELF" ] && sed -i '1s/^sleep 1$/sleep 8/' "$script" chmod 0755 "$script" ( sh "$script" >/dev/null 2>&1 & ) printf '{"ok":true,"kept":%s}\n' "$([ "$1" = wipe ] && echo false || echo true)" } PG_ORDER="steer forkozz mixomo awg bytetube zapret zapret2 tgproxy stgws tailscale terminal htop adblock doh dnsnet hosts panel" _pg_title() { case "$1" in zapret) echo "Zapret" ;; zapret2) echo "Zapret2" ;; bytetube) echo "ByeTube (ByeDPI, hev-socks5-tunnel)" ;; steer) echo "Steer (ядро steer, WARP, подписки)" ;; forkozz) echo "Forkozz (netshift, sing-box)" ;; mixomo) echo "Mixomo (Mihomo, MagiTrickle)" ;; awg) echo "AmneziaWG" ;; tgproxy) echo "TG WS Proxy (MTProto, SOCKS5, Rust)" ;; stgws) echo "sTGWS" ;; tailscale) echo "Tailscale (телефон через роутер)" ;; terminal) echo "Терминал (ttyd)" ;; htop) echo "htop (Монитор ресурсов)" ;; adblock) echo "Блокировка рекламы" ;; doh) echo "DNS over HTTPS (https-dns-proxy)" ;; dnsnet) echo "Браузеры и российские сайты (записи DNS)" ;; hosts) echo "Записи панели в hosts" ;; panel) echo "Zapret Manager (LuCI, Web UI, настройки, zms и zmw)" ;; esac } _pg_group() { case "$1" in zapret|zapret2|bytetube) echo block ;; steer|forkozz|mixomo|awg|tgproxy|stgws|tailscale) echo vpn ;; adblock|doh|dnsnet|hosts) echo dns ;; terminal|htop) echo tools ;; panel) echo panel ;; esac } _pg_inst() { case "$1" in zapret) [ -f /etc/init.d/zapret ] || _pg_pkg zapret ;; zapret2) [ -f /etc/init.d/zapret2 ] || _pg_pkg zapret2 ;; bytetube) [ -x /usr/bin/bytetube ] ;; steer) [ -s "$ST_OWNED" ] ;; forkozz) _fk_ours ;; mixomo) _mx_installed || [ -x /etc/init.d/magitrickle ] ;; awg) _awg_installed ;; tgproxy) [ -x /etc/init.d/tg-ws-proxy ] || [ -x "$TG_BIN_GO" ] || [ -x "$TG_BIN_RS" ] ;; stgws) [ -x /etc/init.d/tgws ] || _pg_pkg tgws ;; tailscale) _tsc_installed || _pg_pkg tailscale ;; terminal) _term_installed ;; htop) [ -x /usr/bin/htop ] || _pg_pkg htop ;; adblock) _ab_on ;; doh) _doh_pkg ;; dnsnet) [ -s "$DN_OWN" ] ;; hosts) _pg_hosts_has ;; panel) return 0 ;; *) return 1 ;; esac } _pg_st_dir() { if [ ! -s "$ST_OWNED" ] && _fk_installed && [ -z "$(ls -A "$ST_DIR" 2>/dev/null | grep -vxE 'services\.remote|catalog\.(idx|meta|url|off|err)')" ]; then return 0; fi echo "$ST_DIR" } _pg_trace() { case "$1" in zapret) echo "pkg:luci-i18n-zapret-ru pkg:luci-app-zapret pkg:zapret /opt/zapret /etc/config/zapret /etc/config/zapret-opkg /etc/init.d/zapret /etc/rc.d/*[0-9]zapret /etc/firewall.zapret /etc/hotplug.d/*/*zapret /etc/config/zapret.apk-new /tmp/zapret $(_zm_luci_files zapret) nft:zapret cron:zm-autobest $ZO_RKN_FLAG $ZO_USER_FLAG $ZO_IPID_FLAG $ZO_DOH_FLAG $ZA_MODE_FILE $ZA_LAST $ZA_YT_FILE $ZA_LAST_YT $ZA_TS $ZA_TIME_FILE $TEST_BACKUP $ZC_FILE $ZC_BAK $ZC_AFTER $YV_OFF_FLAG $DV_OFF_FLAG $TEST_DIR $TEST_CUSTOM_FILE" ;; zapret2) echo "pkg:luci-i18n-zapret2-ru pkg:luci-app-zapret2 pkg:zapret2 /opt/zapret2 /etc/config/zapret2 /etc/config/zapret2-opkg /etc/init.d/zapret2 /etc/rc.d/*zapret2 /etc/hotplug.d/*/*zapret2 /etc/config/zapret2.apk-new /tmp/zapret2* $(_zm_luci_files zapret2) nft:zapret2 cronrx:zapret2" ;; bytetube) echo "pkg:byedpi /usr/bin/ciadpi /etc/init.d/byedpi /etc/rc.d/*byedpi /etc/config/byedpi /etc/config/byedpi-opkg /usr/bin/bytetube /etc/config/bytetube /etc/init.d/bytetube /etc/rc.d/*bytetube /usr/libexec/bytetube /usr/share/bytetube /etc/bytetube /etc/hotplug.d/firewall/90-bytetube /usr/share/nftables.d/chain-pre/forward/50-bytetube.nft /var/etc/bytetube /var/run/bytetube.started /tmp/bytetube-test /lib/upgrade/keep.d/bytetube /tmp/dhcp.ytb.bak dnsconf:bytetube.conf cronrx:bytetube" _mx_installed || echo "pkg:hev-socks5-tunnel /usr/bin/hev-socks5-tunnel /etc/config/hev-socks5-tunnel /etc/config/hev-socks5-tunnel-opkg /etc/init.d/hev-socks5-tunnel /etc/rc.d/*hev-socks5-tunnel /etc/hev-socks5-tunnel" ;; steer) echo "$(_pg_st_dir) $ST_TGWS_WARP $ST_WARP_OWN_KEEP cron:zm-steer cron:zm-wfix cron:zm-subupd cron:zm-subping" ;; forkozz) echo "pkg:luci-i18n-netshift-ru pkg:luci-app-netshift pkg:netshift $FK_FILES $(_fk_naive_own) $FK_MARK $FK_EXT_FAIL $FK_NAIVE_FAIL $FK_DEPS_OWN $ZM_STATE_DIR/fk.zash.fail $ZM_STATE_DIR/fk.watch $ZM_STATE_DIR/fk.dnsfo $ZM_STATE_DIR/latest.netshift $FK_SB_OWN nft:$FK_NFT cronrx:netshift" [ -z "$(_fk_sb_users)" ] && echo "pkg:sing-box-tiny pkg:sing-box pkg:sing-box-extended /usr/bin/sing-box /usr/bin/.sing-box.zm-keep /etc/init.d/sing-box /etc/rc.d/*sing-box /etc/sing-box /etc/config/sing-box /etc/config/sing-box-opkg /etc/config/sing-box.apk-new /var/run/sing-box /tmp/sing-box /usr/share/sing-box /usr/lib/libcronet.so /usr/libexec/sing-box-core /etc/sing-box-version.cache" ;; mixomo) echo "$MIHOMO_BIN $MIHOMO_DIR /tmp/mihomo* /tmp/zashboard /tmp/zashboard.zip /tmp/metacubexd /tmp/metacubexd.tgz /etc/init.d/mihomo /etc/rc.d/*mihomo pkg:magitrickle /etc/magitrickle /etc/init.d/magitrickle /etc/rc.d/*magitrickle /etc/config/magitrickle /etc/config/magitrickle-opkg /usr/bin/magitrickle* /usr/sbin/magitrickle* /var/run/magitrickle* nft:magitrickle uci:network.Mihomo uci:firewall.Mihomo uci:firewall.lan_to_Mihomo cronrx:mihomo cronrx:magitrickle" _bt_installed || echo "pkg:hev-socks5-tunnel /usr/bin/hev-socks5-tunnel /etc/config/hev-socks5-tunnel /etc/config/hev-socks5-tunnel-opkg /etc/init.d/hev-socks5-tunnel /etc/rc.d/*hev-socks5-tunnel /etc/hev-socks5-tunnel" ;; awg) echo "pkg:luci-i18n-amneziawg-ru pkg:luci-app-amneziawg pkg:luci-proto-amneziawg pkg:amneziawg-tools pkg:kmod-amneziawg $AWG_DIR /usr/bin/awg /usr/bin/awg-quick /lib/netifd/proto/amneziawg.sh /lib/modules/*/amneziawg.ko /etc/modules.d/*amneziawg* $AWG_KMOD_MANUAL $(_zm_luci_files amneziawg) $AWF_MODE $AWF_SKIP $AWF_STATE $AWF_LOG cron:zm-awgfix" ;; tgproxy) echo "pkg:tg-ws-proxy /usr/bin/tg-ws-proxy /etc/init.d/tg-ws-proxy /etc/tg-ws-proxy /etc/tg-ws-proxy.conf /etc/config/tg-ws-proxy $TG_BIN_GO $TG_BIN_GO.new $TG_INIT_GO $TG_BIN_RS $TG_INIT_RS $TG_SECRET_RS_FILE $TG_VER_GO_FILE $TG_VER_RS_FILE /etc/config/tg-ws-proxy-rs $TG_WATCH_FLAG /tmp/tg-ws-proxy.ipk /tmp/tg-ws-proxy.apk /etc/rc.d/*tg-ws-proxy* cron:zm-tg-auto cronrx:tg-ws-proxy" ;; stgws) echo "pkg:tgws /etc/init.d/tgws /usr/sbin/tgws /usr/sbin/stgws /etc/config/tgws /etc/rc.d/*tgws* /var/lib/tgws /var/lib/stgws nft:tgws nft:stgws cronrx:tgws" ;; tailscale) echo "cron:zm-tailscale $ZM_STATE_DIR/latest.tsc pkg:luci-i18n-tailscale-community-ru pkg:luci-app-tailscale-community /etc/init.d/tailscale-settings /etc/rc.d/*tailscale-settings /www/luci-static/resources/view/tailscale.js /usr/share/luci/menu.d/luci-app-tailscale-community.json /usr/share/rpcd/acl.d/luci-app-tailscale-community.json /usr/share/rpcd/ucode/tailscale.uc pkg:tailscale /usr/sbin/tailscale /usr/sbin/tailscaled /etc/init.d/tailscale /etc/rc.d/*tailscale /etc/config/tailscale /etc/config/tailscale-opkg /etc/config/tailscale.apk-new /etc/tailscale /var/lib/tailscale $TSC_NFT $TSC_HOOK $TSC_SYSCTL $TSC_DIR $TSC_UPLOG uci:network.$TSC_NET uci:firewall.$TSC_FW_RULE" ;; terminal) echo "pkg:luci-i18n-ttyd-ru pkg:luci-app-ttyd pkg:ttyd /usr/bin/ttyd /etc/config/ttyd /etc/config/ttyd-opkg /etc/config/ttyd.apk-new /etc/init.d/ttyd /etc/rc.d/*ttyd $(_zm_luci_files ttyd)" ;; htop) echo "pkg:htop /usr/bin/htop /etc/htoprc /root/.config/htop $MON_TTYD_OWN" ;; adblock) echo "$AB_DIR $AB_STATS_DIR $AB_STATS_LOG dnsconf:zm-adblock.conf dnsconf:zm-adblock-stats.conf nft:zm_adblock_free /var/run/zm-adblock-free.pid /var/etc/zm-adblock-free.conf /var/etc/zm-adblock-free.conf.macs cron:zm-adblock cron:zm-abstats" ;; doh) echo "pkg:luci-i18n-https-dns-proxy-ru pkg:luci-app-https-dns-proxy pkg:https-dns-proxy /usr/sbin/https-dns-proxy /etc/uci-defaults/*https-dns-proxy* $(_zm_luci_files https-dns-proxy) /etc/config/https-dns-proxy /etc/init.d/https-dns-proxy /etc/rc.d/*https-dns-proxy /etc/config/https-dns-proxy-opkg /etc/config/https-dns-proxy.apk-new /etc/config/https-dns-proxy.zm-prev $DOH_FORCE_MODE_FILE $DOH_BOOT_FILE $DOH_FO_FILE $DOH_OWN_FILE $DOH_FO_STATE $DOH_FO_EVENTS /tmp/zm-doh-failover.lock cron:zm-dohwatch" ;; dnsnet) echo "$DN_FILE $DN_OWN" ;; hosts) echo "$HOSTS_FILE.zm-bak $HOSTS_FILE.bak" ;; panel) echo "/opt/zapret-manager-luci /www/zm /www/zm-webui.html /usr/libexec/rpcd/zapret-manager /usr/share/rpcd/acl.d/luci-app-zapret-manager.json /usr/share/luci/menu.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 /usr/share/zm-redbtn /usr/bin/zmw /usr/bin/zms /lib/upgrade/keep.d/zapret-manager uci:uhttpd.zmweb cronrx:/opt/zapret-manager-luci/backend.sh" ;; esac } _pg_hosts_geo() { grep -qE '^### (geohide\.ru|dns\.malw\.link): hosts file' "$HOSTS_FILE" 2>/dev/null; } _pg_hosts_lines() { local b; for b in $HOSTS_BLOCKS; do _hosts_block "$b"; done; } _pg_hosts_has() { _pg_hosts_geo && return 0 _pg_hosts_lines | 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 !~ /^#/ && (k in have)) { f = 1; exit } } END { exit !f }' } _pg_found() { local t f d for t in $(_pg_trace "$1"); do case "$t" in pkg:*) _pg_pkg "${t#pkg:}" && echo "пакет ${t#pkg:}" ;; nft:*) nft list table inet "${t#nft:}" >/dev/null 2>&1 && echo "правила nft ${t#nft:}" ;; cron:*) grep -qF "# ${t#cron:}" "$CRON_FILE" 2>/dev/null && echo "задания расписания (# ${t#cron:})" ;; cronrx:*) grep -qF "${t#cronrx:}" "$CRON_FILE" 2>/dev/null && echo "задания расписания (${t#cronrx:})" ;; uci:*) uci -q get "${t#uci:}" >/dev/null 2>&1 && echo "настройка ${t#uci:}" ;; dnsconf:*) for d in $(dnsmasq_confdirs) /tmp/dnsmasq.d; do [ -e "$d/${t#dnsconf:}" ] && { echo "$d/${t#dnsconf:}"; break; }; done ;; /*) for f in $t; do { [ -e "$f" ] || [ -L "$f" ]; } && echo "$f"; done ;; esac done 2>/dev/null [ "$1" = hosts ] && _pg_hosts_has && { _pg_hosts_geo && echo "hosts заменён на GeoHide / dns.malw.link" || echo "строки блоков панели в /etc/hosts"; } return 0 } _pg_pkg() { [ -n "$PG_PKGLIST" ] || { _pkg_is_installed "$1"; return; } grep -qxF "$1" "$PG_PKGLIST" 2>/dev/null } purge_scan() { local id out="" sep="" st n items f mkdir -p "$JOBS_DIR" PG_PKGLIST="$JOBS_DIR/pg.pkgs.$$" if [ -f /lib/apk/db/installed ]; then sed -n 's/^P://p' /lib/apk/db/installed elif [ -f /usr/lib/opkg/status ]; then sed -n 's/^Package: //p' /usr/lib/opkg/status else opkg list-installed 2>/dev/null | cut -d' ' -f1; fi > "$PG_PKGLIST" for id in $PG_ORDER; do [ "$id" = forkozz ] && _fk_foreign && continue f="$JOBS_DIR/pg.$id.$$" _pg_found "$id" | sort -u > "$f" n="$(grep -c . "$f")" if _pg_inst "$id"; then st=installed; elif [ "$n" -gt 0 ]; then st=tails; else st=clean; fi items="$(head -n 8 "$f" | awk '{ gsub(/["\\]/, ""); printf "%s\"%s\"", (NR > 1 ? "," : ""), $0 }')" rm -f "$f" out="$out$sep{\"id\":\"$id\",\"title\":\"$(esc "$(_pg_title "$id")")\",\"group\":\"$(_pg_group "$id")\",\"state\":\"$st\",\"n\":$n,\"items\":[$items]}" sep="," done rm -f "$PG_PKGLIST"; PG_PKGLIST="" printf '{"items":[%s],"busy":"%s"}\n' "$out" "$(esc "$(_zm_busy_job)")" } _pg_sweep() { local t f d n=0 cfg for t in $(_pg_trace "$1"); do case "$t" in pkg:*) _pkg_is_installed "${t#pkg:}" && { ZM_PKG_QUIET=1 _zm_pkg_purge "${t#pkg:}" >/dev/null 2>&1; n=$((n + 1)); } ;; nft:*) nft list table inet "${t#nft:}" >/dev/null 2>&1 && { nft delete table inet "${t#nft:}" >/dev/null 2>&1; n=$((n + 1)); } ;; cron:*) grep -qF "# ${t#cron:}" "$CRON_FILE" 2>/dev/null && { grep -vF "# ${t#cron:}" "$CRON_FILE" > "$CRON_FILE.zm"; cat "$CRON_FILE.zm" > "$CRON_FILE"; rm -f "$CRON_FILE.zm"; PG_CRON=1; n=$((n + 1)); } ;; cronrx:*) grep -qF "${t#cronrx:}" "$CRON_FILE" 2>/dev/null && { grep -vF "${t#cronrx:}" "$CRON_FILE" > "$CRON_FILE.zm"; cat "$CRON_FILE.zm" > "$CRON_FILE"; rm -f "$CRON_FILE.zm"; PG_CRON=1; n=$((n + 1)); } ;; uci:*) uci -q get "${t#uci:}" >/dev/null 2>&1 && { uci -q delete "${t#uci:}"; cfg="${t#uci:}"; uci -q commit "${cfg%%.*}"; n=$((n + 1)); } ;; dnsconf:*) for d in $(dnsmasq_confdirs) /tmp/dnsmasq.d; do [ -e "$d/${t#dnsconf:}" ] && { rm -f "$d/${t#dnsconf:}"; n=$((n + 1)); }; done ;; /*) for f in $t; do { [ -e "$f" ] || [ -L "$f" ]; } || continue; case "$f" in /|/etc|/usr|/opt|/tmp|/www|/var|/root) continue ;; esac; rm -rf "$f" 2>/dev/null; n=$((n + 1)); done ;; esac done 2>/dev/null PG_SWEPT="$n" } _pg_remove() { _zm_after_remove() { :; } _zm_rm_done() { if [ "$1" = 0 ]; then echo "==> Готово: $2 удалён полностью, остатков нет" else echo " · $2: после обычного удаления кое-что осталось — сейчас добьём по списку"; fi return 0 } case "$1" in zapret) do_remove_zapret ;; zapret2) do_remove_zapret2 ;; bytetube) do_bytetube_purge ;; steer) do_steer_remove ;; forkozz) do_fk_remove "tun=delete;sb=remove" ;; mixomo) do_mixomo_remove ;; awg) ZM_NO_NETCHECK=1 do_awg_remove all ;; tgproxy) do_tg_remove_mtproto; do_tg_remove_socks5; do_tg_remove_rust ;; stgws) do_tgws_remove ;; tailscale) do_tsc_remove ;; terminal) do_term_remove ;; htop) do_htop_remove ;; adblock) adblock_action off >/dev/null 2>&1; rm -rf "$AB_DIR"; rm -f "$JOBS_DIR"/adblock.*; echo " ✓ Блокировка рекламы выключена, списки, белый список и статистика удалены" ;; doh) do_doh_remove ;; dnsnet) dns_net_gone; echo " ✓ Записи «Браузеры и российские сайты» убраны из настроек DNS" ;; hosts) if _pg_hosts_geo; then printf '%s\n' "127.0.0.1 localhost" "" "::1 localhost ip6-localhost ip6-loopback" "ff02::1 ip6-allnodes" "ff02::2 ip6-allrouters" > "$HOSTS_FILE" echo " ✓ hosts, заменённый на GeoHide / dns.malw.link, возвращён к стандартному" else _pg_hosts_lines | _hosts_drop_lines echo " ✓ Строки блоков панели убраны из hosts, свои записи остались" fi ;; esac } do_purge() { local ids="$1" id n=0 total=0 rc=0 left panel="" j skip="" _fk_foreign && ids="$(printf ',%s,' "$ids" | sed 's/,forkozz,/,/; s/^,//; s/,$//')" for id in $PG_ORDER; do case ",$ids," in *",$id,"*) [ "$id" = panel ] && panel=1 || total=$((total + 1)) ;; esac; done _rb_say "Полное удаление: выбрано $total$([ -n "$panel" ] && echo " и сама панель")" PG_CRON="" for id in $PG_ORDER; do case ",$ids," in *",$id,"*) ;; *) continue ;; esac [ "$id" = panel ] && continue n=$((n + 1)) echo "" _rb_say "[$n/$total] $(_pg_title "$id")" left="" if [ "$id" = awg ] && _st_installed && _st_warp_on && case ",$ids," in *",steer,"*) false ;; *) true ;; esac; then echo "!! Пропущено: AmneziaWG нужен Steer — через него идут его сервисы. Удалите AmneziaWG вместе со Steer или сначала уберите WARP в Steer" skip=1 continue fi [ "$id" = awg ] && _fk_foreign && left="$(for j in $(_awg_ifaces); do _awg_fk_live "$j" && echo "$j"; done)" if [ -n "$left" ]; then echo "!! Пропущено: через туннель $(echo $left) работает NetShift. Выберите в нём другое подключение или выключите его, потом удалите AmneziaWG" skip=1 continue fi ( _pg_remove "$id" ) 2>&1 _pg_sweep "$id" [ "${PG_SWEPT:-0}" -gt 0 ] && echo " ✓ Добито оставшихся хвостов: $PG_SWEPT" left="$(_pg_found "$id" | sort -u)" if [ -n "$left" ]; then rc=1 echo "!! Осталось:"; printf '%s\n' "$left" | head -n 8 | sed 's/^/ · /' else echo " ✓ $(_pg_title "$id") — ничего не осталось" fi done echo "" [ -n "$PG_CRON" ] && /etc/init.d/cron restart >/dev/null 2>&1 if [ "$total" -gt 0 ]; then /etc/init.d/firewall reload >/dev/null 2>&1 /etc/init.d/dnsmasq restart >/dev/null 2>&1 rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* 2>/dev/null _zm_net_recover || rc=1 fi if [ -n "$panel" ]; then _rb_say "Удаляем саму панель Zapret Manager — через несколько секунд страница перестанет отвечать" rm -rf /usr/bin/zms /usr/share/zm-redbtn ZM_PG_SELF=1 system_uninstall_panel wipe >/dev/null 2>&1 echo "==> Готово: всё выбранное удалено, панель удаляется" return 0 fi if [ "$rc" = 0 ] && [ -n "$skip" ]; then echo "!! Готово, кроме AmneziaWG — почему он пропущен, написано выше"; return 1 elif [ "$rc" = 0 ]; then echo "==> Готово: всё выбранное удалено полностью, хвостов не осталось" else echo "!! Готово, но кое-что осталось — список выше. Перезагрузите роутер и запустите полное удаление ещё раз"; fi return "$rc" } purge_run() { local v="$1" id ids="" j set -f for id in $(printf '%s' "$v" | tr -c 'a-z0-9,' ' ' | tr ',' ' '); do [ "$id" = forkozz ] && _fk_foreign && continue case " $PG_ORDER " in *" $id "*) case ",$ids," in *",$id,"*) ;; *) ids="$ids${ids:+,}$id" ;; esac ;; esac done set +f [ -n "$ids" ] || { echo '{"error":"ничего не выбрано"}'; return 1; } if j="$(_zm_busy_job)"; then printf '{"error":"%s"}\n' "$(esc "идёт операция «$j» — дождитесь её окончания")"; return 1; fi job_start purge do_purge "$ids" } BK_DIR="/opt/zapret-manager-luci/backups" BK_KEEPD="/lib/upgrade/keep.d/zapret-manager" BK_AUTO_OFF="$BK_DIR/auto.off" BK_AUTO_N=5 BK_KEEPD_KB=8192 BK_ORDER="zapret zapret2 bytetube awg steer forkozz mixomo tgproxy stgws tailscale terminal htop doh adblock dnsnet hosts" BK_NET="network firewall dhcp" BK_UP="$JOBS_DIR/bk.upload" BK_RUN="$JOBS_DIR/bk" _bk_plural() { local a=$(($1 % 100)) b=$(($1 % 10)); if [ "$a" -gt 10 ] && [ "$a" -lt 20 ]; then echo "$4"; elif [ "$b" = 1 ]; then echo "$2"; elif [ "$b" -ge 2 ] && [ "$b" -le 4 ]; then echo "$3"; else echo "$4"; fi; } _bk_kb() { local k="${1:-0}"; if [ "$k" -lt 1024 ]; then echo "$k КБ"; else echo "$((k / 1024)),$((k % 1024 * 10 / 1024)) МБ"; fi; } _bk_name() { case "$1" in zapret) echo Zapret ;; zapret2) echo Zapret2 ;; bytetube) echo ByeTube ;; steer) echo Steer ;; forkozz) echo Forkozz ;; mixomo) echo Mixomo ;; awg) echo AmneziaWG ;; tgproxy) echo "TG WS Proxy" ;; stgws) echo sTGWS ;; tailscale) echo Tailscale ;; terminal) echo "Терминал" ;; htop) echo htop ;; doh) echo "DNS over HTTPS" ;; adblock) echo "Блокировка рекламы" ;; dnsnet) echo "Записи DNS для браузеров" ;; hosts) echo "Hosts" ;; panel) echo "Настройки панели" ;; *) echo "$1" ;; esac } _bk_chip() { local n s case "$1" in zapret) s="$(_strat_label 2>/dev/null)"; echo "Zapret${s:+ · $s}" ;; steer) s="$(cat "$ST_EXIT" 2>/dev/null)" case "$s" in warp) s=WARP ;; vpn) s=VPN ;; *) s="" ;; esac n="$(_st_secs 2>/dev/null | grep -c .)" [ "${n:-0}" -gt 0 ] && s="${s:+$s · }$((n + 1)) $(_bk_plural $((n + 1)) секция секции секций)" echo "Steer${s:+ · $s}" ;; awg) n="$(_awg_ifaces 2>/dev/null | grep -c .)" if [ "${n:-0}" -gt 0 ]; then echo "AmneziaWG · $n $(_bk_plural "$n" интерфейс интерфейса интерфейсов)"; else echo AmneziaWG; fi ;; doh) s="$(_doh_name "$(_doh_cur_id 2>/dev/null)" 2>/dev/null)"; s="${s%% · *}"; echo "DoH${s:+ · $s}" ;; tgproxy) s="" [ -f /etc/init.d/tg-ws-proxy ] && s="MTProto" [ -x "$TG_BIN_GO" ] && s="${s:+$s, }SOCKS5" [ -x "$TG_BIN_RS" ] && s="${s:+$s, }Rust" echo "TG WS Proxy${s:+ · $s}" ;; *) _bk_name "$1" ;; esac } _bk_comps() { local id; for id in $BK_ORDER; do _pg_inst "$id" >/dev/null 2>&1 && printf '%s ' "$id"; done; return 0; } _bk_svcs() { local t for t in $(_pg_trace "$1" 2>/dev/null); do case "$t" in /etc/init.d/*) [ -x "$t" ] && echo "${t#/etc/init.d/}" ;; esac done [ "$1" = steer ] && [ -x /etc/init.d/steer ] && echo steer return 0 } _bk_skip() { case "$1" in /tmp/*|/var/*|/usr/*|/lib/*|/www/*|/proc/*|/sys/*|/dev/*|/etc/init.d/*|/etc/rc.d/*|/etc/hotplug.d/*|/etc/uci-defaults/*|/etc/modules.d/*|/etc/modules-boot.d/*) return 0 ;; "$BK_DIR"|"$BK_DIR"/*) return 0 ;; /opt/zapret-manager-luci/backend.sh*|/opt/zapret-manager-luci/*.uc|/opt/zapret-manager-luci/*.zm-new|/opt/zapret-manager-luci/CHANGELOG*|/opt/zapret-manager-luci/rpcd-plugin*) return 0 ;; /opt/zapret/ipset/*|/opt/zapret2/ipset/*|/opt/zapret/init.d/openwrt/custom.d/*|/opt/zapret2/init.d/openwrt/custom.d/*|/opt/zapret/files/fake/*|/opt/zapret2/files/fake/*) ;; /opt/zapret/*|/opt/zapret2/*) return 0 ;; "$MIHOMO_DIR"/ui|"$MIHOMO_DIR"/ui/*|"$MIHOMO_DIR"/*.dat|"$MIHOMO_DIR"/*.mmdb|"$MIHOMO_DIR"/*.metadb) return 0 ;; *.tmp|*.tmp.*|*.zm-new|*.apk-new|*-opkg|*.lock|*/lock|*.pid) return 0 ;; esac return 1 } _bk_elf() { [ "$(dd if="$1" bs=4 count=1 2>/dev/null | tail -c 3)" = ELF ]; } _bk_paths() { local t case "$1" in sys) echo /etc/hosts; for t in $BK_NET; do echo "/etc/config/$t"; done ;; *) for t in $(_pg_trace "$1" 2>/dev/null); do case "$t" in /*) echo "$t" ;; esac; done ;; esac } _bk_collect() { local c p r="$BK_RUN.col.$$" mkdir -p "$JOBS_DIR" : > "$r.roots" for c in "$@"; do _bk_paths "$c" | while IFS= read -r p; do [ -e "$p" ] || [ -L "$p" ] || continue _bk_skip "$p" && continue echo "$p" >> "$r.roots" if [ -d "$p" ] && [ ! -L "$p" ]; then find "$p" \( -type f -o -type l \) 2>/dev/null; else echo "$p"; fi | while IFS= read -r f; do _bk_skip "$f" || printf '%s\t%s\n' "$c" "$f" done done done | awk -F '\t' '!seen[$2]++' > "$r" sed 's/["\\]/\\&/g; s/.*/"&"/' "$r.roots" | xargs sh -c 'find "$@" -type f \( -perm -100 -o -size +64k \) 2>/dev/null' sh 2>/dev/null | while IFS= read -r p; do _bk_skip "$p" || ! _bk_elf "$p" || echo "$p" done > "$r.elf" if [ -s "$r.elf" ]; then awk -F '\t' 'NR == FNR { e[$0] = 1; next } !($2 in e)' "$r.elf" "$r"; else cat "$r"; fi rm -f "$r" "$r.roots" "$r.elf" } _bk_sums() { local t="$BK_RUN.sum.$$" f cat > "$t.in" echo "//" > "$t.s" cut -f 2 "$t.in" | while IFS= read -r f; do [ -L "$f" ] && printf '%s\tL:%s\n' "$f" "$(readlink "$f")"; done >> "$t.s" cut -f 2 "$t.in" | while IFS= read -r f; do [ -L "$f" ] || [ ! -f "$f" ] || echo "$f"; done | sed 's/["\\]/\\&/g; s/.*/"&"/' | xargs md5sum 2>/dev/null | awk '{ s = $1; sub(/^[^ ]+ /, ""); print $0 "\t" s }' >> "$t.s" awk -F '\t' 'NR == FNR { if (NR > 1) s[$1] = $2; next } { print $1 "\t" (($2 in s) ? s[$2] : "-") "\t" $2 }' "$t.s" "$t.in" rm -f "$t.in" "$t.s" } _bk_cron_rx() { local id t echo '/opt/zapret-manager-luci/' for id in $PG_ORDER; do for t in $(_pg_trace "$id" 2>/dev/null); do case "$t" in cron:*) echo "# ${t#cron:}" ;; cronrx:*) echo "${t#cronrx:}" ;; esac done done | sort -u } _bk_cron_ours() { grep -F -f "$1" "$CRON_FILE" 2>/dev/null; return 0; } _bk_sysopt() { local o for o in zonename timezone; do printf 'sys=%s=%s\n' "$o" "$(uci -q get "system.@system[0].$o")"; done printf 'sys=ntp=%s\n' "$(uci -q get system.ntp.server | tr '\n' ' ' | sed 's/ *$//')" printf 'sys=ntpon=%s\n' "$(uci -q get system.ntp.enabled)" } _bk_pkgver() { if [ "$PKG" = apk ]; then _apk_ver "$1"; else _opkg_ver "$1"; fi; } _bk_tar() { local out="$1" lst="$2" if tar --help 2>&1 | grep -qE '^[[:space:]]+-T'; then sed 's#^/##' "$lst" > "$lst.rel" tar -czf "$out" -C / -T "$lst.rel" 2>/dev/null else ( IFS=' ' set -f cd / && tar -czf "$out" $(sed 's#^/##' "$lst") ) 2>/dev/null fi local rc=$? rm -f "$lst.rel" return $rc } _bk_free_kb() { df -k "$1" 2>/dev/null | awk 'NR > 1 && $4 ~ /^[0-9]+$/ { v = $4; f = 1 } END { print (f ? v : -1) }'; } _bk_lock() { local i=0 mkdir -p "$JOBS_DIR" while ! mkdir "$BK_RUN.lock" 2>/dev/null; do i=$((i + 1)) [ "$i" -gt 60 ] && { rm -rf "$BK_RUN.lock"; mkdir "$BK_RUN.lock" 2>/dev/null; break; } sleep 1 done return 0 } _bk_unlock() { rmdir "$BK_RUN.lock" 2>/dev/null; return 0; } _bk_meta() { sed -n "s/^$2=//p" "$BK_DIR/$1.meta" 2>/dev/null | head -n 1; } _bk_ids() { ls "$BK_DIR" 2>/dev/null | sed -n 's/^\([0-9]\{8\}-[0-9]\{6\}[0-9a-z-]*\)\.zmb$/\1/p' | sort; } _bk_id_ok() { case "$1" in ''|*[!0-9a-z-]*) return 1 ;; esac; [ -f "$BK_DIR/$1.zmb" ]; } _bk_make() { local kind="$1" name="$2" reason="$3" id w comps c n raw free sz st tmp dst t mkdir -p "$BK_DIR" "$JOBS_DIR" id="$(date +%Y%m%d-%H%M%S)" [ -e "$BK_DIR/$id.zmb" ] && id="$id-$$" w="$BK_RUN.mk.$$"; rm -rf "$w"; mkdir -p "$w" comps="$(_bk_comps)" _bk_collect $comps panel sys | _bk_sums > "$w/files.lst" cut -f 3 "$w/files.lst" > "$w/paths" n="$(grep -c . "$w/paths")" raw="$(sed 's/.*/"&"/' "$w/paths" | xargs du -k 2>/dev/null | awk '{ s += $1 } END { print s + 0 }')" free="$(_bk_free_kb "$BK_DIR")" if [ "$free" -ge 0 ] && [ "$free" -lt $((raw / 2 + 512)) ]; then rm -rf "$w" BK_ERR="мало места на флеше: копия займёт до $(_bk_kb $((raw / 2 + 64))), свободно $(_bk_kb "$free")" return 1 fi tmp="$w" t="$(_bk_free_kb "$JOBS_DIR")" [ "$t" -lt 0 ] || [ "$t" -gt $((raw + 1024)) ] || { tmp="$BK_DIR/.mk.$$"; mkdir -p "$tmp"; } if ! _bk_tar "$tmp/files.tgz" "$w/paths"; then rm -rf "$w" "$BK_DIR/.mk.$$" BK_ERR="не удалось упаковать файлы" return 1 fi _bk_cron_rx > "$w/cron.rx" _bk_cron_ours "$w/cron.rx" > "$w/cron.lst" { echo "format=1" echo "id=$id" echo "at=$(date +%s)" echo "kind=$kind" printf 'name=%s\n' "$(printf '%s' "$name" | tr -d '\000-\037' | cut -c 1-60)" printf 'reason=%s\n' "$reason" echo "panel=$ZM_VERSION" echo "owrt=$(. /etc/openwrt_release 2>/dev/null; echo "$DISTRIB_RELEASE")" echo "arch=$(. /etc/openwrt_release 2>/dev/null; echo "$DISTRIB_ARCH")" echo "board=$(cat /tmp/sysinfo/board_name 2>/dev/null)" echo "host=$(uci -q get 'system.@system[0].hostname')" echo "lan=$(uci -q get network.lan.ipaddr | cut -d/ -f1)" echo "pkg=$PKG" echo "comps=$comps" for c in $comps; do printf 'chip=%s|%s\n' "$c" "$(_bk_chip "$c")" for t in $(_pg_trace "$c" 2>/dev/null); do case "$t" in pkg:*) _pkg_is_installed "${t#pkg:}" && printf 'pkgv=%s|%s|%s\n' "$c" "${t#pkg:}" "$(_bk_pkgver "${t#pkg:}")" ;; esac done for t in $(_bk_svcs "$c"); do st=0; /etc/init.d/"$t" enabled >/dev/null 2>&1 && st=1 if /etc/init.d/"$t" running >/dev/null 2>&1; then st="$st 1"; else st="$st 0"; fi printf 'svc=%s|%s|%s\n' "$c" "$t" "$st" done done [ -f /etc/init.d/tg-ws-proxy ] && echo "tgv=mtproto" [ -x "$TG_BIN_GO" ] && echo "tgv=socks5" [ -x "$TG_BIN_RS" ] && echo "tgv=rust" _bk_sysopt echo "files=$n" echo "raw=$raw" echo "sha=$(sha256sum "$tmp/files.tgz" | cut -d' ' -f1)" echo "lsha=$(cat "$w/files.lst" "$w/cron.lst" | sha256sum | cut -d' ' -f1)" } > "$w/manifest" dst="$BK_DIR/.$id.part" if ! (cd "$w" && tar -cf "$dst" manifest files.lst cron.lst -C "$tmp" files.tgz) 2>/dev/null; then rm -rf "$w" "$BK_DIR/.mk.$$" "$dst" BK_ERR="не удалось записать копию на флеш" return 1 fi sz="$(wc -c < "$dst" | tr -d ' ')" { cat "$w/manifest"; echo "size=$sz"; } > "$BK_DIR/$id.meta" mv -f "$dst" "$BK_DIR/$id.zmb" rm -rf "$w" "$BK_DIR/.mk.$$" BK_ID="$id"; BK_SIZE="$sz" [ "$kind" = manual ] || [ "$kind" = upload ] || _bk_rotate _bk_keepd return 0 } _bk_rotate() { local ids n ids="$(for i in $(_bk_ids); do [ "$i" = "$BK_PROTECT" ] && continue; case "$(_bk_meta "$i" kind)" in auto|safety) echo "$i" ;; esac; done)" n="$(printf '%s\n' "$ids" | grep -c .)" [ "$n" -gt "$BK_AUTO_N" ] || return 0 printf '%s\n' "$ids" | head -n $((n - BK_AUTO_N)) | while read -r i; do rm -f "$BK_DIR/$i.zmb" "$BK_DIR/$i.meta"; done } _bk_keepd() { local i s tot=0 out="" [ -d /lib/upgrade/keep.d ] || mkdir -p /lib/upgrade/keep.d 2>/dev/null || return 0 for i in $(_bk_ids | sort -r); do s="$(( ($(_bk_meta "$i" size) + 1023) / 1024 ))" [ $((tot + s)) -gt "$BK_KEEPD_KB" ] && continue tot=$((tot + s)) out="$out$BK_DIR/$i.zmb $BK_DIR/$i.meta " done [ -f "$BK_AUTO_OFF" ] && out="$out$BK_AUTO_OFF " if [ -n "$out" ]; then printf '%s' "$out" > "$BK_KEEPD"; else rm -f "$BK_KEEPD"; fi return 0 } _bk_auto_why() { case "$1" in do_install_zapret_full) echo "перед установкой Zapret" ;; do_install_zapret) echo "перед обновлением Zapret" ;; do_remove_zapret) echo "перед удалением Zapret" ;; do_install_zapret2) echo "перед установкой Zapret2" ;; do_remove_zapret2) echo "перед удалением Zapret2" ;; do_bytetube_install) echo "перед установкой ByeTube" ;; do_bytetube_purge|do_bytetube_uninstall) echo "перед удалением ByeTube" ;; do_steer_install) echo "перед установкой Steer" ;; do_steer_remove) echo "перед удалением Steer" ;; do_fk_install) [ "$2" = install ] && echo "перед установкой Forkozz" || echo "перед обновлением Forkozz" ;; do_fk_remove) echo "перед удалением Forkozz" ;; do_mixomo_install) echo "перед установкой Mixomo" ;; do_mixomo_remove) echo "перед удалением Mixomo" ;; do_awg_install) [ "$2" = update ] && echo "перед обновлением AmneziaWG" || echo "перед установкой AmneziaWG" ;; do_awg_remove) echo "перед удалением AmneziaWG" ;; do_tg_install_*) echo "перед установкой TG WS Proxy" ;; do_tg_remove_*) echo "перед удалением TG WS Proxy" ;; do_tgws_install) echo "перед установкой sTGWS" ;; do_tgws_remove) echo "перед удалением sTGWS" ;; do_tsc_install) echo "перед установкой Tailscale" ;; do_tsc_update) echo "перед обновлением Tailscale" ;; do_tsc_remove) echo "перед удалением Tailscale" ;; do_term_install) echo "перед установкой терминала" ;; do_term_remove) echo "перед удалением терминала" ;; do_htop_install) echo "перед установкой htop" ;; do_htop_remove) echo "перед удалением htop" ;; do_doh_install) echo "перед установкой DNS over HTTPS" ;; do_doh_remove) echo "перед удалением DNS over HTTPS" ;; do_purge) echo "перед полным удалением" ;; do_zm_update) echo "перед обновлением панели" ;; _zm_reinstall) _bk_auto_why "$3" | sed 's/перед установкой/перед переустановкой/' ;; esac } _bk_auto() { local why [ -n "$ZM_BK_NOAUTO" ] && return 0 [ -f "$BK_AUTO_OFF" ] && return 0 why="$(_bk_auto_why "$@")" [ -n "$why" ] || return 0 _rb_say "Сохраняем резервную копию панели — $why" _bk_lock if _bk_make auto "" "$why"; then echo " ✓ Копия сохранена ($(_bk_kb $(( (BK_SIZE + 1023) / 1024 )))). Вернуть всё как было — раздел «Резервная копия»" else echo " · Копию сохранить не удалось: $BK_ERR — продолжаем без неё"; fi _bk_unlock return 0 } backup_list() { local i sep="" out="" mkdir -p "$BK_DIR" 2>/dev/null for i in $(_bk_ids | sort -r); do [ -f "$BK_DIR/$i.meta" ] || _bk_meta_fix "$i" || continue out="$out$sep$(awk -v id="$i" ' function e(s) { gsub(/[\\"]/, "", s); gsub(/[\001-\037]/, "", s); return s } { k = $0; sub(/=.*/, "", k); v = substr($0, length(k) + 2) } k == "chip" { sub(/^[^|]*\|/, "", v); ch = ch (ch == "" ? "" : ",") "\"" e(v) "\""; next } k ~ /^(at|kind|name|reason|size|panel|host)$/ && !(k in m) { m[k] = v } END { printf "{\"id\":\"%s\",\"at\":%s,\"kind\":\"%s\",\"name\":\"%s\",\"reason\":\"%s\",\"size\":%s,\"panel\":\"%s\",\"host\":\"%s\",\"chips\":[%s]}", id, (m["at"] ~ /^[0-9]+$/ ? m["at"] : 0), e(m["kind"]), e(m["name"]), e(m["reason"]), (m["size"] ~ /^[0-9]+$/ ? m["size"] : 0), e(m["panel"]), e(m["host"]), ch }' "$BK_DIR/$i.meta")" sep="," done printf '{"items":[%s],"auto":%s,"auto_n":%s,"used_kb":%s,"free_kb":%s,"keep_kb":%s,"dir":"%s","busy":"%s","b64":%s}\n' \ "$out" "$([ -f "$BK_AUTO_OFF" ] && echo false || echo true)" "$BK_AUTO_N" "$(du -sk "$BK_DIR" 2>/dev/null | awk '{ print $1 + 0 }')" "$(_bk_free_kb "$BK_DIR")" "$BK_KEEPD_KB" "$BK_DIR" \ "$(esc "$(_zm_busy_job)")" "$(_bk_b64_ok && echo true || echo false)" } _bk_b64_ok() { { command -v ucode >/dev/null 2>&1 && [ -f "$BK_UC" ]; } || command -v base64 >/dev/null 2>&1; } _bk_meta_fix() { local m m="$(tar -xOf "$BK_DIR/$1.zmb" manifest 2>/dev/null)" || return 1 printf '%s\n' "$m" | grep -q '^format=1$' || return 1 { printf '%s\n' "$m"; echo "size=$(wc -c < "$BK_DIR/$1.zmb" | tr -d ' ')"; } > "$BK_DIR/$1.meta" } _bk_info() { local comps c raw w="$BK_RUN.inf.$$" chips="" sep="" np=0 t comps="$(_bk_comps)" _bk_collect $comps panel sys | cut -f 2 > "$w" raw="$(sed 's/.*/"&"/' "$w" | xargs du -k 2>/dev/null | awk '{ s += $1 } END { print s + 0 }')" rm -f "$w" for c in $comps; do chips="$chips$sep\"$(esc "$(_bk_chip "$c")")\""; sep="," for t in $(_pg_trace "$c" 2>/dev/null); do case "$t" in pkg:*) _pkg_is_installed "${t#pkg:}" && np=$((np + 1)) ;; esac; done done printf '{"chips":[%s],"est_kb":%s,"free_kb":%s,"pkgs":%s,"secrets":{"warp":%s,"subs":%s,"tsc":%s,"awg":%s},"busy":"%s"}\n' \ "$chips" "$((raw / 3 + 8))" "$(_bk_free_kb "$BK_DIR")" "$np" \ "$({ [ -s "$ST_WARP_CONF" ] || [ -s "$ST_WARP_OWN" ] || ls "$ST_SEC_DIR"/*/warp.conf >/dev/null 2>&1; } && echo true || echo false)" \ "$({ [ -s "$ST_SUB" ] || ls "$ST_SEC_DIR"/*/sub.txt >/dev/null 2>&1 || [ -d /etc/netshift ]; } && echo true || echo false)" \ "$({ [ -d /etc/tailscale ] && [ -n "$(ls -A /etc/tailscale 2>/dev/null)" ]; } && echo true || echo false)" \ "$([ -n "$(_awg_ifaces 2>/dev/null)" ] && echo true || echo false)" \ "$(esc "$(_zm_busy_job)")" } _bk_uci_secs() { awk ' function fl() { if (k != "") { if (t == "forwarding" && l ~ /#/ && src != "") l = src "→" dst; print k "\001" t "\001" l "\001" b } } /^[ \t]*config[ \t]/ { fl(); t = $2; gsub(/[\047"]/, "", t); n = $3; gsub(/[\047"]/, "", n); c[t]++; src = ""; dst = ""; b = "" if (n != "") { k = t "." n; l = n } else { k = t "#" c[t]; l = t " #" c[t] }; next } k == "" { next } /^[ \t]*option[ \t]+name[ \t]/ && k ~ /#/ { v = $0; sub(/^[ \t]*option[ \t]+name[ \t]+/, "", v); gsub(/[\047"]/, "", v); l = v; k = t "~" v } /^[ \t]*option[ \t]+src[ \t]/ { src = $3; gsub(/[\047"]/, "", src) } /^[ \t]*option[ \t]+dest[ \t]/ { dst = $3; gsub(/[\047"]/, "", dst) } { s = $0; gsub(/^[ \t]+|[ \t]+$/, "", s); if (s != "" && s !~ /^#/) b = b "\002" s } END { fl() }' "$1" 2>/dev/null } _bk_uci_diff() { _bk_uci_secs "$1" > "$BK_RUN.ua.$$" _bk_uci_secs "$2" > "$BK_RUN.ub.$$" awk -F '\001' 'NR == FNR { o[$1] = $4; ot[$1] = $2; ol[$1] = $3; next } { seen[$1] = 1; if (!($1 in o)) print "add\001" $2 "\001" $3; else if (o[$1] != $4) print "mod\001" $2 "\001" $3 } END { for (k in o) if (!(k in seen)) print "del\001" ot[k] "\001" ol[k] }' "$BK_RUN.ua.$$" "$BK_RUN.ub.$$" rm -f "$BK_RUN.ua.$$" "$BK_RUN.ub.$$" } _bk_open() { local id="$1" BK_W="$BK_RUN.op.$$" rm -rf "$BK_W"; mkdir -p "$BK_W" tar -xf "$BK_DIR/$id.zmb" -C "$BK_W" manifest files.lst cron.lst 2>/dev/null || { BK_ERR="файл копии повреждён"; return 1; } grep -q '^format=1$' "$BK_W/manifest" || { BK_ERR="это не копия Zapret Manager или она от более новой версии панели"; return 1; } return 0 } _bk_board() { cat /tmp/sysinfo/board_name 2>/dev/null; } _bk_samehw() { local b; b="$(_bk_m board)"; [ -z "$b" ] || [ -z "$(_bk_board)" ] || [ "$b" = "$(_bk_board)" ]; } _bk_close() { [ -n "$BK_W" ] && rm -rf "$BK_W"; BK_W=""; } _bk_m() { sed -n "s/^$1=//p" "$BK_W/manifest" | head -n 1; } _bk_net_stage() { local f mkdir -p "$BK_W/net" for f in $BK_NET; do grep -q " /etc/config/$f\$" "$BK_W/files.lst" || continue tar -xOf "$BK_DIR/$1.zmb" files.tgz | tar -xzOf - "etc/config/$f" > "$BK_W/net/$f" 2>/dev/null || rm -f "$BK_W/net/$f" done return 0 } backup_diff() { local id="$1" bc nc c rem="" ins="" chg="" same="" sep1="" sep2="" sep3="" sep4="" was now m r d f out net="" nsep="" vers="" vsep="" p v cv cron=0 warn="" wsep="" x _bk_id_ok "$id" || { echo '{"error":"копия не найдена"}'; return 1; } _bk_open "$id" || { printf '{"error":"%s"}\n' "$(esc "$BK_ERR")"; _bk_close; return 1; } bc=" $(_bk_m comps) "; nc=" $(_bk_comps) " _bk_collect $nc panel sys | _bk_sums > "$BK_W/now.lst" for c in $BK_ORDER; do case "$bc" in *" $c "*) ;; *) case "$nc" in *" $c "*) rem="$rem$sep1{\"id\":\"$c\",\"name\":\"$(esc "$(_bk_chip "$c")")\"}"; sep1="," ;; esac; continue ;; esac was="$(sed -n "s/^chip=$c|//p" "$BK_W/manifest" | head -n 1)" case "$nc" in *" $c "*) now="$(_bk_chip "$c")" set -- $(awk -F '\t' -v c="$c" 'NR == FNR { if ($1 == c) b[$3] = $2; next } $1 == c { n[$3] = $2 } END { for (f in b) { if (!(f in n)) r++; else if (n[f] != b[f]) m++ } for (f in n) if (!(f in b)) d++; print m + 0, r + 0, d + 0 }' "$BK_W/files.lst" "$BK_W/now.lst") if [ "$was" != "$now" ] || [ "$1$2$3" != 000 ]; then chg="$chg$sep3{\"id\":\"$c\",\"name\":\"$(esc "$(_bk_name "$c")")\",\"was\":\"$(esc "$was")\",\"now\":\"$(esc "$now")\",\"mod\":$1,\"ret\":$2,\"del\":$3}"; sep3="," else same="$same$sep4\"$(esc "$(_bk_name "$c")")\""; sep4=","; fi sed -n "s/^pkgv=$c|//p" "$BK_W/manifest" | while IFS='|' read -r p v; do cv="$(_bk_pkgver "$p")" [ -n "$v" ] && [ -n "$cv" ] && [ "$v" != "$cv" ] && printf '{"p":"%s","was":"%s","now":"%s"}\n' "$(esc "$p")" "$(esc "$v")" "$(esc "$cv")" done >> "$BK_W/vers" ;; *) ins="$ins$sep2{\"id\":\"$c\",\"name\":\"$(esc "${was:-$(_bk_name "$c")}")\",\"pkgs\":\"$(esc "$(sed -n "s/^pkgv=$c|\([^|]*\)|.*/\1/p" "$BK_W/manifest" | tr '\n' ' ' | sed 's/ $//')")\"}"; sep2="," ;; esac done set -- $(awk -F '\t' -v c=panel 'NR == FNR { if ($1 == c) b[$3] = $2; next } $1 == c { n[$3] = $2 } END { for (f in b) { if (!(f in n)) r++; else if (n[f] != b[f]) m++ } for (f in n) if (!(f in b)) d++; print m + 0, r + 0, d + 0 }' "$BK_W/files.lst" "$BK_W/now.lst") x="{\"mod\":$1,\"ret\":$2,\"del\":$3" _bk_samehw && _bk_net_stage "$id" _bk_samehw || x="$x,\"otherhw\":{\"was\":\"$(esc "$(_bk_m board)")\",\"now\":\"$(esc "$(_bk_board)")\"}" for f in $BK_NET; do [ -f "$BK_W/net/$f" ] || continue out="$(_bk_uci_diff "/etc/config/$f" "$BK_W/net/$f" | head -n 40 | awk -F '\001' '{ gsub(/["\\]/, "", $3); printf "%s{\"op\":\"%s\",\"t\":\"%s\",\"l\":\"%s\"}", (NR > 1 ? "," : ""), $1, $2, $3 }')" [ -n "$out" ] && { net="$net$nsep{\"f\":\"$f\",\"s\":[$out]}"; nsep=","; } done [ -s "$BK_W/vers" ] && vers="$(tr '\n' ',' < "$BK_W/vers" | sed 's/,$//')" _bk_cron_rx > "$BK_W/cron.rx" _bk_cron_ours "$BK_W/cron.rx" | sort > "$BK_W/cron.now" sort "$BK_W/cron.lst" > "$BK_W/cron.was" cron="$(cat "$BK_W/cron.now" "$BK_W/cron.was" | sort | uniq -u | grep -c .)" x="$x,\"cron\":$cron" _bk_sysopt | sort > "$BK_W/sys.now" grep '^sys=' "$BK_W/manifest" | sort > "$BK_W/sys.was" [ "$(md5sum < "$BK_W/sys.now")" = "$(md5sum < "$BK_W/sys.was")" ] || x="$x,\"sys\":true" v="$(_bk_m lan)"; cv="$(uci -q get network.lan.ipaddr | cut -d/ -f1)" _bk_samehw && [ -n "$v" ] && [ -n "$cv" ] && [ "$v" != "$cv" ] && x="$x,\"lan\":{\"was\":\"$(esc "$v")\",\"now\":\"$(esc "$cv")\"}" printf '{"id":"%s","at":%s,"panel":"%s","owrt":"%s","arch":"%s","cur":{"panel":"%s","owrt":"%s","arch":"%s"},"remove":[%s],"install":[%s],"change":[%s],"same":[%s],"panelf":%s},"net":[%s],"vers":[%s],"secrets":{"tsc":%s,"warp":%s,"subs":%s},"busy":"%s"}\n' \ "$id" "$(_bk_m at | grep -E '^[0-9]+$' || echo 0)" \ "$(esc "$(_bk_m panel)")" "$(esc "$(_bk_m owrt)")" "$(esc "$(_bk_m arch)")" \ "$ZM_VERSION" "$(esc "$(. /etc/openwrt_release 2>/dev/null; echo "$DISTRIB_RELEASE")")" "$(esc "$(. /etc/openwrt_release 2>/dev/null; echo "$DISTRIB_ARCH")")" \ "$rem" "$ins" "$chg" "$same" "$x" "$net" "$vers" \ "$(grep -q ' /etc/tailscale/' "$BK_W/files.lst" && echo true || echo false)" \ "$(grep -qE ' /etc/zm-steer/(warp\.conf|warp\.own\.conf|sec/[^/]*/warp\.conf)$| /etc/zm-warp-own\.conf$' "$BK_W/files.lst" && echo true || echo false)" \ "$(grep -qE ' /etc/zm-steer/(sub\.txt|sec/[^/]*/sub\.txt)$| /etc/netshift/' "$BK_W/files.lst" && echo true || echo false)" \ "$(esc "$(_zm_busy_job)")" _bk_close } _bk_install() { case "$1" in zapret) do_install_zapret_full ;; zapret2) do_install_zapret2 ;; bytetube) do_bytetube_install ;; awg) do_awg_install ;; steer) do_steer_install ;; forkozz) do_fk_install install ;; mixomo) do_mixomo_install ;; tgproxy) grep -q '^tgv=mtproto$' "$BK_W/manifest" && do_tg_install_mtproto grep -q '^tgv=socks5$' "$BK_W/manifest" && do_tg_install_socks5 grep -q '^tgv=rust$' "$BK_W/manifest" && do_tg_install_rust return 0 ;; stgws) do_tgws_install ;; tailscale) do_tsc_install ;; terminal) do_term_install ;; htop) do_htop_install ;; doh) do_doh_install ;; *) return 0 ;; esac } _bk_svc_do() { [ -x "/etc/init.d/$1" ] && _zm_run 60 "/etc/init.d/$1" "$2" >/dev/null 2>&1; return 0; } _bk_netsave() { local f mkdir -p "$BK_RUN.net" for f in $BK_NET; do [ -f "/etc/config/$f" ] && cp -p "/etc/config/$f" "$BK_RUN.net/$f"; done return 0 } _bk_netchanged() { local f ch="" for f in $BK_NET; do [ -f "$BK_RUN.net/$f" ] || continue [ "$(md5sum < "/etc/config/$f")" = "$(md5sum < "$BK_RUN.net/$f")" ] || ch="$ch $f" done echo "${ch# }" } _bk_net_reload() { case " $1 " in *" network "*) _zm_run 90 /etc/init.d/network reload >/dev/null 2>&1 ;; esac case " $1 " in *" firewall "*|*" network "*) _zm_run 60 /etc/init.d/firewall reload >/dev/null 2>&1 ;; esac _zm_run 30 /etc/init.d/dnsmasq restart >/dev/null 2>&1 return 0 } _bk_ipwait() { local t=0 while [ "$t" -lt "$1" ]; do _zm_ip_ok && return 0 sleep 5; t=$((t + 5)) done return 1 } do_backup_create() { _rb_say "Собираем настройки панели и компонентов" _bk_lock if _bk_make manual "$1" ""; then _bk_unlock echo " ✓ Файлов в копии: $(_bk_meta "$BK_ID" files), компонентов: $(_bk_meta "$BK_ID" comps | wc -w)" echo " ✓ Размер копии: $(_bk_kb $(( (BK_SIZE + 1023) / 1024 )))" _rb_say "Готово: копия сохранена на роутере" return 0 fi _bk_unlock echo "!! Копию сохранить не удалось: $BK_ERR" return 1 } do_backup_restore() { local id="$1" bc nc c t f s e r p v cv rc=0 errs=0 notes=0 chg had=0 ok sysch="" hw=1 _bk_lock BK_W="" _bk_open "$id" || { echo "!! $BK_ERR"; _bk_unlock; return 1; } bc=" $(_bk_m comps) "; nc=" $(_bk_comps) " _rb_say "Проверяем файл копии" if [ "$(tar -xOf "$BK_DIR/$id.zmb" files.tgz | sha256sum | cut -d' ' -f1)" != "$(_bk_m sha)" ] || [ "$(cat "$BK_W/files.lst" "$BK_W/cron.lst" | sha256sum | cut -d' ' -f1)" != "$(_bk_m lsha)" ]; then echo "!! Файл копии повреждён (контрольная сумма не совпала) — восстановление отменено, ничего не менялось" _bk_close; _bk_unlock; return 1 fi echo " ✓ Копия целая: файлов $(_bk_m files), компонентов $(echo $bc | wc -w)" _bk_samehw || hw="" BK_PROTECT="$id" _rb_say "Сохраняем текущее состояние — на случай, если передумаете" cp -f "$BK_W/manifest" "$BK_RUN.restore.manifest" 2>/dev/null if _bk_make safety "" "перед восстановлением"; then echo " ✓ Копия «перед восстановлением» сохранена ($(_bk_kb $(( (BK_SIZE + 1023) / 1024 ))))" else echo "!! Не удалось сохранить текущее состояние: $BK_ERR" echo "!! Восстановление отменено, ничего не менялось. Освободите место (удалите старые копии) и попробуйте снова" _bk_close; _bk_unlock; return 1 fi _zm_ip_ok && had=1 /etc/init.d/cron stop >/dev/null 2>&1 for c in $PG_ORDER; do case "$c" in panel|hosts|dnsnet) continue ;; esac case "$nc" in *" $c "*) ;; *) continue ;; esac case "$bc" in *" $c "*) continue ;; esac _rb_say "Удаляем $(_bk_name "$c") — в копии его не было" ( ZM_BK_NOAUTO=1 _pg_remove "$c" ) 2>&1 | grep -v '^==> Готово' | sed 's/^==> / → /' _pg_sweep "$c" if _pg_inst "$c" >/dev/null 2>&1; then echo "!! $(_bk_name "$c") удалить не удалось"; errs=$((errs + 1)) else echo " ✓ $(_bk_name "$c") удалён"; fi done for c in $BK_ORDER; do case "$bc" in *" $c "*) ;; *) continue ;; esac case "$nc" in *" $c "*) continue ;; esac case "$c" in adblock|dnsnet|hosts) continue ;; esac _rb_say "Устанавливаем $(_bk_name "$c") — он был в копии" ( ZM_BK_NOAUTO=1 _bk_install "$c" ) 2>&1 | sed 's/^==> / → /' if _pg_inst "$c" >/dev/null 2>&1; then echo " ✓ $(_bk_name "$c") установлен" else echo "!! $(_bk_name "$c") установить не удалось — его настройки всё равно вернём, поставьте его потом на его вкладке"; errs=$((errs + 1)); fi done _rb_say "Останавливаем службы перед заменой настроек" s="" sed -n 's/^svc=[^|]*|\([^|]*\)|.*/\1/p' "$BK_W/manifest" | sort -u > "$BK_W/svcs" while read -r t; do [ -x "/etc/init.d/$t" ] && { _bk_svc_do "$t" stop; s="$s $t"; }; done < "$BK_W/svcs" case "$bc" in *" steer "*) _st_installed && { stl_stop >/dev/null 2>&1; s="$s steer"; } ;; esac [ -n "$s" ] && echo " ✓ Остановлены:$s" || echo " ✓ Останавливать нечего" _rb_say "Возвращаем файлы настроек" _bk_netsave cut -f 3 "$BK_W/files.lst" > "$BK_W/want" _bk_collect $(_bk_comps) panel | cut -f 2 > "$BK_W/have" awk 'NR == FNR { w[$0] = 1; next } !($0 in w)' "$BK_W/want" "$BK_W/have" > "$BK_W/extra" e="$(grep -c . "$BK_W/extra")" while IFS= read -r f; do rm -f "$f"; f="${f%/*}"; rmdir "$f" 2>/dev/null && rmdir "${f%/*}" 2>/dev/null; done < "$BK_W/extra" if tar -xOf "$BK_DIR/$id.zmb" files.tgz | tar -xzf - -C / 2>"$BK_W/tar.err"; then echo " ✓ Возвращено файлов: $(_bk_m files)$([ "$e" -gt 0 ] && echo ", удалено лишних: $e")" else echo "!! Часть файлов вернуть не удалось:"; head -n 5 "$BK_W/tar.err" | sed 's/^/ · /' errs=$((errs + 1)) fi cut -f 1,3 "$BK_W/files.lst" | _bk_sums > "$BK_W/now.sum" ok="$(awk -F '\t' 'NR == FNR { w[$3] = $2; next } ($3 in w) && w[$3] != $2 { n++ } END { print n + 0 }' "$BK_W/files.lst" "$BK_W/now.sum")" if [ "$ok" -gt 0 ]; then echo "!! Не совпали с копией файлов: $ok"; errs=$((errs + 1)) else echo " ✓ Все файлы совпадают с копией"; fi if [ -z "$hw" ]; then for f in $BK_NET; do [ -f "$BK_RUN.net/$f" ] && cp -p "$BK_RUN.net/$f" "/etc/config/$f"; done echo " · Копия с другого роутера ($(_bk_m board)) — сеть, firewall и DHCP оставлены как сейчас, чтобы не потерять доступ к роутеру" fi _rb_say "Возвращаем расписание и системные настройки" _bk_cron_rx > "$BK_W/cron.rx" grep -v -F -f "$BK_W/cron.rx" "$CRON_FILE" > "$BK_W/cron.new" 2>/dev/null cat "$BK_W/cron.lst" >> "$BK_W/cron.new" cat "$BK_W/cron.new" > "$CRON_FILE" echo " ✓ Заданий панели в расписании: $(grep -c . "$BK_W/cron.lst")" v="$(sed -n 's/^sys=zonename=//p' "$BK_W/manifest")"; cv="$(sed -n 's/^sys=timezone=//p' "$BK_W/manifest")" if [ -n "$v$cv" ] && { [ "$v" != "$(uci -q get 'system.@system[0].zonename')" ] || [ "$cv" != "$(uci -q get 'system.@system[0].timezone')" ]; }; then uci -q set "system.@system[0].zonename=$v"; uci -q set "system.@system[0].timezone=$cv"; sysch=1 fi v="$(sed -n 's/^sys=ntp=//p' "$BK_W/manifest")" if [ "$v" != "$(uci -q get system.ntp.server | tr '\n' ' ' | sed 's/ *$//')" ]; then uci -q get system.ntp >/dev/null 2>&1 || uci -q set system.ntp=timeserver uci -q delete system.ntp.server for p in $v; do uci -q add_list "system.ntp.server=$p"; done v="$(sed -n 's/^sys=ntpon=//p' "$BK_W/manifest")"; [ -n "$v" ] && uci -q set "system.ntp.enabled=$v" sysch=1 fi if [ "$sysch" = 1 ]; then uci -q commit system /etc/init.d/system reload >/dev/null 2>&1 /etc/init.d/sysntpd restart >/dev/null 2>&1 echo " ✓ Часовой пояс и серверы времени как в копии" else echo " ✓ Часовой пояс и серверы времени не менялись"; fi chg="$(_bk_netchanged)" if [ -n "$chg" ]; then _rb_say "Применяем настройки сети ($(echo "$chg" | sed 's/network/сеть/; s/firewall/firewall/; s/dhcp/DHCP и DNS/; s/ /, /g')) — связь может пропасть на полминуты" _bk_net_reload "$chg" if [ "$had" = 1 ] && ! _bk_ipwait 90; then echo "!! За 90 секунд интернет не появился — возвращаем прежние настройки сети" for f in $BK_NET; do [ -f "$BK_RUN.net/$f" ] && cp -p "$BK_RUN.net/$f" "/etc/config/$f"; done _bk_net_reload "$chg" if _bk_ipwait 60; then echo " ✓ Прежняя сеть вернулась, интернет работает. Остальное восстановлено — сеть оставлена как была до восстановления" else echo "!! Интернета нет и с прежними настройками — перезагрузите роутер"; fi errs=$((errs + 1)) else echo " ✓ Сеть применена$([ "$had" = 1 ] && echo ", интернет работает")" fi else /etc/init.d/dnsmasq restart >/dev/null 2>&1 echo " ✓ Настройки сети, firewall и DHCP совпадают с копией — сеть не перезапускаем" fi rm -rf "$BK_RUN.net" _rb_say "Запускаем службы как в копии" sed -n 's/^svc=[^|]*|//p' "$BK_W/manifest" | sort -u | while IFS='|' read -r t s; do [ -x "/etc/init.d/$t" ] || continue case "$s" in 1*) /etc/init.d/"$t" enable >/dev/null 2>&1 ;; *) /etc/init.d/"$t" disable >/dev/null 2>&1 ;; esac case "$s" in *" 1") if [ "$t" = zapret ]; then zapret_restart >/dev/null 2>&1; else _bk_svc_do "$t" restart; fi ;; *) _bk_svc_do "$t" stop ;; esac done if case "$bc" in *" steer "*) true ;; *) false ;; esac && _st_installed; then if [ -f "$ST_OFF" ]; then echo " · Steer в копии был выключен — оставляем выключенным" else ( do_steer_apply ) 2>&1 | grep -E '^(!!| ✓| ·)' | head -n 12; fi fi case "$bc" in *" forkozz "*) _fk_ours && forkop_device_setup apply >/dev/null 2>&1 ;; esac case "$bc" in *" bytetube "*) bytetube_sync >/dev/null 2>&1 ;; esac rm -f "$JOBS_DIR/adblock.watch" /etc/init.d/cron restart >/dev/null 2>&1 zm_watch >/dev/null 2>&1 rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* 2>/dev/null echo " ✓ Службы запущены" _rb_say "Сверяем результат с копией" nc=" $(_bk_comps) " ok=0 for c in $BK_ORDER; do case "$bc" in *" $c "*) case "$nc" in *" $c "*) ;; *) echo "!! Нет компонента из копии: $(_bk_name "$c")"; ok=1 ;; esac ;; *) case "$nc" in *" $c "*) echo "!! Остался компонент, которого не было в копии: $(_bk_name "$c")"; ok=1 ;; esac ;; esac done if [ "$ok" = 1 ]; then errs=$((errs + 1)) else echo " ✓ Компоненты как в копии: $(for c in $bc; do printf '%s, ' "$(_bk_name "$c")"; done | sed 's/, $//')$([ -z "$(echo $bc)" ] && echo "не установлено ни одного")"; fi sed -n 's/^svc=[^|]*|//p' "$BK_W/manifest" | sort -u | while IFS='|' read -r t s; do [ -x "/etc/init.d/$t" ] || continue case "$s" in *" 1") /etc/init.d/"$t" running >/dev/null 2>&1 || { sleep 3; /etc/init.d/"$t" running >/dev/null 2>&1; } || echo "!! Служба $t в копии работала, а сейчас не запустилась" ;; esac done > "$BK_W/svc.bad" if [ -s "$BK_W/svc.bad" ]; then cat "$BK_W/svc.bad"; errs=$((errs + $(grep -c . "$BK_W/svc.bad"))) elif grep -q '^svc=' "$BK_W/manifest"; then echo " ✓ Службы работают как в копии"; fi sed -n 's/^pkgv=[^|]*|//p' "$BK_W/manifest" | while IFS='|' read -r p v; do cv="$(_bk_pkgver "$p")" [ -n "$cv" ] && [ -n "$v" ] && [ "$cv" != "$v" ] && echo " · Версия $p: в копии $v, сейчас $cv" done > "$BK_W/ver.diff" if [ -s "$BK_W/ver.diff" ]; then echo " · Версии программ отличаются от копии — ставятся из интернета, старых там уже нет. Настройки при этом как в копии:" cat "$BK_W/ver.diff"; notes=1 fi if _zm_inet_wait 20; then echo " ✓ Интернет и DNS на роутере работают" elif [ "$had" = 1 ]; then echo "!! Интернет на роутере не отвечает"; _zm_net_why; errs=$((errs + 1)) else echo " · Интернета на роутере не было и до восстановления — проверьте подключение к провайдеру"; fi _bk_close _bk_unlock if [ "$errs" -gt 0 ]; then echo "!! Восстановлено с отличиями ($errs) — они перечислены выше. Вернуть всё как было до восстановления — копия «перед восстановлением»" return 1 fi if [ "$notes" = 1 ]; then echo "==> Готово: всё как в копии, кроме версий программ — список выше" else echo "==> Готово: всё как в копии"; fi return 0 } _bk_b64dec() { if command -v ucode >/dev/null 2>&1 && [ -f "$BK_UC" ]; then ucode "$BK_UC" dec "$1" "$2" else tr -d '\r\n ' < "$1" | base64 -d > "$2" 2>/dev/null; fi } BK_UC="/opt/zapret-manager-luci/b64.uc" _bk_put() { local f="$BK_UP.zmb" m id k ow m2 [ -s "$BK_UP" ] || { echo '{"error":"файл не загрузился — попробуйте ещё раз"}'; return 1; } _bk_b64_ok || { rm -f "$BK_UP"; echo '{"error":"на роутере нет ucode и base64 — загрузить файл не получится"}'; return 1; } _bk_b64dec "$BK_UP" "$f" rm -f "$BK_UP" m="$(tar -xOf "$f" manifest 2>/dev/null)" if ! printf '%s\n' "$m" | grep -q '^format=1$' || ! tar -tf "$f" 2>/dev/null | grep -qx files.tgz; then rm -f "$f"; echo '{"error":"это не файл копии Zapret Manager"}'; return 1 fi if [ "$(tar -xOf "$f" files.tgz | sha256sum | cut -d' ' -f1)" != "$(printf '%s\n' "$m" | sed -n 's/^sha=//p')" ] || [ "$( (tar -xOf "$f" files.lst; tar -xOf "$f" cron.lst) 2>/dev/null | sha256sum | cut -d' ' -f1)" != "$(printf '%s\n' "$m" | sed -n 's/^lsha=//p')" ]; then rm -f "$f"; echo '{"error":"файл копии повреждён — скачайте его заново"}'; return 1 fi k="$(( ($(wc -c < "$f") + 1023) / 1024 ))" m2="$(_bk_free_kb "$BK_DIR")" if [ "$m2" -ge 0 ] && [ "$m2" -lt $((k + 256)) ]; then rm -f "$f"; echo '{"error":"мало места на флеше для этой копии — удалите старые копии"}'; return 1; fi mkdir -p "$BK_DIR" id="$(printf '%s\n' "$m" | sed -n 's/^id=//p' | head -n 1)" case "$id" in ''|*[!0-9a-z-]*) id="$(date +%Y%m%d-%H%M%S)" ;; esac ow="" if [ -f "$BK_DIR/$id.zmb" ]; then if [ "$(sha256sum < "$BK_DIR/$id.zmb")" = "$(sha256sum < "$f")" ]; then rm -f "$f"; printf '{"ok":true,"id":"%s","same":true}\n' "$id"; return 0; fi id="$id-u$$" fi mv -f "$f" "$BK_DIR/$id.zmb" { printf '%s\n' "$m" | sed '/^kind=/d; /^id=/d'; echo "id=$id"; echo "kind=upload"; echo "size=$(wc -c < "$BK_DIR/$id.zmb" | tr -d ' ')"; } > "$BK_DIR/$id.meta" _bk_keepd printf '{"ok":true,"id":"%s"}\n' "$id" } backup_action() { local a="$1" v="$2" j id case "$a" in create) ;; *) v="$(printf '%s' "$v" | tr -d ' \t\r\n')" ;; esac case "$a" in list) backup_list ;; info) _bk_info ;; diff) backup_diff "$v" ;; auto) mkdir -p "$BK_DIR" case "$v" in on) rm -f "$BK_AUTO_OFF" ;; off) : > "$BK_AUTO_OFF" ;; *) echo '{"error":"неверное значение"}'; return 1 ;; esac _bk_keepd printf '{"ok":true,"auto":%s}\n' "$([ "$v" = on ] && echo true || echo false)" ;; create) if j="$(_zm_busy_job)"; then printf '{"error":"%s"}\n' "$(esc "идёт операция «$j» — дождитесь её окончания")"; return 1; fi job_start backup do_backup_create "$v" ;; restore) _bk_id_ok "$v" || { echo '{"error":"копия не найдена"}'; return 1; } if j="$(_zm_busy_job)"; then printf '{"error":"%s"}\n' "$(esc "идёт операция «$j» — дождитесь её окончания")"; return 1; fi job_start backup do_backup_restore "$v" ;; delete) _bk_id_ok "$v" || { echo '{"error":"копия не найдена"}'; return 1; } _job_running backup && { echo '{"error":"идёт работа с копиями — дождитесь её окончания"}'; return 1; } rm -f "$BK_DIR/$v.zmb" "$BK_DIR/$v.meta" _bk_keepd printf '{"ok":true}\n' ;; put) _bk_put ;; *) echo '{"error":"неизвестное действие"}'; return 1 ;; esac } ZM_MIRRORS="downloads.openwrt.org mirror-03.infra.openwrt.org openwrt.tetaneutral.net ftp.halifax.rwth-aachen.de/openwrt openwrt.mirror.garr.it/mirrors/openwrt mirrors.ustc.edu.cn/openwrt openwrt.c3sl.ufpr.br mirror.marwan.ma/openwrt openwrt.pixeldeck.net" _mirror_feed1() { local l l="$(grep -v '^[[:space:]]*#' "$(_confz)" 2>/dev/null | grep -oE 'https?://[^ ]+/(releases|snapshots)/[^ ]+' | head -n1)" [ -n "$l" ] || return 1 case "$l" in *.adb|*/Packages.gz) ;; *) [ "$PKG" = apk ] || l="${l%/}/Packages.gz" ;; esac echo "$l" } _mirror_swap() { printf '%s\n' "$1" | sed -E "s#^https?://[^ ]+/(releases|snapshots)/#https://$2/\1/#"; } _mirror_write() { local f f="$(_confz)" [ -f "$f" ] || return 1 cp -p "$f" "$f.zm-bak" 2>/dev/null sed -i -E "s#https?://[^ ]+/(releases|snapshots)/#https://$1/\1/#g" "$f" } _mirror_undo() { local f; rm -f "$ZM_SI_STATIC"; f="$(_confz)"; [ -f "$f.zm-bak" ] && mv -f "$f.zm-bak" "$f"; } _mirror_keep() { rm -f "$(_confz).zm-bak"; } _mirror_probe() { local r code t r="$(curl -s -o /dev/null -L -r 0-1023 --connect-timeout 4 --max-time 10 -w '%{http_code} %{time_total}' "$(_mirror_swap "$2" "$1")" 2>/dev/null)" code="${r%% *}"; t="${r##* }" case "$code" in 200|206) ;; *) return 1 ;; esac awk -v t="$t" -v h="$1" 'BEGIN { printf "%d %s\n", t * 1000 + 0.5, h }' } _mirror_upd_bad() { local h="$2" [ -s "$1" ] || return 1 grep -iE 'error|failed|unable|warning|timed out|not found|404|network' "$1" 2>/dev/null | grep -v 'write error: Broken pipe' | grep -qF "$h" } _mirror_upd_try() { local out="$JOBS_DIR/mirror-upd.$$" rc h h="$(_mirror_host)" mkdir -p "$JOBS_DIR" _zm_run 150 $PKG_UPD > "$out" 2>&1; rc=$? _zm_pkg_unlock if [ "$rc" = 0 ] && ! _mirror_upd_bad "$out" "$h"; then rm -f "$out"; return 0; fi _zm_pkg_fmt < "$out" | head -n 4 rm -f "$out" return 1 } _mirror_mark() { rm -f "$ZM_SI_STATIC"; mkdir -p "$ZM_STATE_DIR"; printf '%s\t%s\t%s\n' "$1" "$(date +%s)" "$2" > "$ZM_MIRROR_STATE"; } _mirror_auto() { local cur u dir h t list="" n=0 [ -n "$ZM_MIRROR_TRIED" ] && return 1 ZM_MIRROR_TRIED=1 cur="$(_mirror_host)"; u="$(_mirror_feed1)" [ -n "$cur" ] && [ -n "$u" ] || return 1 echo "==> Репозиторий OpenWrt ($cur) отвечает с ошибками — подбираем рабочее зеркало" dir="$JOBS_DIR/mirror.$$"; rm -rf "$dir"; mkdir -p "$dir" for h in $ZM_MIRRORS; do [ "$h" = "$cur" ] && continue n=$((n + 1)) _mirror_probe "$h" "$u" > "$dir/$n" & done wait list="$(cat "$dir"/* 2>/dev/null | sort -n | head -n 4)" rm -rf "$dir" if [ -z "$list" ]; then echo " ✗ Ни одно зеркало не ответило — похоже, нет интернета. Оставляем $cur" return 1 fi echo " ✓ Отвечают: $(printf '%s\n' "$list" | awk '{ printf "%s%s (%d мс)", (NR > 1 ? ", " : ""), $2, $1 }')" if printf '%s\n' "$list" | { while read -r t h; do [ -n "$h" ] || continue echo " → Пробуем $h" _mirror_write "$h" || exit 1 if _mirror_upd_try; then _mirror_keep _mirror_mark "$h" auto echo " ✓ Список пакетов обновлён через зеркало $h — оно останется, вернуть основное можно в «Системе»" exit 0 fi _mirror_undo echo " ✗ $h: список пакетов не обновился без ошибок" done exit 1 }; then return 0; fi echo " ✗ Ни одно зеркало не помогло — оставляем $cur" return 1 } mirror_status() { printf '{"current":"%s",%s}\n' "$(esc "$(_mirror_name)")" "$(_mirror_info)" } do_mirror_set() { local host="$1" u prev ms prev="$(_mirror_host)" u="$(_mirror_feed1)" [ -n "$u" ] || { echo "ОШИБКА: в списке репозиториев ($(_confz)) нет адреса OpenWrt"; return 1; } echo "==> Проверяем зеркало $host: отдаёт ли оно список пакетов этого роутера" if ! ms="$(_mirror_probe "$host" "$u")"; then echo "ОШИБКА: зеркало $host не отдаёт список пакетов для этой версии OpenWrt — оставляем ${prev:-прежнее}" return 1 fi echo " ✓ Отвечает за ${ms%% *} мс" _mirror_write "$host" || { echo "ОШИБКА: не удалось записать $(_confz)"; return 1; } echo "==> Обновляем список пакетов через $host" if ! _mirror_upd_try; then _mirror_undo echo "ОШИБКА: через $host список пакетов обновился с ошибками — вернули ${prev:-прежнее}" return 1 fi _mirror_keep if [ "$host" = downloads.openwrt.org ]; then rm -f "$ZM_MIRROR_STATE"; else _mirror_mark "$host" manual; fi echo "==> Готово: зеркало $host работает, список пакетов обновлён без ошибок" } 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" "$arp_tmp.log" 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 [ -f "$arp_tmp.log" ] || logread 2>/dev/null | grep -i "DHCPACK" > "$arp_tmp.log" aname=$(grep -i "$aip" "$arp_tmp.log" | grep -i "$amac" | tail -n1 | awk '{print $NF}') fi [ "$aname" = "$amac" ] && aname="" [ -n "$aname" ] || aname="Неизвестное устройство" printf '%s|%s\n' "$aip" "$aname" >> "$arp_tmp" done rm -f "$arp_tmp.log" 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" _zm_re "$ip" '^[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' } _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 ! _zm_dl_sane "$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; } case "$content" in *"'"*) echo '{"error":"одинарные кавычки \u0027 в стратегии нельзя — они ломают конфиг Zapret"}'; return 1 ;; esac printf '%s' "$content" | grep -q -- '--' || { echo '{"error":"в стратегии нет ни одного параметра --… — сохранение отменено"}'; return 1; } sed -i "/^[[:space:]]*option NFQWS_OPT '/,\$d" "$CONF" { echo " option NFQWS_OPT '"; printf '%s\n' "$content"; echo "'"; } >> "$CONF" if _zo_doh_on; then [ -f "$ZO_DOH_FLAG" ] || { mkdir -p "$ZM_STATE_DIR"; _zo_doh_tpl > "$ZO_DOH_FLAG"; } else rm -f "$ZO_DOH_FLAG"; fi [ -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.11" TG_MTPROTO_TAG="0.11-rev3" TG_MTPROTO_REV=3 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]+)+(-rev[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_mt_base() { _ver_norm "${1%%-rev*}"; } _tg_mt_rev() { case "$1" in *-rev[0-9]*) echo "${1##*-rev}" ;; *) echo 1 ;; esac; } _tg_mt_show() { if [ "${2:-1}" -gt 1 ] 2>/dev/null; then echo "$1-$2"; else echo "$1"; fi; } _tg_tag_mt() { local t v r r0 t="$(_gh_latest_tag "$TG_REPO_MT")"; [ -n "$t" ] || return 1 v="$(_tg_mt_base "$t")"; r0="$(_tg_mt_rev "$t")" for r in $r0 $(for r in 1 2 3; do [ "$r" = "$r0" ] || echo "$r"; done); 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_TAG $TG_MTPROTO_REV" ;; 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_newer() { [ -n "$1" ] && [ -n "$2" ] && _st_ver_lt "$(_ver_norm "$1" | tr - .)" "$(_ver_norm "$2" | tr - .)"; } _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_reg_drop() { local p="$1" if [ "$PKG" = apk ]; then [ -f /etc/apk/world ] && sed -i "/^$p\$/d; /^$p[<>=~]/d" /etc/apk/world 2>/dev/null else rm -f "/usr/lib/opkg/info/$p".* 2>/dev/null if [ -f /usr/lib/opkg/status ]; then awk -v p="$p" 'BEGIN { RS = ""; ORS = "\n\n" } { split($0, l, "\n"); if (l[1] != "Package: " p) print }' /usr/lib/opkg/status > /usr/lib/opkg/status.zm 2>/dev/null && cat /usr/lib/opkg/status.zm > /usr/lib/opkg/status rm -f /usr/lib/opkg/status.zm fi fi } _zm_pkg_purge() { local p="$1" files f try=1 left reg ca=0 _pkg_is_installed "$p" || return 0 _zm_ca_have && ca=1 files="$(_zm_pkg_files "$p")" echo " → Удаляем пакет $p" while [ "$try" -le 4 ]; do if [ "$try" = 1 ]; then _zm_run 180 $PKG_DEL "$p" >/dev/null 2>&1 else [ "$try" = 4 ] && _zm_pkg_reg_drop "$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 _zm_pkg_unlock left="" for f in $files; do [ -f "$f" ] || [ -L "$f" ] || continue rm -f "$f" 2>/dev/null [ -f "$f" ] || [ -L "$f" ] && left="$left $f" done reg=0; _pkg_is_installed "$p" && reg=1 if [ "$reg" = 0 ] && [ -z "$left" ]; then if [ -n "$ZM_PKG_QUIET" ]; then : elif [ "$try" = 1 ]; then echo " ✓ Пакет $p удалён, остатков нет" else echo " ✓ Пакет $p удалён с попытки $try, остатков нет"; fi [ "$ca" = 1 ] && _zm_ca_keep return 0 fi [ "$try" -lt 4 ] && echo " ↻ Пакет $p удалился не полностью — попытка $((try + 1)) из 4" try=$((try + 1)) sleep 2 done [ "$ca" = 1 ] && _zm_ca_keep [ "$reg" = 1 ] && echo "!! Пакет $p не удалился за 4 попытки — он всё ещё числится в системе" [ -n "$left" ] && echo "!! От пакета $p остались файлы:$left" return 1 } _zm_luci_files() { local n for n in "$@"; do printf '%s ' "/www/luci-static/resources/view/$n" "/www/luci-static/resources/$n" "/www/luci-static/resources/protocol/$n.js" \ "/usr/share/luci/menu.d/luci-app-$n.json" "/usr/share/luci/menu.d/luci-proto-$n.json" \ "/usr/share/rpcd/acl.d/luci-app-$n.json" "/usr/share/rpcd/acl.d/luci-proto-$n.json" \ "/usr/libexec/rpcd/luci.$n" "/usr/share/rpcd/ucode/luci.$n" "/usr/lib/lua/luci/i18n/$n.*.lmo" done } _zm_rm_listed() { local t l="" for t in $(_pg_trace "$1"); do case "$t" in /*) l="$l $t" ;; esac; done _zm_wipe $l } _zm_pkg_wipe() { local p rc=0 for p; do _zm_pkg_purge "$p" || rc=1; done return $rc } _zm_wipe() { local f try=1 left while [ "$try" -le 4 ]; do left="" for f in "$@"; do [ -e "$f" ] || [ -L "$f" ] || continue rm -rf "$f" 2>/dev/null [ -e "$f" ] || [ -L "$f" ] && left="$left $f" done [ -z "$left" ] && return 0 try=$((try + 1)) sleep 1 done echo "!! Не удалось удалить за 4 попытки:$left" return 1 } _zm_rm_done() { if [ "$1" = 0 ] && [ -n "$ZM_RM_INNER" ]; then ZM_RB_NOTE="" echo " ✓ Прежний $2 удалён — ставим заново" return 0 fi if [ "$1" = 0 ]; then echo "==> Готово: $2 удалён полностью, остатков нет" _zm_rb_note return 0 fi ZM_RB_NOTE="" echo "ОШИБКА: $2 удалён не полностью — что осталось, написано выше. Перезагрузите роутер и нажмите «Удалить» ещё раз" 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() { ZM_RM_INNER=1 "$1" || { ZM_RM_INNER=""; echo "ОШИБКА: не удалось удалить — переустановка остановлена"; return 1; } ZM_RM_INNER="" "$2" } _tg_arch_rs() { _tg_arch_init 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() { _tg_arch_init 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_rs_uci() { [ -f /etc/config/tg-ws-proxy-rs ] && [ -f "$TG_INIT_RS" ] && ! grep -q -- '--secret' "$TG_INIT_RS" } _tg_rs_off() { _tg_rs_uci && [ "$(uci -q get tg-ws-proxy-rs.main.enabled)" != 1 ] } _tg_hex() { if command -v hexdump >/dev/null 2>&1; then printf '%s' "$1" | hexdump -v -e '1/1 "%02x"' else printf '%s' "$1" | od -An -v -tx1 | tr -d ' \n'; fi } _tg_rs_link_secret() { local secret="$1" domain="$2" key="$1" [ -n "$secret" ] || return 0 case "$secret" in dd*|ee*) [ "${#secret}" -gt 32 ] && key="$(printf '%s' "$secret" | cut -c3-34)" ;; esac if [ -n "$domain" ]; then printf 'ee%s%s' "$key" "$(_tg_hex "$domain")" elif [ "$key" != "$secret" ]; then printf '%s' "$secret" else printf 'dd%s' "$secret" fi } 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 local rs_uci="false" rs_enabled="true" rs_port=2443 rs_host="" rs_domain="" v 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 v=$(apk info -v 2>/dev/null | grep '^tg-ws-proxy-[0-9]' | head -n1 | sed -E 's/^tg-ws-proxy-([0-9.]+)(-r([0-9]+))?.*/\1 \3/') else v=$(_opkg_ver tg-ws-proxy | sed -E 's/^([0-9.]+)(-r?([0-9]+))?.*/\1 \3/') fi set -- $v mt_ver="$(_tg_mt_show "$1" "$2")" fi 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 secret_mt="$(_tg_mt_secret)" if _tg_rs_uci; then rs_uci="true" _tg_rs_off && rs_enabled="false" rs_port="$(uci -q get tg-ws-proxy-rs.main.port)"; [ -n "$rs_port" ] || rs_port=1443 secret_rs="$(uci -q get tg-ws-proxy-rs.main.secret)"; secret_rs="${secret_rs%%,*}" rs_host="$(uci -q get tg-ws-proxy-rs.main.link_ip)" rs_domain="$(uci -q get tg-ws-proxy-rs.main.listen_faketls_domain)" elif [ -f "$TG_INIT_RS" ]; then secret_rs="$(_tg_rs_init_secret)" [ -z "$secret_rs" ] && [ -f "$TG_SECRET_RS_FILE" ] && secret_rs="$(head -n1 "$TG_SECRET_RS_FILE" | tr -cd '0-9a-fA-F')" rs_port="$(_tg_port_of rust)" rs_domain="$(_tg_init_arg "$TG_INIT_RS" --listen-faketls-domain '[A-Za-z0-9.-]')" v="$(_tg_init_arg "$TG_INIT_RS" --host '[0-9.]')" case "$v" in ''|0.0.0.0|127.*) ;; *) rs_host="$v" ;; esac fi if [ "$rs" = installed ] && { [ "$rs_uci" = true ] || [ -z "$rs_ver" ]; }; then v="$("$TG_BIN_RS" --version 2>/dev/null | awk '{print $2}')"; [ -n "$v" ] && rs_ver="$(_ver_norm "$v")" fi secret_rs="$(_tg_rs_link_secret "$secret_rs" "$rs_domain")" lan_ip="$(_zm_lan_ip)" local mt_port mt_host="" mt_link="" go_port mt_port="$(_tg_port_of mtproto)"; go_port="$(_tg_port_of socks5)" v="$(_tg_kv "$TG_CONF_MT" HOST)" case "$v" in ''|0.0.0.0|127.*) ;; *) mt_host="$v" ;; esac if [ -n "$secret_mt" ]; then v="$(_tg_kv "$TG_CONF_MT" FAKE_TLS_DOMAIN)" if [ -n "$v" ]; then mt_link="ee$secret_mt$(_tg_hex "$v")"; else mt_link="dd$secret_mt"; fi fi local mt_l go_l rs_l mt_l="$(_tg_latest mt)"; mt_l="$(_tg_mt_show "$(_tg_mt_base "${mt_l%% *}")" "${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,"rust_uci":%s,"rust_enabled":%s,"rust_port":"%s","rust_host":"%s","lan_ip":"%s","secret_mtproto":"%s","secret_rust":"%s","mtproto_port":"%s","mtproto_host":"%s","link_mtproto":"%s","socks5_port":"%s","rust_key":"%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)" \ "$rs_uci" "$rs_enabled" "$(esc "$rs_port")" "$(esc "$rs_host")" \ "$(esc "$lan_ip")" "$(esc "$secret_mt")" "$(esc "$secret_rs")" \ "$(esc "$mt_port")" "$(esc "$mt_host")" "$(esc "$mt_link")" "$(esc "$go_port")" "$(_tg_rs_uci || esc "$(_tg_rs_init_secret)")" "$(_tg_auto_json)" } TG_CONF_MT="/etc/tg-ws-proxy/config.conf" # Значение KEY=… из конфигурации, которую читает init.d (без выполнения файла) _tg_kv() { [ -f "$1" ] || return 0 awk -v k="$2" 'index($0, k "=") == 1 { v = substr($0, length(k) + 2); sub(/[ \t]*(#.*)?\r?$/, "", v); if (v ~ /^".*"$/ || v ~ /^\047.*\047$/) v = substr(v, 2, length(v) - 2) } END { printf "%s", v }' "$1" } _tg_mt_secret() { _tg_kv "$TG_SECRET_MT_FILE" SECRET | tr -cd '0-9a-fA-F' | tr 'A-F' 'a-f'; } # Аргумент ключа из строки запуска в init.d: _tg_init_arg файл --ключ 'класс символов' _tg_init_arg() { [ -f "$1" ] || return 0 sed -n "s/.*$2[[:space:]=]\{1,\}[\"']\{0,1\}\($3\{1,\}\).*/\1/p" "$1" | head -n1 } _tg_rs_init_secret() { _tg_init_arg "$TG_INIT_RS" --secret '[0-9a-fA-F]' | tr 'A-F' 'a-f'; } _tg_port_of() { local v="" case "$1" in mtproto) v="$(_tg_kv "$TG_CONF_MT" PORT | tr -cd 0-9)"; echo "${v:-1443}" ;; socks5) v="$(_tg_init_arg "$TG_INIT_GO" --port '[0-9]')"; echo "${v:-2080}" ;; rust) if _tg_rs_uci; then v="$(uci -q get tg-ws-proxy-rs.main.port)"; echo "${v:-1443}" else v="$(_tg_init_arg "$TG_INIT_RS" --port '[0-9]')"; echo "${v:-2443}"; fi ;; esac } _tg_bin_of() { case "$1" in mtproto) echo tg-ws-proxy ;; socks5) echo tg-ws-proxy-go ;; rust) echo tg-ws-proxy-rs ;; esac; } _tg_title_of() { case "$1" in mtproto) echo "Go (MTProto)" ;; socks5) echo "SOCKS5" ;; rust) echo "Rust (MTProto)" ;; esac; } # Слушает ли кто-то TCP-порт на роутере _zm_tcp_listen() { awk -v P="$(printf '%04X' "$1")" '$4 == "0A" { n = split($2, a, ":"); if (toupper(a[n]) == P) { f = 1; exit } } END { exit !f }' /proc/net/tcp /proc/net/tcp6 2>/dev/null } # Какая программа слушает TCP-порт (имя процесса), пусто — никто _zm_port_owner() { local ino d ino="$(awk -v P="$(printf '%04X' "$1")" '$4 == "0A" { n = split($2, a, ":"); if (toupper(a[n]) == P) { print $10; exit } }' /proc/net/tcp /proc/net/tcp6 2>/dev/null)" [ -n "$ino" ] || return 0 for d in /proc/[0-9]*; do ls -l "$d/fd" 2>/dev/null | grep -qF "socket:[$ino]" || continue cat "$d/comm" 2>/dev/null || echo "?" return 0 done echo "?" } # Проверка порта: свободен, занят этим же прокси или другой программой _tg_port_state() { local id="$1" p="$2" o x case "$p" in ''|*[!0-9]*) echo bad; return ;; esac [ "${#p}" -le 5 ] && [ "$p" -ge 1 ] && [ "$p" -le 65535 ] || { echo bad; return; } for x in mtproto socks5 rust; do [ "$x" = "$id" ] && continue [ -x "$(_tg_auto_init "$x")" ] || continue [ "$(_tg_port_of "$x")" = "$p" ] && { echo "tg $x"; return; } done o="$(_zm_port_owner "$p")" [ -n "$o" ] || { echo free; return; } [ "$o" = "$(_tg_bin_of "$id")" ] && { echo self; return; } echo "busy $o" } tg_port_check() { local st st="$(_tg_port_state "$1" "$2")" case "$st" in free|self) printf '{"ok":true,"free":true,"self":%s}\n' "$([ "$st" = self ] && echo true || echo false)" ;; bad) echo '{"ok":true,"free":false,"why":"порт — число от 1 до 65535"}' ;; "tg "*) printf '{"ok":true,"free":false,"why":"этот порт уже у %s"}\n' "$(esc "$(_tg_title_of "${st#tg }")")" ;; *) printf '{"ok":true,"free":false,"why":"порт занят программой %s"}\n' "$(esc "${st#busy }")" ;; esac } # Ключ для прокси: печатает нормализованный ключ или ошибку в stderr _tg_secret_norm() { local id="$1" s d s="$(printf '%s' "$2" | tr -d ' \t\r\n' | tr 'A-F' 'a-f')" case "$s" in *[!0-9a-f]*|'') echo "ключ — только цифры 0-9 и буквы a-f" >&2; return 1 ;; esac if [ "$id" = mtproto ]; then case "$s" in dd*) [ "${#s}" = 34 ] && s="${s#dd}" ;; esac case "$s" in ee*) [ "${#s}" -gt 34 ] && { echo "этот прокси не принимает ключи ee… (FakeTLS) — нужен ключ из 32 знаков" >&2; return 1; } ;; esac [ "${#s}" = 32 ] || { echo "нужно ровно 32 знака, сейчас ${#s}" >&2; return 1; } printf '%s' "$s"; return 0 fi [ "${#s}" = 32 ] && { printf '%s' "$s"; return 0; } case "$s" in dd*) [ "${#s}" = 34 ] && { printf '%s' "$s"; return 0; } ;; ee*) [ "${#s}" -ge 38 ] && [ $(( ${#s} % 2 )) = 0 ] || { echo "ключ ee… — это ee, 32 знака ключа и имя сайта в hex" >&2; return 1; } d="$(printf '%s' "$s" | cut -c35- | awk '{ h = "0123456789abcdef"; o = ""; for (i = 1; i < length($0); i += 2) o = o sprintf("%c", (index(h, substr($0, i, 1)) - 1) * 16 + index(h, substr($0, i + 1, 1)) - 1); print o }')" printf '%s' "$d" | grep -qE '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$' && [ "${#d}" -le 253 ] \ || { echo "в ключе ee… после 32 знаков должно быть имя сайта в hex (например, google.com)" >&2; return 1; } printf '%s' "$s"; return 0 ;; esac echo "нужно 32 знака (или dd + 32, или ee + 32 + сайт в hex), сейчас ${#s}" >&2 return 1 } _tg_bk() { local f b d="$JOBS_DIR/tg-conf-bak" mkdir -p "$d" for f in "$TG_INIT_GO" "$TG_INIT_RS" /etc/init.d/tg-ws-proxy "$TG_CONF_MT" "$TG_SECRET_MT_FILE" "$TG_SECRET_RS_FILE"; do b="$d/$(printf '%s' "$f" | tr / _)" case "$1" in save) rm -f "$b"; [ -f "$f" ] && cp -p "$f" "$b" ;; restore) [ -f "$b" ] && cp -p "$b" "$f" ;; esac done [ "$1" = save ] || rm -rf "$d" return 0 } _tg_listening() { local t=0 while [ "$t" -lt 10 ]; do sleep 1 pidof "$(_tg_bin_of "$1")" >/dev/null 2>&1 && _zm_tcp_listen "$2" && return 0 t=$((t + 1)) done return 1 } tg_conf_set() { local id="$1" port="${2%%:*}" sec="${2#*:}" init was="" st err cur_port cur_sec case "$id" in mtproto|socks5|rust) ;; *) echo '{"error":"у этого прокси порт и ключ не меняются"}'; return 1 ;; esac init="$(_tg_auto_init "$id")" [ -f "$init" ] || { echo '{"error":"прокси не установлен"}'; return 1; } [ "$id" = rust ] && _tg_rs_uci && { echo '{"error":"Rust установлен пакетом luci-app-tg-ws-proxy-rs — порт и ключ меняются в его настройках"}'; return 1; } _tg_auto_busy "$id" && { echo '{"error":"Идёт установка или удаление этого прокси — дождитесь окончания"}'; return 1; } [ "$id" = socks5 ] && sec="" port="$(printf '%s' "$port" | sed 's/^0*//')" st="$(_tg_port_state "$id" "$port")" case "$st" in free|self) ;; bad) echo '{"error":"Порт — число от 1 до 65535"}'; return 1 ;; "tg "*) printf '{"error":"Порт %s уже у %s — выберите другой"}\n' "$port" "$(esc "$(_tg_title_of "${st#tg }")")"; return 1 ;; *) printf '{"error":"Порт %s уже занят программой %s — выберите другой"}\n' "$port" "$(esc "${st#busy }")"; return 1 ;; esac if [ "$id" != socks5 ]; then sec="$(_tg_secret_norm "$id" "$sec" 2>"$JOBS_DIR/tg_conf.err")" || { err="$(cat "$JOBS_DIR/tg_conf.err")"; rm -f "$JOBS_DIR/tg_conf.err"; printf '{"error":"Ключ не подходит: %s"}\n' "$(esc "$err")"; return 1; } rm -f "$JOBS_DIR/tg_conf.err" fi cur_port="$(_tg_port_of "$id")" case "$id" in mtproto) cur_sec="$(_tg_mt_secret)" ;; rust) cur_sec="$(_tg_rs_init_secret)" ;; esac [ "$port" = "$cur_port" ] && [ "$sec" = "$cur_sec" ] && { printf '{"ok":true,"same":true}\n'; return 0; } pidof "$(_tg_bin_of "$id")" >/dev/null 2>&1 && was=1 _tg_bk save case "$id" in mtproto) mkdir -p "$(dirname "$TG_CONF_MT")" [ -f "$TG_CONF_MT" ] || printf 'HOST=0.0.0.0\nPORT=1443\n' > "$TG_CONF_MT" if grep -q '^PORT=' "$TG_CONF_MT"; then sed -i "s/^PORT=.*/PORT=$port/" "$TG_CONF_MT"; else echo "PORT=$port" >> "$TG_CONF_MT"; fi if grep -q '^SECRET=' "$TG_SECRET_MT_FILE" 2>/dev/null; then sed -i "s/^SECRET=.*/SECRET=$sec/" "$TG_SECRET_MT_FILE"; else echo "SECRET=$sec" >> "$TG_SECRET_MT_FILE"; fi ;; socks5) if grep -q -- '--port[[:space:]=]\{1,\}[0-9]' "$init"; then sed -i "s/--port\([[:space:]=]\{1,\}\)[0-9]\{1,5\}/--port\1$port/" "$init" else sed -i "s#\($TG_BIN_GO\)#\1 --port $port#" "$init"; fi ;; rust) if grep -q -- '--port[[:space:]=]\{1,\}[0-9]' "$init"; then sed -i "s/--port\([[:space:]=]\{1,\}\)[0-9]\{1,5\}/--port\1$port/" "$init" else sed -i "s#\($TG_BIN_RS\)#\1 --port $port#" "$init"; fi if grep -q -- '--secret[[:space:]=]\{1,\}["'"'"']\{0,1\}[0-9a-fA-F]' "$init"; then sed -i "s/--secret\([[:space:]=]\{1,\}[\"']\{0,1\}\)[0-9a-fA-F,]\{1,\}/--secret\1$sec/" "$init" else sed -i "s#\($TG_BIN_RS\)#\1 --secret $sec#" "$init"; fi printf '%s\n' "$sec" > "$TG_SECRET_RS_FILE" ;; esac "$init" restart >/dev/null 2>&1 if _tg_listening "$id" "$port"; then _tg_bk drop logger -t zapret-manager "TG WS Proxy ($id): порт $port${sec:+, ключ изменён}" printf '{"ok":true,"running":true}\n' return 0 fi if [ -z "$was" ]; then _tg_bk drop printf '{"ok":true,"running":false}\n' return 0 fi _tg_bk restore "$init" restart >/dev/null 2>&1 printf '{"error":"С портом %s прокси не запустился — вернули прежние порт и ключ, прокси работает как раньше"}\n' "$port" return 1 } 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)" } # Установлен ли хоть один прокси для Telegram (MTProto, SOCKS5, Rust, sTGWS) _tg_any_installed() { local id for id in mtproto socks5 rust tgws; do [ -x "$(_tg_auto_init "$id")" ] && return 0 done return 1 } tg_auto_tick() { local init init="$(_tg_auto_init "$1")" || return 0 [ -x "$init" ] || return 0 "$init" enabled >/dev/null 2>&1 || return 0 [ "$1" = rust ] && _tg_rs_off && 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 [ "$id" = rust ] && _tg_rs_off && 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 fb="" raz="ipk"; [ "$PKG" = "apk" ] && raz="apk" echo "==> Узнаём последнюю версию на GitHub ($TG_REPO_MT)" tag="$(ZM_VER_FORCE=1 _zm_cached tg_mt _tg_tag_mt)" if [ -z "$tag" ]; then tag="$TG_MTPROTO_TAG $TG_MTPROTO_REV"; fb=1 fi rev="${tag#* }"; tag="${tag%% *}"; [ "$rev" = "$tag" ] && rev="$(_tg_mt_rev "$tag")" ver="$(_tg_mt_base "$tag")" cur="$(tg_status | sed -n 's/.*"mtproto_version":"\([^"]*\)".*/\1/p')" if [ -n "$fb" ]; then echo " ! GitHub не ответил или для этой архитектуры нет пакета — ставим проверенную $(_tg_mt_show "$ver" "$rev")${cur:+, сейчас стоит $cur}" else echo " ✓ Последняя версия: $(_tg_mt_show "$ver" "$rev")${cur:+, сейчас стоит $cur}"; fi 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 [ "$tag" != "$TG_MTPROTO_TAG" ]; then echo "!! Для версии $(_tg_mt_show "$ver" "$rev") нет пакета под эту архитектуру — ставим проверенную $(_tg_mt_show "$TG_MTPROTO_VER" "$TG_MTPROTO_REV")" ver="$TG_MTPROTO_VER"; rev="$TG_MTPROTO_REV" url="$(_tg_mt_url "$TG_MTPROTO_TAG" "$ver" "$rev")" 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:-$(_tg_mt_show "$ver" "$rev")}" } 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 "==> Чистим файлы и настройки" _zm_wipe /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/config/tg-ws-proxy-opkg /etc/rc.d/*tg-ws-proxy || rc=1 _zm_cron_drop 'tg-ws-proxy([^-]|$)' "TG WS Proxy" _zm_rm_done "$rc" "TG WS Proxy MTProto" } do_tg_install_socks5() { _ensure_deps echo "==> Устанавливаем TG WS Proxy SOCKS5" local file url tag tmp="$TG_BIN_GO.new" _tg_arch_init 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" 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 "==> Удаляем программу, службу и настройки" local rc=0 _zm_wipe "$TG_BIN_GO" "$TG_BIN_GO.new" "$TG_INIT_GO" "$TG_VER_GO_FILE" /etc/rc.d/*tg-ws-proxy-go || rc=1 _zm_cron_drop 'tg-ws-proxy-go' "TG WS Proxy SOCKS5" _zm_rm_done "$rc" "TG WS Proxy SOCKS5" } do_tg_install_rust() { _ensure_deps echo "==> Устанавливаем TG WS Proxy Rust" local file url tmp_archive tmp_dir secret tag _tg_arch_init 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" 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 "==> Удаляем программу, службу и настройки" local rc=0 _zm_wipe "$TG_BIN_RS" "$TG_INIT_RS" "$TG_SECRET_RS_FILE" "$TG_VER_RS_FILE" /etc/rc.d/*tg-ws-proxy-rs || rc=1 _zm_cron_drop 'tg-ws-proxy-rs' "TG WS Proxy Rust" _zm_rm_done "$rc" "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 ;; *:port=*) tg_port_check "$variant" "${action#port=}"; return ;; *:conf=*) tg_conf_set "$variant" "${action#conf=}"; return ;; rust:install|rust:update|rust:reinstall|rust:remove) _tg_rs_uci && { echo '{"error":"TG WS Proxy (Rust) установлен пакетом luci-app-tg-ws-proxy-rs — обновляйте и удаляйте его через менеджер пакетов, настройки — в Службы → Telegram WS Proxy (Rust)"}'; return 1; } ;; esac case "$variant:$action" in 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_ver tgws | 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:-не определён}" _tsc_tg_ensure >/dev/null 2>&1 _ab_dns_bg 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 "==> Чистим файлы" _zm_wipe /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* && echo " ✓ Файлы, правила файрвола и настройки удалены" || rc=1 _zm_cron_drop 'tgws' "sTGWS" _zm_rm_listed stgws || rc=1 _zm_rm_done "$rc" "sTGWS" } 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_unstale() { [ -s "$TEST_BACKUP" ] || return 0 if [ -d /opt/zapret ] && cp -f "$TEST_BACKUP" "$CONF"; then echo "==> Прошлый тест был прерван — возвращаем прежние настройки Zapret" zapret_restart fi rm -f "$TEST_BACKUP" } _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 '{ n = split($0, a, " → "); if (n < 2 || a[n] !~ /^[0-9]+\/[0-9]+/) next; print a[n] + 0, $0 }' "$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" ;; custom|custom_yt) [ -s "$TEST_CUSTOM_FILE" ] && _test_custom_clean < "$TEST_CUSTOM_FILE" >> "$out" ;; esac case "$mode" in youtube|custom|custom_yt) ;; *) sed -i '/^#Y/d' "$out" ;; esac } _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; } _test_unstale 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|custom_yt) 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 ZT_STOPPED="" if [ -f "$TEST_STOP_FLAG" ]; then echo "==> Тест остановлен, возвращаем настройки" ZT_STOPPED=1 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) ;; custom|custom_yt) grep -q '^#' "$TEST_CUSTOM_FILE" 2>/dev/null || { echo '{"error":"список своих стратегий пуст — добавьте их и сохраните"}'; return 1; } ;; *) echo '{"error":"неизвестный режим теста"}'; return 1 ;; esac _test_running && { echo '{"error":"тест уже идёт"}'; return 1; } rm -f "$ZA_RUN" job_start strategy_test do_test_run "$mode" ;; custom_set) test_custom_set "$mode" ;; ts_check) mkdir -p "$JOBS_DIR"; ts_check "$mode" ;; auto_set) test_auto_set "$mode" ;; auto_run) [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } _test_running && { echo '{"error":"тест уже идёт"}'; return 1; } job_start strategy_test do_autobest ;; auto_tick) test_auto_tick ;; auto_clear) rm -f "$ZA_LAST" "$ZA_LAST_YT"; test_status ;; 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|custom|custom_yt) 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 custom custom_yt; do has="$has,\"has_results_$m\":$([ -s "$(_test_results_file "$m")" ] && echo true || echo false)" done _za_running && mode=auto printf '{"running":%s,"mode":"%s","domains":"%s"%s,"custom":"%s","auto":%s,%s}\n' "$running" "$(esc "$mode")" "$(esc "$(cat "$TEST_DIR/domain_list" 2>/dev/null | tr '\n' ' ' | sed 's/ $//')")" "$has" \ "$(esc_ml "$(cat "$TEST_CUSTOM_FILE" 2>/dev/null)")" "$(_za_json)" "$(_zt_json)" } 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)")" } ZO_RKN_URL="${GH_RAW}/IndeecFOX/zapret4rocket/refs/heads/master/extra_strats/TCP/RKN/List.txt" ZO_RKN_FILE="/opt/zapret/ipset/zapret-hosts-rkn.txt" ZO_RKN_FLAG="$ZM_STATE_DIR/zapret_rkn" ZO_USER_HL="--hostlist=/opt/zapret/ipset/zapret-hosts-user.txt" ZO_EXCL_HL="--hostlist-exclude=/opt/zapret/ipset/zapret-hosts-user-exclude.txt" ZO_WSS="--wssize 1:6" ZO_IPID="--ip-id=zero" ZO_IPID_FLAG="$ZM_STATE_DIR/zapret_ipid" ZO_YT_HL="--hostlist=/opt/zapret/ipset/zapret-hosts-google.txt" ZO_SCRIPT="/opt/zapret/init.d/openwrt/custom.d/50-script.sh" _zo_rkn_on() { case "$ZM_RKN" in 1) return 0 ;; 0) return 1 ;; esac; _nfq_opt_body | grep -qxF -- "--hostlist=$ZO_RKN_FILE"; } _zo_rkn_fits() { case "$ZM_FITS" in 1) return 0 ;; 0) return 1 ;; esac; _nfq_opt_body | grep -qxF -- "$ZO_EXCL_HL"; } _zo_rkn_patch() { [ -s "$ZO_RKN_FILE" ] || return 1 _zo_rkn_on && return 0 _zo_rkn_fits || return 1 [ -f /opt/zapret/ipset/zapret-hosts-user.txt ] || : > /opt/zapret/ipset/zapret-hosts-user.txt awk -v EX="$ZO_EXCL_HL" -v U="$ZO_USER_HL" -v R="--hostlist=$ZO_RKN_FILE" ' /^[[:space:]]*option NFQWS_OPT \047[[:space:]]*$/ { inb = 1; print; next } inb && /^[[:space:]]*\047[[:space:]]*$/ { inb = 0 } inb && $0 == EX { print U; print R; next } { print }' "$CONF" > "$CONF.zo" && mv -f "$CONF.zo" "$CONF" _zo_rkn_on } _zo_rkn_unpatch() { awk -v EX="$ZO_EXCL_HL" -v U="$ZO_USER_HL" -v R="--hostlist=$ZO_RKN_FILE" ' /^[[:space:]]*option NFQWS_OPT \047[[:space:]]*$/ { inb = 1; print; next } inb && /^[[:space:]]*\047[[:space:]]*$/ { if (held) print U; held = 0; inb = 0; print; next } inb && $0 == U && !held { held = 1; next } inb && $0 == R { if (held) { print EX; held = 0 } next } inb && held { print U; held = 0 } { print }' "$CONF" > "$CONF.zo" && mv -f "$CONF.zo" "$CONF" } ZO_USER_FLAG="$ZM_STATE_DIR/zapret_userlist" ZO_USER_FILE="/opt/zapret/ipset/zapret-hosts-user.txt" _zo_user_on() { [ -f "$ZO_USER_FLAG" ] && ! _zo_rkn_on && ! _zo_rkn_fits && _nfq_opt_body | grep -qxF -- "$ZO_USER_HL"; } _zo_user_count() { sed 's/#.*//' "$ZO_USER_FILE" 2>/dev/null | awk 'NF { n++ } END { print n + 0 }'; } _zo_user_patch() { local k _zo_rkn_on && return 1 [ -f "$ZO_USER_FLAG" ] && [ -s "$ZO_USER_FLAG" ] && ! _zo_rkn_fits && _nfq_opt_body | grep -qxF -- "$ZO_USER_HL" && return 0 _zo_rkn_fits || return 1 [ -f "$ZO_USER_FILE" ] || : > "$ZO_USER_FILE" k="$(_nfq_opt_body | awk -v EX="$ZO_EXCL_HL" -v U="$ZO_USER_HL" '$0 == U { n++ } $0 == EX { print n + 1; exit }')" [ -n "$k" ] || return 1 awk -v EX="$ZO_EXCL_HL" -v U="$ZO_USER_HL" ' /^[[:space:]]*option NFQWS_OPT \047[[:space:]]*$/ { inb = 1; print; next } inb && /^[[:space:]]*\047[[:space:]]*$/ { inb = 0 } inb && !done && $0 == EX { print U; done = 1; next } { print }' "$CONF" > "$CONF.zo" && mv -f "$CONF.zo" "$CONF" mkdir -p "$ZM_STATE_DIR"; echo "$k" > "$ZO_USER_FLAG" ! _zo_rkn_fits && _nfq_opt_body | grep -qxF -- "$ZO_USER_HL" } _zo_user_unpatch() { local k k="$(cat "$ZO_USER_FLAG" 2>/dev/null)" case "$k" in ''|*[!0-9]*) return 0 ;; esac _zo_rkn_on && return 0 _zo_rkn_fits && return 0 awk -v EX="$ZO_EXCL_HL" -v U="$ZO_USER_HL" -v K="$k" ' /^[[:space:]]*option NFQWS_OPT \047[[:space:]]*$/ { inb = 1; print; next } inb && /^[[:space:]]*\047[[:space:]]*$/ { inb = 0 } inb && $0 == U && ++n == K { print EX; next } { print }' "$CONF" > "$CONF.zo" && mv -f "$CONF.zo" "$CONF" } _zo_wss_on() { _nfq_opt_body | grep -qxF -- "$ZO_WSS"; } ZO_DOH_FLAG="$ZM_STATE_DIR/zapret_doh" ZO_DOH_DEF="cloudflare-dns.com,dns.google,dns.comss.one,dns.quad9.net" ZO_DOH_MK='^[ \t]*#[ \t]*DoH[ \t]*$' _zo_doh_on() { case "$ZM_DOH" in 1) return 0 ;; 0) return 1 ;; esac; _nfq_opt_body | awk -v M="$ZO_DOH_MK" '$0 ~ M { f = 1 } END { exit !f }'; } _zo_doh_hosts() { local x="" id c if _doh_fo_on; then c="$(_doh_fo_get chain "")" set -f for id in $([ -n "$ZO_DOH_PLAN" ] || _doh_fo_get pref "") $(IFS=,; echo $c); do x="$x $(_doh_url_of "$id")"; done set +f fi { [ -n "$ZO_DOH_PLAN" ] || uci -q show https-dns-proxy 2>/dev/null; } | awk -v P="$ZO_DOH_PLAN" -v X="$x" ' function add(u, h) { if (u !~ /^https:\/\//) return h = tolower(u); sub(/^https:\/\//, "", h); sub(/[\/:?].*$/, "", h) if (h !~ /^[a-z0-9.-]*[a-z][a-z0-9.-]*$/ || (h in s)) return s[h] = 1; d[++n] = h } P == "" && /\.resolver_url=/ { v = $0; sub(/^[^=]*=/, "", v); gsub(/\047/, "", v); add(v); r++ } END { if (P != "") { add(P); r = 1 } if (!r) exit m = split(X, a, " ") for (i = 1; i <= m; i++) add(a[i]) for (i = 1; i <= n; i++) { k = 0 for (j = 1; j <= n; j++) if (j != i && length(d[i]) > length(d[j]) && substr(d[i], length(d[i]) - length(d[j])) == "." d[j]) k = 1 if (!k) o = o (o == "" ? "" : ",") d[i] } print o }' } _zo_doh_doms() { local d; d="$(_zo_doh_hosts)"; echo "${d:-$ZO_DOH_DEF}"; } _zo_doh_tpl() { local t t="$(grep '^--' "$ZO_DOH_FLAG" 2>/dev/null | grep -v '^--hostlist-domains=' | grep -vx -- '--new')" if ! printf '%s\n' "$t" | grep -q '^--filter-tcp=' || ! printf '%s\n' "$t" | grep -q '^--dpi-desync='; then t="$(printf '%s\n' --filter-tcp=443 --dpi-desync=hostfakesplit --dpi-desync-hostfakesplit-mod=host=ya.ru --dpi-desync-hostfakesplit-midhost=host-2 \ --dpi-desync-split-seqovl=726 --dpi-desync-fooling=badsum,badseq --dpi-desync-badseq-increment=0)" fi printf '%s\n' "$t" } _zo_doh_scan() { _nfq_opt_body | awk -v M="$ZO_DOH_MK" ' NR == 1 { next } $0 ~ M { c++; if (!opt) top = 1; d = (c == 1); next } d && $0 == "--new" { d = 0 } d && dom == "" && /^[ \t]*--hostlist-domains=/ { dom = $0; sub(/^[ \t]*--hostlist-domains=/, "", dom); sub(/[ \t\r]+$/, "", dom) } /^[ \t]*--/ && $0 != "--new" { opt = 1 } END { print (c == 0 ? "none" : (c == 1 && top) ? "top" : "mid") " " dom }' } _zo_doh_cut() { local f="$CONF.zdoh" awk -v M="$ZO_DOH_MK" -v C="${1:-/dev/null}" -v q="'" ' !inb && /^[ \t]*option NFQWS_OPT \047[ \t]*$/ { inb = 1; n = 0; print; next } inb && $0 ~ ("^[ \t]*" q "[ \t]*$") { while (n > 0 && b[n] == "--new") n-- for (i = 1; i <= n; i++) print b[i] inb = 0; d = 0; print; next } inb && $0 ~ M { d = 1; c++; next } inb && d && $0 == "--new" { d = 0; next } inb && d && /^[ \t]*#/ { b[++n] = $0; next } inb && d { if (c == 1) print > C; next } inb { if ($0 == "--new" && (n == 0 || b[n] == "--new")) next; b[++n] = $0; next } { print } END { exit (inb ? 1 : 0) }' "$CONF" > "$f" && [ -s "$f" ] && cat "$f" > "$CONF" rm -f "$f" } _zo_doh_put() { local f="$CONF.zdoh" { echo "#DoH"; _zo_doh_tpl | awk -v D="--hostlist-domains=$1" '!p && !/^--filter-/ { print D; p = 1 } { print } END { if (!p) print D }'; echo "--new"; } > "$f.b" awk -v F="$f.b" '!d && /^[ \t]*option NFQWS_OPT \047[ \t]*$/ { print; while ((getline l < F) > 0) print l; d = 1; next } { print } END { exit !d }' "$CONF" > "$f" && [ -s "$f" ] && cat "$f" > "$CONF" rm -f "$f" "$f.b" } _zo_doh_sync() { local doms cap [ -f "$ZO_DOH_FLAG" ] && [ -f "$CONF" ] || return 1 grep -q "^[[:space:]]*option NFQWS_OPT '[[:space:]]*\$" "$CONF" || return 1 _nochange_strategy && return 1 doms="$(_zo_doh_doms)" [ "$(ZM_NFQ_BODY=""; _zo_doh_scan)" = "top $doms" ] && return 1 cap="$ZO_DOH_FLAG.cap" rm -f "$cap" _zo_doh_cut "$cap" grep -q '^--dpi-desync=' "$cap" 2>/dev/null && mv -f "$cap" "$ZO_DOH_FLAG" rm -f "$cap" _zo_doh_put "$doms" return 0 } _zo_doh_follow() { [ -f "$ZO_DOH_FLAG" ] && [ -x /etc/init.d/zapret ] && [ -f "$CONF" ] || return 0 _test_running && return 0 [ -s "$TEST_BACKUP" ] && return 0 _zo_doh_sync || return 0 pgrep -f "/opt/zapre[t]/" >/dev/null 2>&1 || 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 return 0 } _yv_ipid_scan() { _nfq_opt_body | awk -v H="$ZO_YT_HL" -v Z="$ZO_IPID" -v S="$ZC_START_RX" -v E="$ZC_END_RX" ' function flush() { if (yt && tcp) { n++; if (z) k++ } yt = 0; tcp = 0; z = 0 } { l = $0; sub(/^[ \t]+/, "", l); sub(/[ \t\r]+$/, "", l) } tolower(l) ~ S { flush(); c = 1; next } c { if (tolower(l) ~ E) c = 0; next } l == "--new" { flush(); next } l == H { yt = 1 } l ~ /^--filter-tcp=/ { tcp = 1 } l == Z { z = 1 } END { flush(); print n + 0, k + 0 }' } _yv_ipid_on() { set -- ${ZM_YS:-$(_yv_ipid_scan)}; [ "${2:-0}" -gt 0 ]; } _yv_ipid_want() { local w=""; [ -s "$ZO_IPID_FLAG" ] && read -r w < "$ZO_IPID_FLAG"; case "$w" in on|off) echo "$w" ;; esac; } _yv_ipid_apply() { [ -f "$CONF" ] || return 1 case "$1" in on|off) ;; *) return 1 ;; esac awk -v W="$1" -v H="$ZO_YT_HL" -v Z="$ZO_IPID" -v S="$ZC_START_RX" -v E="$ZC_END_RX" -v q="'" ' function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t\r]+$/, "", s); return s } function flush( i, l, yt, tcp, put) { yt = 0; tcp = 0; put = 0 for (i = 1; i <= nb; i++) { l = trim(b[i]); if (l == H) yt = 1; if (l ~ /^--filter-tcp=/) tcp = 1 } for (i = 1; i <= nb; i++) { l = trim(b[i]) if (yt && tcp && ((W == "on" && l ~ /^--ip-id=/) || (W == "off" && l == Z))) continue print b[i] if (yt && tcp && W == "on" && l == H && !put) { print Z; put = 1 } } nb = 0 } !inb && /^[ \t]*option NFQWS_OPT \047[ \t]*$/ { inb = 1; print; next } inb && $0 ~ ("^[ \t]*" q "[ \t]*$") { flush(); inb = 0; print; next } inb && !c && tolower(trim($0)) ~ S { flush(); c = 1; print; next } inb && c { print; if (tolower(trim($0)) ~ E) c = 0; next } inb && trim($0) == "--new" { flush(); print; next } inb { b[++nb] = $0; next } { print } END { if (inb) exit 1 }' "$CONF" > "$CONF.ipid" && [ -s "$CONF.ipid" ] && { cmp -s "$CONF" "$CONF.ipid" || cat "$CONF.ipid" > "$CONF"; } rm -f "$CONF.ipid" } _yv_ipid_sync() { local w w="$(_yv_ipid_want)" [ -n "$w" ] && [ -f "$CONF" ] || return 0 _nochange_strategy && return 0 _yv_ipid_apply "$w" return 0 } _zo_wss_add() { _zo_wss_on && return 0 _nfq_opt_body | sed '1d;$d' | grep -q '^--' || return 1 awk -v W="$ZO_WSS" ' /^[[:space:]]*option NFQWS_OPT \047[[:space:]]*$/ { inb = 1; print; next } inb && /^[[:space:]]*\047[[:space:]]*$/ { print "--new"; print "--filter-tcp=443"; print W; inb = 0 } { print }' "$CONF" > "$CONF.zo" && mv -f "$CONF.zo" "$CONF" _zo_wss_on } _zo_wss_del() { _zo_wss_on || return 0 _nfq_drop_profiles '^--wssize 1:6$' case $? in 0|3) return 0 ;; esac return 1 } _zo_reapply() { [ -f "$ZO_RKN_FLAG" ] && _zo_rkn_patch [ -f "$ZO_USER_FLAG" ] && [ ! -f "$ZO_RKN_FLAG" ] && _zo_user_patch [ -n "$1" ] && _zo_wss_add return 0 } _zo_script() { local l [ -f "$ZO_SCRIPT" ] || return 0 l="$(head -n 5 "$ZO_SCRIPT" 2>/dev/null | grep '^[[:space:]]*#' | tr -d '\r' | tr 'A-Z' 'a-z')" case "$l" in *quic*) echo quic4all ;; *"discord media"*) echo discord-media ;; *"discord subnets"*) echo discord ;; *stun*) echo stun4all ;; *) echo other ;; esac } _zo_script_url() { case "$1" in stun4all|quic4all|discord-media) echo "${GH_RAW}/bol-van/zapret/master/init.d/custom.d.examples.linux/50-$1" ;; discord) echo "${GH_RAW}/bol-van/zapret/v70.5/init.d/custom.d.examples.linux/50-discord" ;; *) return 1 ;; esac } zapret_opt_set() { local what="${1%%:*}" val="${1#*:}" url tmp n [ -x /etc/init.d/zapret ] && [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } case "$what" in rkn) case "$val" in on) [ -f "$ZO_USER_FLAG" ] && { _zo_user_unpatch; rm -f "$ZO_USER_FLAG"; } _zo_rkn_on && { mkdir -p "$ZM_STATE_DIR"; touch "$ZO_RKN_FLAG"; status; return 0; } _zo_rkn_fits || { echo '{"error":"текущая стратегия не подходит для списков РКН — выберите другую"}'; return 1; } tmp="$ZO_RKN_FILE.zmdl" mkdir -p "$(dirname "$ZO_RKN_FILE")" rm -f "$tmp" curl -fsSL --connect-timeout 6 --max-time 16 -o "$tmp" "$ZO_RKN_URL" 2>/dev/null n="$(grep -c '^[A-Za-z0-9*][A-Za-z0-9.*-]*\.[A-Za-z]' "$tmp" 2>/dev/null)" if [ ! -s "$tmp" ] || grep -qi ' "$ZO_RKN_FILE"; rm -f "$tmp"; chmod 644 "$ZO_RKN_FILE" _zo_rkn_patch || { echo '{"error":"не удалось включить списки РКН в стратегии"}'; return 1; } mkdir -p "$ZM_STATE_DIR"; touch "$ZO_RKN_FLAG" ;; off) rm -f "$ZO_RKN_FLAG" _zo_rkn_unpatch rm -f "$ZO_RKN_FILE" ;; *) echo '{"error":"неизвестное значение"}'; return 1 ;; esac ;; userlist) case "$val" in on) if [ -f "$ZO_RKN_FLAG" ] || _zo_rkn_on; then rm -f "$ZO_RKN_FLAG"; _zo_rkn_unpatch; rm -f "$ZO_RKN_FILE"; fi rm -f "$ZO_USER_FLAG" _zo_user_patch || { echo '{"error":"текущая стратегия не подходит для обхода по своему списку — выберите другую"}'; return 1; } ;; off) _zo_user_unpatch; rm -f "$ZO_USER_FLAG" ;; *) echo '{"error":"неизвестное значение"}'; return 1 ;; esac ;; ipid) case "$val" in on|off) ;; *) echo '{"error":"неизвестное значение"}'; return 1 ;; esac mkdir -p "$ZM_STATE_DIR"; echo "$val" > "$ZO_IPID_FLAG" _yv_ipid_apply "$val" || { echo '{"error":"блок стратегий Zapret (NFQWS_OPT) записан не так, как его пишет панель, — стратегия YouTube не тронута"}'; return 1; } ;; doh) _nochange_guard || return 1 case "$val" in on) mkdir -p "$ZM_STATE_DIR"; [ -s "$ZO_DOH_FLAG" ] || _zo_doh_tpl > "$ZO_DOH_FLAG" ;; off) rm -f "$ZO_DOH_FLAG"; _zo_doh_cut ;; *) echo '{"error":"неизвестное значение"}'; return 1 ;; esac ;; wssize) case "$val" in on) _zo_wss_add || { echo '{"error":"не удалось добавить блок --wssize: в стратегии нет ни одного профиля"}'; return 1; } ;; off) _zo_wss_del || { echo '{"error":"блок стратегий Zapret (NFQWS_OPT) записан не так, как его пишет панель, — блок --wssize не тронут"}'; return 1; } ;; *) echo '{"error":"неизвестное значение"}'; return 1 ;; esac ;; script) case "$val" in off) rm -f "$ZO_SCRIPT" ;; *) url="$(_zo_script_url "$val")" || { echo '{"error":"неизвестный скрипт"}'; return 1; } tmp="$ZO_SCRIPT.zmdl" mkdir -p "$(dirname "$ZO_SCRIPT")" rm -f "$tmp" curl -fsSL --connect-timeout 10 --max-time 30 -o "$tmp" "$url" 2>/dev/null if [ ! -s "$tmp" ] || grep -qi ' "$tmp" || { rm -f "$tmp"; echo '{"error":"не удалось записать файл"}'; return 1; } if [ ! -s "$tmp" ]; then rm -f "$tmp" "$TEST_CUSTOM_FILE"; printf '{"ok":true,"count":0}\n'; return 0; fi [ "$(wc -c < "$tmp")" -le 65536 ] || { rm -f "$tmp"; echo '{"error":"слишком большой список — не больше 64 КБ"}'; return 1; } head -n 1 "$tmp" | grep -q '^#' || { rm -f "$tmp"; echo '{"error":"каждая стратегия начинается со строки #Название — первая строка должна быть такой"}'; return 1; } bad="$(grep -v '^#' "$tmp" | grep -v '^--' | head -n 1)" [ -z "$bad" ] || { rm -f "$tmp"; printf '{"error":"%s"}\n' "$(esc "строка не похожа на параметр nfqws (должна начинаться с --): $bad")"; return 1; } grep -q "'" "$tmp" && { rm -f "$tmp"; echo "{\"error\":\"одинарные кавычки ' в стратегиях нельзя — они ломают конфиг Zapret\"}"; return 1; } bad="$(awk '/^#/ { if (name != "" && !opts) { print name; exit } name = $0; opts = 0; next } { opts = 1 } END { if (name != "" && !opts) print name }' "$tmp" | head -n 1)" [ -z "$bad" ] || { rm -f "$tmp"; printf '{"error":"%s"}\n' "$(esc "у стратегии $bad нет ни одного параметра")"; return 1; } n="$(grep -c '^#' "$tmp")" mv -f "$tmp" "$TEST_CUSTOM_FILE"; chmod 600 "$TEST_CUSTOM_FILE" printf '{"ok":true,"count":%s}\n' "$n" } ZA_TAG="# zm-autobest" ZA_MODE_FILE="$ZM_STATE_DIR/autobest.mode" ZA_LAST="$ZM_STATE_DIR/autobest.last" ZA_RUN="$TEST_DIR/autobest" ZA_TS="$ZM_STATE_DIR/autobest.ts" ZA_YT_FILE="$ZM_STATE_DIR/autobest.yt" ZA_LAST_YT="$ZM_STATE_DIR/autobest.last_yt" _za_mode() { case "$(cat "$ZA_MODE_FILE" 2>/dev/null)" in v) echo v ;; flowseal) echo flowseal ;; youtube) echo youtube ;; *) echo v_flowseal ;; esac; } _za_yt() { [ -f "$ZA_YT_FILE" ]; } ZA_TIME_FILE="$ZM_STATE_DIR/autobest.time" _za_time() { grep -F "$ZA_TAG" "$CRON_FILE" 2>/dev/null | head -n 1 | awk '{ printf "%02d:%02d\n", $2, $1 }'; } _za_running() { [ -f "$ZA_RUN" ] && _test_running; } _za_extras_save() { ZA_YV="$(grep -oE '^#[[:space:]]*Yv[0-9]+$' "$CONF" | head -n1 | sed 's/^#[[:space:]]*//')" ZA_DV="$(grep -oE '^#[[:space:]]*Dv[0-9]+$' "$CONF" | head -n1 | sed 's/^#[[:space:]]*Dv//')" ZA_GV="$(grep -oE '^#Gv[1-4](Xtreme)?$' "$CONF" | head -n1 | sed 's/^#Gv//; s/Xtreme$//')" ZA_XT=""; grep -q "^#Gv[0-9]\+Xtreme\$" "$CONF" && ZA_XT=1 ZA_YOFF=""; [ -f "$YV_OFF_FLAG" ] && ZA_YOFF=1 ZA_DOFF=""; [ -f "$DV_OFF_FLAG" ] && ZA_DOFF=1 ZA_GACT=""; _game_active && ZA_GACT=1 return 0 } _za_extras_restore() { local said="" ZM_NO_RESTART=1 if [ -n "$ZA_YOFF" ]; then strategy_set_youtube off >/dev/null 2>&1 && said="$said, YouTube выключен" elif [ -n "$ZA_YV" ]; then [ -s "$(_yv_file)" ] || do_yv_download >/dev/null 2>&1 if [ -s "$(_yv_file)" ] && grep -qxF "#$ZA_YV" "$(_yv_file)"; then case "$(strategy_set_youtube "$ZA_YV" 2>/dev/null)" in *'"ok":true'*) said="$said, YouTube $ZA_YV" ;; *) echo "!! Не удалось вернуть стратегию YouTube $ZA_YV" ;; esac else echo "!! Стратегии YouTube $ZA_YV нет в списке — YouTube остаётся от Flowseal" fi fi if [ -n "$ZA_DOFF" ]; then discord_set_dv off >/dev/null 2>&1 && said="$said, Discord выключен" elif [ -n "$ZA_DV" ]; then case "$(discord_set_dv "$ZA_DV" 2>/dev/null)" in *'"ok":true'*) said="$said, Discord Dv$ZA_DV" ;; *) echo "!! Не удалось вернуть стратегию Discord Dv$ZA_DV" ;; esac fi if [ -n "$ZA_GV" ]; then case "$(game_set "$ZA_GV" 2>/dev/null)" in *'"ok":true'*) said="$said, игры Gv$ZA_GV" [ -n "$ZA_XT" ] && ! grep -q "^#Gv[0-9]\+Xtreme\$" "$CONF" && game_toggle_xtreme >/dev/null 2>&1 && said="$said Xtreme" ;; *) echo "!! Не удалось вернуть игровую стратегию Gv$ZA_GV" ;; esac elif [ -z "$ZA_GACT" ] && _game_active; then game_set off >/dev/null 2>&1 && said="$said, игровая часть выключена, как было" fi ZM_NO_RESTART="" _nfq_normalize zapret_restart [ -n "$said" ] && echo " оставили ваши настройки: ${said#, }" return 0 } _za_last_write() { mkdir -p "$ZM_STATE_DIR" printf '%s|%s|%s|%s|%s|%s|%s|%s\n' "$(date +%s)" "$1" "$2" "$3" "$4" "$5" "$6" "$7" > "${ZA_LAST_TO:-$ZA_LAST}" } do_autobest() { local mode rc=0 mode="$(_za_mode)" mkdir -p "$TEST_DIR"; : > "$ZA_RUN" echo "==> Автоподбор стратегии: $(case "$mode" in v) echo v1–v10 ;; flowseal) echo Flowseal ;; youtube) echo 'только YouTube (Yv)' ;; *) echo 'v + Flowseal' ;; esac)$([ "$mode" != youtube ] && _za_yt && echo ', затем YouTube (Yv)')" [ -f "$CONF" ] || { echo "ОШИБКА: Zapret не установлен"; rm -f "$ZA_RUN"; return 1; } _test_unstale rm -f "$ZA_LAST_YT" if _nochange_strategy; then echo "==> Включено «Не изменять стратегию» (#nochange) — автоподбор ничего не меняет" _za_last_write "$mode" "" 0 0 0 "" nochange; rm -f "$ZA_RUN"; return 0 fi ZT_STOPPED="" if [ "$mode" = youtube ]; then _za_youtube; rc=$? else _za_main "$mode"; rc=$? if [ "$rc" = 0 ] && [ -z "$ZT_STOPPED" ] && _za_yt; then ZA_LAST_TO="$ZA_LAST_YT"; _za_youtube; ZA_LAST_TO="" fi fi rm -f "$ZA_RUN" return "$rc" } _za_youtube() { local res best yname yok ytot cur cok ctot r applied=0 echo "==> YouTube: подбираем стратегию Yv" if [ -f "$YV_OFF_FLAG" ]; then echo " YouTube выключен в стратегии — его настройку не трогаем" _za_last_write youtube "" 0 0 0 "" yoff; return 0 fi if _nochange_strategy; then echo "==> Включено «Не изменять стратегию» (#nochange) — YouTube не трогаем" _za_last_write youtube "" 0 0 0 "" nochange; return 0 fi cur="$(grep -oE '^#[[:space:]]*Yv[0-9]+$' "$CONF" | head -n1 | sed 's/^#[[:space:]]*//')" do_test_run youtube || { _za_last_write youtube "" 0 0 0 "$cur" error; return 1; } res="$(_test_results_file youtube)" best="$(grep -v '^Контрольный тест' "$res" 2>/dev/null | head -n 1)" if [ -z "$best" ] || [ -n "$ZT_STOPPED" ]; then echo "==> YouTube: лучшую стратегию определить не удалось — оставляем текущую" _za_last_write youtube "" 0 0 0 "$cur" none; return 0 fi yname="$(printf '%s' "$best" | sed 's/[[:space:]]*→.*$//')" yok="$(printf '%s' "$best" | sed -n 's/.*→[[:space:]]*\([0-9]*\)\/\([0-9]*\).*/\1/p')" ytot="$(printf '%s' "$best" | sed -n 's/.*→[[:space:]]*\([0-9]*\)\/\([0-9]*\).*/\2/p')" echo "==> Проверяем текущую настройку YouTube (${cur:-из основной стратегии}) на тех же адресах" r="$(_test_check_all_urls "$(_test_yt_urls)" "$TEST_DIR/log_cur_yt.txt")" cok="${r% *}"; ctot="${r#* }" echo " текущая: ${cok}/${ctot}, лучшая из теста: $yname — ${yok}/${ytot}" if [ "$yname" != "$cur" ] && [ "${yok:-0}" -gt "${cok:-0}" ] 2>/dev/null; then if _nochange_strategy; then echo "==> Пока шёл тест, включили «Не изменять стратегию» — ничего не меняем" _za_last_write youtube "$yname" "$yok" "$ytot" "$cok" "$cur" nochange; return 0 fi echo "==> Применяем $yname для YouTube" _zc_lift r="$(strategy_set_youtube "$yname" 2>/dev/null)" _zc_drop case "$r" in *'"ok":true'*) applied=1; echo "==> Готово: стратегия YouTube $yname применена" ;; *) echo "!! Не удалось применить $yname — YouTube оставлен как был" ;; esac if [ "$applied" = 1 ] && _nfq_opt_body | _ts_has; then date +%s > "$ZA_TS" echo " ! в стратегии есть метка ts — на компьютерах с Windows нужно один раз выполнить:" echo " netsh int tcp set global timestamps=enabled (в терминале от имени администратора)" fi [ "$applied" = 1 ] && logger -t zapret-manager "Автоподбор: для YouTube применена $yname ($yok/$ytot, было $cok/$ctot)" else echo "==> Текущая настройка YouTube не хуже — оставляем её" fi _za_last_write youtube "$yname" "$yok" "$ytot" "$cok" "$cur" "$([ "$applied" = 1 ] && echo applied || echo kept)" return 0 } _za_main() { local mode="$1" res best bname bok btot cok ctot cname urls applied=0 r cname="$(_nfq_names | sed 's/^#[[:space:]]*//' | grep -viE '^(udp443|DoH|nochange|Yv[0-9]+|Dv[0-9]+|Gv[0-9]+.*)$' | head -n 1)" [ -n "$cname" ] || cname="$(grep -m1 '^# ZMFS:' "$CONF" | sed 's/^# ZMFS://')" do_test_run "$mode" || { _za_last_write "$mode" "" 0 0 0 "" error; return 1; } res="$(_test_results_file "$mode")" best="$(grep -v '^Контрольный тест' "$res" 2>/dev/null | head -n 1)" if [ -z "$best" ] || [ -n "$ZT_STOPPED" ]; then echo "==> Лучшую стратегию определить не удалось — оставляем текущую" _za_last_write "$mode" "" 0 0 0 "" none; return 0 fi bname="$(printf '%s' "$best" | sed 's/[[:space:]]*→.*$//')" bok="$(printf '%s' "$best" | sed -n 's/.*→[[:space:]]*\([0-9]*\)\/\([0-9]*\).*/\1/p')" btot="$(printf '%s' "$best" | sed -n 's/.*→[[:space:]]*\([0-9]*\)\/\([0-9]*\).*/\2/p')" echo "==> Проверяем текущую стратегию${cname:+ ($cname)} на тех же сайтах" urls="$(cat "$TEST_DIR/urls.txt" 2>/dev/null)" r="$(_test_check_all_urls "$urls" "$TEST_DIR/log_cur.txt")" cok="${r% *}"; ctot="${r#* }" echo " текущая: ${cok}/${ctot}, лучшая из теста: $bname — ${bok}/${btot}" if [ "${bok:-0}" -gt "${cok:-0}" ] 2>/dev/null; then if _nochange_strategy; then echo "==> Пока шёл тест, включили «Не изменять стратегию» — ничего не меняем" _za_last_write "$mode" "$bname" "$bok" "$btot" "$cok" "$cname" nochange; return 0 fi echo "==> Применяем $bname" _zc_has && echo " · свой блок #CustomStart … #CustomEnd сохраняем — он останется в самом верху стратегии" _zc_lift case "$bname" in v[1-9]|v10) r="$(strategy_set_v "$bname")" ;; *) _za_extras_save r="$(ZM_NO_RESTART=1 strategy_set_flowseal "$bname")" case "$r" in *'"ok":true'*) _za_extras_restore ;; *) zapret_restart ;; esac ;; esac _zc_drop case "$r" in *'"ok":true'*) applied=1; echo "==> Готово: основная стратегия $bname применена" ;; *) echo "!! Не удалось применить $bname — оставлена текущая" ;; esac if [ "$applied" = 1 ] && _nfq_opt_body | _ts_has; then date +%s > "$ZA_TS" echo " ! в стратегии есть метка ts — на компьютерах с Windows нужно один раз выполнить:" echo " netsh int tcp set global timestamps=enabled (в терминале от имени администратора)" fi logger -t zapret-manager "Автоподбор: применена стратегия $bname ($bok/$btot, было $cok/$ctot)" else echo "==> Текущая стратегия не хуже — оставляем её" fi _za_last_write "$mode" "$bname" "$bok" "$btot" "$cok" "$cname" "$([ "$applied" = 1 ] && echo applied || echo kept)" return 0 } test_auto_set() { local when="${1%%|*}" mode="${1#*|}" yt="" h m case "$mode" in *'|'*) yt="${mode#*|}"; mode="${mode%%|*}" ;; esac case "$mode" in v|flowseal|v_flowseal|youtube) ;; *) mode="$(_za_mode)" ;; esac case "$when" in off) ;; [0-9]:[0-5][0-9]|[01][0-9]:[0-5][0-9]|2[0-3]:[0-5][0-9]) [ -x /etc/init.d/zapret ] || { echo '{"error":"Zapret не установлен"}'; return 1; } h="${when%%:*}"; m="${when#*:}"; h="${h#0}"; m="${m#0}" ;; *) echo '{"error":"неверное время"}'; return 1 ;; esac mkdir -p "$ZM_STATE_DIR"; echo "$mode" > "$ZA_MODE_FILE" case "$yt" in on) : > "$ZA_YT_FILE" ;; off) rm -f "$ZA_YT_FILE" ;; esac mkdir -p "$(dirname "$CRON_FILE")"; touch "$CRON_FILE" sed -i "\\|$ZA_TAG|d" "$CRON_FILE" if [ "$when" != off ]; then echo "${m:-0} ${h:-0} * * * /opt/zapret-manager-luci/backend.sh test_action auto_tick x >/dev/null 2>&1 $ZA_TAG" >> "$CRON_FILE" printf '%02d:%02d\n' "${h:-0}" "${m:-0}" > "$ZA_TIME_FILE" fi /etc/init.d/cron enable >/dev/null 2>&1 /etc/init.d/cron restart >/dev/null 2>&1 test_status } test_auto_tick() { [ -x /etc/init.d/zapret ] && [ -f "$CONF" ] || return 0 pgrep -f "/opt/zapre[t]/" >/dev/null 2>&1 || return 0 [ "$(date +%Y)" -ge 2024 ] 2>/dev/null || return 0 _test_running && return 0 _zm_busy_job >/dev/null 2>&1 && return 0 job_start strategy_test do_autobest >/dev/null } _za_last_json() { local last at m b bo bt co cn res last="$(cat "$1" 2>/dev/null)" [ -n "$last" ] || { echo null; return 0; } IFS='|' read -r at m b bo bt co cn res <<-ZA_EOF $last ZA_EOF printf '{"at":%s,"mode":"%s","best":"%s","best_ok":%s,"total":%s,"cur_ok":%s,"cur":"%s","result":"%s"}' \ "$(printf '%s' "$at" | tr -cd 0-9 | grep . || echo 0)" "$(esc "$m")" "$(esc "$b")" "$(printf '%s' "$bo" | tr -cd 0-9 | grep . || echo 0)" \ "$(printf '%s' "$bt" | tr -cd 0-9 | grep . || echo 0)" "$(printf '%s' "$co" | tr -cd 0-9 | grep . || echo 0)" "$(esc "$cn")" "$(esc "$res")" } _za_json() { local h last lasty h="$(_za_time)" last="$(_za_last_json "$ZA_LAST")" lasty="$(_za_last_json "$ZA_LAST_YT")" local tsat="" tsp="" l tsat="$(cat "$ZA_TS" 2>/dev/null | tr -cd 0-9)" if [ -n "$tsat" ] && [ -f "$CONF" ]; then for l in $(_nfq_opt_body | _ts_parts | tr ' ' '_'); do tsp="$tsp${tsp:+,}\"$(esc "$(printf '%s' "$l" | tr '_' ' ')")\""; done [ -n "$tsp" ] || tsat="" fi printf '{"time":"%s","pref":"%s","mode":"%s","yt":%s,"running":%s,"last":%s,"last_yt":%s,"ts_at":%s,"ts_parts":[%s]}' "$h" "$(cat "$ZA_TIME_FILE" 2>/dev/null || echo 04:00)" "$(_za_mode)" "$(_za_yt && echo true || echo false)" "$(_za_running && echo true || echo false)" "$last" "$lasty" "${tsat:-0}" "$tsp" } _zt_tz() { case "$1" in Europe/Kaliningrad) echo "EET-2" ;; Europe/Moscow) echo "MSK-3" ;; Europe/Samara) echo "<+04>-4" ;; Asia/Yekaterinburg) echo "<+05>-5" ;; Asia/Omsk) echo "<+06>-6" ;; Asia/Krasnoyarsk) echo "<+07>-7" ;; Asia/Irkutsk) echo "<+08>-8" ;; Asia/Yakutsk) echo "<+09>-9" ;; Asia/Vladivostok) echo "<+10>-10" ;; Asia/Magadan) echo "<+11>-11" ;; Asia/Kamchatka) echo "<+12>-12" ;; UTC) echo "UTC" ;; *) return 1 ;; esac } ZT_NTP_STD="0.openwrt.pool.ntp.org 1.openwrt.pool.ntp.org 2.openwrt.pool.ntp.org 3.openwrt.pool.ntp.org" ZT_NTP_IP="89.109.251.21 89.109.251.22 162.159.200.1 162.159.200.123 216.239.35.0 216.239.35.4" _zt_ntp_list() { uci -q get system.ntp.server 2>/dev/null | tr -s ' \t' ' ' | sed 's/^ //; s/ $//'; } _zt_ntp_preset() { local l="$1" [ -n "$l" ] || { echo none; return 0; } [ "$l" = "$ZT_NTP_IP" ] && { echo ip; return 0; } printf '%s\n' $l | grep -qv 'pool\.ntp\.org$' && echo own || echo std } _zt_json() { local l set -- $(date '+%s %z %d.%m.%Y %H:%M:%S') l="$(_zt_ntp_list)" printf '"time":%s,"time_text":"%s","zone":"%s","utc_offset":"%s","ntp":%s,"ntp_preset":"%s","ntp_servers":"%s","dnsm":%s' \ "$1" "$3 $4" "$(esc "$(uci -q get system.@system[0].zonename)")" "$2" \ "$([ "$(uci -q get system.ntp.enabled)" = 0 ] && echo false || echo true)" \ "$(_zt_ntp_preset "$l")" "$(esc "$l")" "$(_dnsm_present && echo true || echo false)" } _zt_posix() { local z="$1" p f p="$(_zt_tz "$z")" && { echo "$p"; return 0; } for f in /usr/share/ucode/luci/zoneinfo.uc /usr/lib/lua/luci/sys/zoneinfo/tzdata.lua; do [ -f "$f" ] || continue p="$(grep -F "'$z'" "$f" 2>/dev/null | head -n 1 | sed -n "s/.*'$(printf '%s' "$z" | sed 's#/#\\/#g')'[^']*'\([^']*\)'.*/\1/p")" [ -n "$p" ] && { echo "$p"; return 0; } done [ -n "$2" ] || return 1 printf '%s' "$2" | awk '{ s = substr($0, 1, 1); h = substr($0, 2, 2) + 0; m = substr($0, 5, 2) + 0; if (s != "+" && s != "-") exit 1 n = sprintf("%s%02d", s, h); if (m) n = n sprintf("%02d", m) printf "<%s>%s%d%s\n", n, (s == "+" ? "-" : ""), h, (m ? ":" m : "") }' } _zt_detect() { local d="$JOBS_DIR/tzdet.$$" i mkdir -p "$d" ( curl -s --noproxy '*' -m 5 'http://ip-api.com/json/?fields=status,timezone,city,offset' > "$d/1" ) 2>/dev/null & ( curl -s --noproxy '*' -m 5 'https://ipwho.is/?fields=timezone,city' > "$d/2" ) 2>/dev/null & ( curl -s --noproxy '*' -m 5 'https://get.geojs.io/v1/ip/geo.json' > "$d/3" ) 2>/dev/null & ( curl -s --noproxy '*' -m 5 'https://ipapi.co/json/' > "$d/4" ) 2>/dev/null & ( curl -s --noproxy '*' -m 5 'https://ipinfo.io/json' > "$d/5" ) 2>/dev/null & wait for i in 1 2 3 4 5; do [ -s "$d/$i" ] || continue tr -d '\n\r' < "$d/$i" | awk -v N="$i" '{ z = ""; c = "" if (match($0, /"timezone"[ \t]*:[ \t]*"[A-Za-z_]+(\/[A-Za-z0-9_+-]+)*"/)) { z = substr($0, RSTART, RLENGTH); sub(/^"timezone"[ \t]*:[ \t]*"/, "", z); sub(/"$/, "", z) } else if (match($0, /"timezone"[ \t]*:[ \t]*\{[^}]*"id"[ \t]*:[ \t]*"[^"]+"/)) { z = substr($0, RSTART, RLENGTH); sub(/.*"id"[ \t]*:[ \t]*"/, "", z); sub(/"$/, "", z) } if (match($0, /"city"[ \t]*:[ \t]*"[^"]*"/)) { c = substr($0, RSTART, RLENGTH); sub(/^"city"[ \t]*:[ \t]*"/, "", c); sub(/"$/, "", c); gsub(/[<>&\\]/, "", c) } if (z ~ /^[A-Za-z_]+(\/[A-Za-z0-9_+-]+)*$/) printf "%s\t%s\n", z, c }' done > "$d/all" ZT_ASKED=5 ZT_GOT="$(wc -l < "$d/all" | tr -d ' ')" set -- $(awk -F'\t' '{ n[$1]++; if (!($1 in o)) { o[$1] = NR; c[$1] = $2 } } END { b = ""; for (z in n) if (b == "" || n[z] > n[b] || (n[z] == n[b] && o[z] < o[b])) b = z; if (b != "") print b, n[b] }' "$d/all") ZT_ZONE="$1"; ZT_VOTES="${2:-0}" ZT_CITY="$(awk -F'\t' -v z="$ZT_ZONE" '$1 == z && $2 != "" { print $2; exit }' "$d/all")" ZT_OFF="$(tr -d '\n' < "$d/1" 2>/dev/null | grep -o '"offset"[[:space:]]*:[[:space:]]*-\{0,1\}[0-9]*' | head -n 1 | sed 's/.*://' | awk '{ s = $1 + 0; g = s < 0 ? "-" : "+"; if (s < 0) s = -s; if ($1 != "") printf "%s%02d:%02d\n", g, int(s / 3600), int((s % 3600) / 60) }')" rm -rf "$d" [ -n "$ZT_ZONE" ] } _zt_sync() { local p d i t n prev="" ZT_SYNC="" if command -v ntpd >/dev/null 2>&1; then ntpd -n -q -p 0.openwrt.pool.ntp.org -p 1.openwrt.pool.ntp.org -p time.cloudflare.com -p ntp1.vniiftri.ru -p 162.159.200.1 -p 89.109.251.21 >/dev/null 2>&1 & p=$! ( sleep 7; kill "$p" 2>/dev/null ) >/dev/null 2>&1 & wait "$p" 2>/dev/null && ZT_SYNC=ntp fi if [ -z "$ZT_SYNC" ]; then for i in https://www.google.com https://ya.ru https://www.cloudflare.com; do d="$(curl -sI --noproxy '*' -m 3 "$i" 2>/dev/null | tr -d '\r' | sed -n 's/^[Dd]ate:[[:space:]]*//p' | head -n 1)" t="$(printf '%s' "$d" | awk '{ split("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec", M, " "); for (k = 1; k <= 12; k++) if (M[k] == $3) m = k; if (m && $4 ~ /^[0-9]{4}$/ && $5 ~ /^[0-9]+:[0-9]+:[0-9]+$/) printf "%s-%02d-%02d %s\n", $4, m, $2, $5 }')" [ -n "$t" ] || continue n="$(date -u -d "$t" +%s 2>/dev/null)" [ -n "$n" ] || continue if [ -n "$prev" ] && [ $((n - prev)) -le 10 ] && [ $((prev - n)) -le 10 ]; then date -u -s "$t" >/dev/null 2>&1 && ZT_SYNC=http break fi prev="$n" done fi [ -n "$ZT_SYNC" ] && command -v hwclock >/dev/null 2>&1 && hwclock -w >/dev/null 2>&1 [ -n "$ZT_SYNC" ] } system_time_set() { local a="$1" tz p case "$a" in ntpsrv:std|ntpsrv:ip) _dnsm_present && { printf '{"error":"Серверы времени настраивает DNS Manager — меняйте их в нём",%s}\n' "$(_zt_json)"; return 1; } if [ "$a" = ntpsrv:ip ]; then tz="$ZT_NTP_IP"; else tz="$ZT_NTP_STD"; fi uci -q get system.ntp >/dev/null 2>&1 || uci -q set system.ntp=timeserver uci -q delete system.ntp.server for p in $tz; do uci -q add_list system.ntp.server="$p"; done uci -q set system.ntp.enabled='1' uci -q commit system /etc/init.d/sysntpd restart >/dev/null 2>&1 _zt_sync printf '{"ok":true,"ntpsrv":"%s","sync":"%s",%s}\n' "${a#ntpsrv:}" "$ZT_SYNC" "$(_zt_json)" return 0 ;; ntp) _zt_sync || { printf '{"error":"время не синхронизировалось — ни NTP, ни сайты не ответили. Проверьте интернет на роутере",%s}\n' "$(_zt_json)"; return 1; } printf '{"ok":true,"sync":"%s",%s}\n' "$ZT_SYNC" "$(_zt_json)" return 0 ;; auto) _zt_detect || { printf '{"error":"не удалось определить часовой пояс — сервисы геолокации не ответили",%s}\n' "$(_zt_json)"; return 1; } tz="$(_zt_posix "$ZT_ZONE" "$ZT_OFF")" || { printf '{"error":"%s",%s}\n' "$(esc "сервисы назвали пояс $ZT_ZONE, но панель не знает его правил — выберите пояс вручную")" "$(_zt_json)"; return 1; } uci -q set system.@system[0].zonename="$ZT_ZONE" uci -q set system.@system[0].timezone="$tz" uci -q commit system /etc/init.d/system reload >/dev/null 2>&1 _zt_sync /etc/init.d/cron restart >/dev/null 2>&1 printf '{"ok":true,"auto":{"zone":"%s","city":"%s","votes":%s,"got":%s,"asked":%s,"sync":"%s"},%s}\n' \ "$(esc "$ZT_ZONE")" "$(esc "$ZT_CITY")" "${ZT_VOTES:-0}" "${ZT_GOT:-0}" "${ZT_ASKED:-0}" "$ZT_SYNC" "$(_zt_json)" return 0 ;; tz:*) tz="$(_zt_tz "${a#tz:}")" || { echo '{"error":"неизвестный часовой пояс"}'; return 1; } uci -q set system.@system[0].zonename="${a#tz:}" uci -q set system.@system[0].timezone="$tz" uci -q commit system /etc/init.d/system reload >/dev/null 2>&1 /etc/init.d/cron restart >/dev/null 2>&1 ;; *) echo '{"error":"неизвестное действие"}'; return 1 ;; esac printf '{"ok":true,%s}\n' "$(_zt_json)" } _zm_gh_get() { local i curl -fsSL --connect-timeout 6 --max-time "${4:-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 "${4:-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 8 || { 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 [ -z "$ZM_VER_FORCE" ] && [ "$(_ver_mode)" != open ]; then latest="$(ZM_VER_LOCAL=1 _zm_cached_first panel _zm_panel_latest)" else latest="$(_zm_cached_first 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_CL_FILE="/opt/zapret-manager-luci/CHANGELOG.md" ZM_CL_PENDING="$ZM_STATE_DIR/cl.pending" ZM_CL_URLS="${GH_RAW}/StressOzz/Zapret-Manager/refs/heads/main/CHANGELOG.md https://cdn.jsdelivr.net/gh/StressOzz/Zapret-Manager@main/CHANGELOG.md" _zm_cl_get() { local out="$1" u for u in $ZM_CL_URLS; do rm -f "$out" _zm_gh_get "$u" "$out" "" 12 && _zm_dl_sane "$out" && [ "$(wc -c < "$out")" -le 262144 ] && return 0 done rm -f "$out" return 1 } _zm_cl_top() { if grep -q '^## ' "$1" 2>/dev/null; then awk '/^## / { if (s) exit; s = 1 } s && !/^---[[:space:]]*$/' "$1" else grep -v '^---[[:space:]]*$' "$1" fi | tr -d '\r' | head -n 80 } _zm_cl_sync() { local t="$ZM_CL_FILE.dl" mkdir -p "$(dirname "$ZM_CL_FILE")" if _zm_cl_get "$t"; then mv -f "$t" "$ZM_CL_FILE"; chmod 644 "$ZM_CL_FILE"; echo '{"ok":true}' else rm -f "$t"; echo '{"ok":false}'; fi } zm_changelog() { local mode="$1" f blk="" ver="" date="" from="" pending=false case "$mode" in local) [ -f "$ZM_CL_PENDING" ] || { printf '{"pending":false,"current":"%s"}\n' "$(esc "$ZM_VERSION")"; return 0; } pending=true from="$(head -n 1 "$ZM_CL_PENDING" | tr -cd '0-9.')" f="$ZM_CL_FILE" ;; remote) f="$JOBS_DIR/changelog.md" if [ ! -s "$f" ] || [ -n "$(find "$f" -mmin +10 2>/dev/null)" ]; then mkdir -p "$JOBS_DIR" if _zm_cl_get "$f.dl"; then mv -f "$f.dl" "$f"; else rm -f "$f.dl"; fi fi [ -s "$f" ] || { echo '{"error":"не удалось скачать список изменений с GitHub"}'; return 1; } ;; all) f="$JOBS_DIR/changelog.md" if [ ! -s "$f" ] || [ -n "$(find "$f" -mmin +10 2>/dev/null)" ]; then mkdir -p "$JOBS_DIR" if _zm_cl_get "$f.dl"; then mv -f "$f.dl" "$f"; else rm -f "$f.dl"; fi fi blk=github [ -s "$f" ] || { f="$ZM_CL_FILE"; blk=local; } [ -s "$f" ] || { echo '{"error":"список изменений не загрузился: нет связи с GitHub, а сохранённой копии на роутере пока нет"}'; return 1; } printf '{"current":"%s","source":"%s","text":"%s"}\n' "$(esc "$ZM_VERSION")" "$blk" \ "$(esc_ml "$(grep -v '^---[[:space:]]*$' "$f" | tr -d '\r' | head -n 600)")" return 0 ;; seen) rm -f "$ZM_CL_PENDING"; echo '{"ok":true}'; return 0 ;; *) echo '{"error":"неизвестный режим"}'; return 1 ;; esac [ -s "$f" ] && blk="$(_zm_cl_top "$f")" ver="$(printf '%s\n' "$blk" | sed -n '/[^[:space:]]/{p;q;}' | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -n 1)" date="$(printf '%s\n' "$blk" | sed -n '/[^[:space:]]/{p;q;}' | grep -oE '[0-9]{2}\.[0-9]{2}\.[0-9]{4}' | head -n 1)" printf '{"pending":%s,"from":"%s","current":"%s","version":"%s","date":"%s","text":"%s"}\n' \ "$pending" "$(esc "$from")" "$(esc "$ZM_VERSION")" "$(esc "$ver")" "$(esc "$date")" "$(esc_ml "$blk")" } _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" ;; term) echo "Терминал" ;; tsc) echo "Tailscale" ;; backup) echo "резервная копия" ;; 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" ;; adblock) echo "блокировка рекламы" ;; mirror_set) echo "смена зеркала" ;; zm_update) 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|fk-guard) continue ;; esac _job_running "$j" && { _zm_job_label "$j"; return 0; } done return 1 } zm_update_action() { local j if j="$(_zm_busy_job)"; then printf '{"error":"%s"}\n' "$(esc "идёт операция «$j» — дождитесь её окончания и обновите панель")" return 1 fi job_start zm_update do_zm_update } do_zm_update() { local tmp="/tmp/zm_update_install.sh" why v echo "==> Скачиваем новую версию панели с GitHub" if ! why="$(_zm_update_fetch "$tmp")"; then rm -f "$tmp" echo "ОШИБКА: $why" return 1 fi v="$(grep -m1 '^# Version:' "$tmp" | sed 's/^# Version:[[:space:]]*//' | tr -d '\r ')" echo " ✓ Установщик скачан и проверен: версия $v" chmod +x "$tmp" rm -f "$ZM_STATE_DIR/latest.panel" echo "==> Запускаем установку — панель на полминуты пропадёт" ( sleep 5; sh "$tmp" >/tmp/zm_update_install.log 2>&1; rm -f "$tmp" ) >/dev/null 2>&1 /dev/null; return 0; fi mkdir -p "$ZM_STATE_DIR" if [ -n "$ZM_VER_FORCE" ]; then v="$("$@")" [ -n "$v" ] && echo "$v" > "$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 } _zm_cached_first() { local f="$ZM_STATE_DIR/latest.$1" [ -n "$ZM_VER_LOCAL" ] && { cat "$f" 2>/dev/null; return 0; } if [ -s "$f" ] || { [ -f "$f.try" ] && [ -z "$(find "$f.try" -mmin +10 2>/dev/null)" ]; }; then _zm_cached "$@" else mkdir -p "$ZM_STATE_DIR"; : > "$f.try" ZM_VER_FORCE=1 _zm_cached "$@" fi } _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); fpu="${fpu:-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) _mx_has_sub && 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,"fk_name":"%s","bytetube":%s,"exclude":"%s","mt_all":%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_installed && echo true || echo false)" "$(_fk_name)" "$(_bt_installed && echo true || echo false)" "$(esc "$(cat "$MIXOMO_EXCL" 2>/dev/null)")" \ "$(_mt_all_on && echo true || echo false)" } _bt_installed() { [ -x /usr/bin/bytetube ] || [ -x /etc/init.d/bytetube ]; } _mx_installed() { [ -x "$MIHOMO_BIN" ] || [ -x /etc/init.d/mihomo ]; } MT_ALL_GID="2a11c0de" MT_ALL_RID="2a11c0d1" _mt_all_on() { [ -f "$MAGITRICKLE_CONF" ] && grep -qE "^- id: [\"']?${MT_ALL_GID}[\"']?[[:space:]]*\$" "$MAGITRICKLE_CONF"; } _mt_all_strip() { awk -v ID="$MT_ALL_GID" ' /^- / { v = $0; sub(/^- id:[ \t]*/, "", v); gsub(/["\047 \t\r]/, "", v); skip = ($0 ~ /^- id:/ && v == ID) } /^[^ -]/ { skip = 0 } skip { next } pend { print ($0 ~ /^- / ? "groups:" : "groups: []"); pend = 0 } /^groups:[ \t]*$/ { pend = 1; next } { print } END { if (pend) print "groups: []" }' "$1" } _mt_all_add() { local ifc ifc="$(awk '/^groups:/ { g = 1; next } /^[^ -]/ { g = 0 } g && /^ interface:/ { sub(/^ interface:[ \t]*/, ""); gsub(/["\047\r]/, ""); print; exit }' "$1")" printf '%s' "$ifc" | grep -qE '^[A-Za-z0-9_.-]+$' || ifc=Mihomo awk -v ID="$MT_ALL_GID" -v RID="$MT_ALL_RID" -v IF="$ifc" ' function blk() { print "- id: " ID; print " name: Весь трафик через Mixomo"; print " color: \047#f97316\047"; print " interface: " IF; print " enable: true" print " rules:"; print " - id: " RID; print " name: \"\""; print " type: subnet"; print " rule: 0.0.0.0/0"; print " enable: true" } !done && /^groups:[ \t]*\[\][ \t]*$/ { print "groups:"; blk(); done = 1; next } !done && /^groups:[ \t]*$/ { print; blk(); done = 1; next } { print } END { if (!done) { print "groups:"; blk() } }' "$1" } _mt_all_set() { local want="$1" tmp="$MAGITRICKLE_CONF.zmall" bak="$MAGITRICKLE_CONF.zmall-bak" run=0 [ -x /etc/init.d/magitrickle ] || { echo '{"error":"MagiTrickle не установлен"}'; return 1; } [ -s "$MAGITRICKLE_CONF" ] && grep -q '^app:' "$MAGITRICKLE_CONF" || { echo '{"error":"конфигурация MagiTrickle не найдена — сначала выберите готовый список"}'; return 1; } if [ "$want" = on ] && _mt_all_on; then printf '{"ok":true,"mt_all":true}\n'; return 0; fi if [ "$want" = off ] && ! _mt_all_on; then printf '{"ok":true,"mt_all":false}\n'; return 0; fi pidof magitrickled >/dev/null 2>&1 && run=1 cp -f "$MAGITRICKLE_CONF" "$bak" _mt_all_strip "$bak" > "$tmp.s" if [ "$want" = on ]; then _mt_all_add "$tmp.s" > "$tmp"; else cat "$tmp.s" > "$tmp"; fi rm -f "$tmp.s" if [ ! -s "$tmp" ] || ! grep -q '^app:' "$tmp" || { [ "$want" = on ] && ! grep -q "^- id: $MT_ALL_GID\$" "$tmp"; }; then rm -f "$tmp" "$bak"; echo '{"error":"не удалось изменить конфигурацию MagiTrickle — она не тронута"}'; return 1 fi [ "$run" = 1 ] && /etc/init.d/magitrickle stop >/dev/null 2>&1 mv -f "$tmp" "$MAGITRICKLE_CONF" if [ "$run" = 1 ]; then /etc/init.d/magitrickle start >/dev/null 2>&1 if ! _mt_wait_running; then cp -f "$bak" "$MAGITRICKLE_CONF"; rm -f "$bak" /etc/init.d/magitrickle start >/dev/null 2>&1 echo '{"error":"MagiTrickle не запустился с новым правилом — вернули прежнюю конфигурацию"}'; return 1 fi fi rm -f "$bak" logger -t zapret-manager "Mixomo: весь трафик через туннель — $([ "$want" = on ] && echo включено || echo выключено)" printf '{"ok":true,"mt_all":%s,"running":%s}\n' "$(_mt_all_on && echo true || echo false)" "$([ "$run" = 1 ] && echo true || echo false)" } _mt_apply_list() { local new="$MAGITRICKLE_CONF.new" out="$MAGITRICKLE_CONF.zmtmp" keep="" _mt_all_on && keep=1 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" if [ -n "$keep" ] && ! _mt_all_on; then _mt_all_add "$MAGITRICKLE_CONF" > "$out" && grep -q '^groups:' "$out" && mv -f "$out" "$MAGITRICKLE_CONF" rm -f "$out" fi return 0 } _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_installed; then echo "ОШИБКА: стоит $(_fk_name) — он сам направляет сервисы в туннель. Чтобы поставить Mixomo, удалите $(_fk_name)" 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 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)"; rm -f "$tmp"; return 1; } gunzip -c "$tmp" >/dev/null 2>&1 || { echo "ОШИБКА: архив $file скачался повреждённым — прежнее ядро Mihomo не тронуто"; rm -f "$tmp"; return 1; } [ -f /etc/init.d/mihomo ] && /etc/init.d/mihomo stop >/dev/null 2>&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 -X show firewall 2>/dev/null | grep -E "\.name='Mihomo'" | sed "s/\.name.*//" | sort -u); do uci -q delete "$fw_section" done for fw_section in $(uci -X show firewall 2>/dev/null | grep -E "\.(src|dest)='Mihomo'" | sed -E "s/\.(src|dest).*//" | sort -u); 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 rc=0 echo "==> Останавливаем Mihomo" _zm_svc_off mihomo killall mihomo >/dev/null 2>&1 echo "==> Удаляем Mihomo, его настройки, подписку и веб-панель" _zm_wipe /etc/init.d/mihomo /etc/rc.d/*mihomo "$MIHOMO_BIN" || rc=1 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 _zm_wipe "$MIHOMO_DIR" /tmp/mihomo* && echo " ✓ Файлы Mihomo удалены, остатков нет" || rc=1 echo "==> Убираем hev-socks5-tunnel" _zm_svc_off hev-socks5-tunnel if [ -x /etc/init.d/bytetube ]; then echo " · hev-socks5-tunnel нужен ByeTube — пакет оставляем, убираем только настройки Mixomo" uci -q delete hev-socks5-tunnel.@instance[0] uci commit hev-socks5-tunnel 2>/dev/null else _zm_pkg_purge hev-socks5-tunnel || rc=1 _zm_wipe /etc/config/hev-socks5-tunnel /etc/config/hev-socks5-tunnel-opkg /etc/init.d/hev-socks5-tunnel /etc/rc.d/*hev-socks5-tunnel /usr/bin/hev-socks5-tunnel || rc=1 fi _zm_wipe /etc/hev-socks5-tunnel || rc=1 echo "==> Удаляем интерфейс Mihomo и зону файрвола" uci -q delete network.Mihomo for fw_section in $(uci -X show firewall 2>/dev/null | grep -E "\.name='Mihomo'" | sed "s/\.name.*//" | sort -u); do uci -q delete "$fw_section" done for fw_section in $(uci -X show firewall 2>/dev/null | grep -E "\.(src|dest)='Mihomo'" | sed -E "s/\.(src|dest).*//" | sort -u); 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 _zm_pkg_purge magitrickle || rc=1 _zm_wipe /etc/magitrickle /etc/init.d/magitrickle /etc/rc.d/*magitrickle /etc/config/magitrickle /var/run/magitrickle* || rc=1 _zm_cron_drop 'mihomo|magitrickle' "Mixomo" _zm_rm_listed mixomo || rc=1 /etc/init.d/dnsmasq restart >/dev/null 2>&1 _zm_after_remove "Mixomo" _zm_rm_done "$rc" "Mixomo" } mixomo_action() { local action="$1" case "$action" in install|update) if [ ! -x "$MIHOMO_BIN" ] && _fk_installed; then printf '{"error":"Стоит %s — он сам направляет сервисы в туннель. Чтобы поставить Mixomo, удалите %s."}\n' "$(_fk_name)" "$(_fk_name)" 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 cmp -s "$MIHOMO_CONF" "$MIHOMO_CONF.bak" 2>/dev/null || rm -f "$MIXOMO_SUB_PREV" 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_SUB_PREV="$MIHOMO_DIR/.zm_sub_prev" _mx_has_sub() { [ -f "$MIHOMO_CONF" ] || return 1 awk '{ t = $0; sub(/\r$/, "", t) } t ~ /^[^ \t#]/ { s = (t ~ /^proxy-providers:[ \t]*(#.*)?$/) } s && t ~ /^[ \t]+url:[ \t]*["\047]?https?:/ { f = 1; exit } END { exit !f }' "$MIHOMO_CONF" } _mx_sub_strip() { awk ' function ind(x) { match(x, /^ */); return RLENGTH } function flush( i, sp) { if (!nb) return for (i = 1; i <= nb; i++) print buf[i] if (!hasp && kind >= 0) { sp = ""; for (i = 0; i < kind; i++) sp = sp " "; print sp "proxies:"; print sp " - DIRECT" } nb = 0; hasp = 0; kind = -1 } BEGIN { kind = -1; useind = -1; iind = -1 } { raw = $0; t = raw; sub(/\r$/, "", t) blank = (t ~ /^[ \t]*(#.*)?$/) if (!blank && t ~ /^[^ \t]/) { flush() top = t; sub(/:.*/, "", top) inpp = (top == "proxy-providers"); ingrp = (top == "proxy-groups"); useind = -1; iind = -1 if (inpp) next print raw; next } if (inpp) next if (!ingrp) { print raw; next } if (blank) { if (nb) buf[++nb] = raw; else print raw; next } i = ind(t) if (useind >= 0) { if (i > useind || (i == useind && t ~ /^ *- /)) next; useind = -1 } if (t ~ /^ *- /) { if (iind < 0) iind = i if (i == iind) { flush(); k = t; sub(/^ *- */, "", k); kind = length(t) - length(k) if (k ~ /^proxies:/) hasp = 1 buf[++nb] = raw; next } } if (nb) { k = t; sub(/^ */, "", k) if (i == kind && k ~ /^use:/) { if (k ~ /^use:[ \t]*(#.*)?$/) useind = i; next } if (i == kind && k ~ /^include-all-providers:/) next if (i == kind && k ~ /^(proxies|include-all|include-all-proxies):/) hasp = 1 buf[++nb] = raw; next } print raw } END { flush() }' "$1" } mixomo_subscription_clear() { local tmp="$MIHOMO_CONF.zm-clear" was="" how [ -x /etc/init.d/mihomo ] || { echo '{"error":"Mixomo не установлен"}'; return 1; } [ -f "$MIHOMO_CONF" ] || { echo '{"error":"конфигурация Mihomo не найдена"}'; return 1; } if [ -s "$MIXOMO_SUB_PREV" ]; then cp -f "$MIXOMO_SUB_PREV" "$tmp" how=restored else _mx_has_sub || grep -q '^proxy-providers:' "$MIHOMO_CONF" || { echo '{"error":"подписки в конфигурации нет — очищать нечего"}'; return 1; } _mx_sub_strip "$MIHOMO_CONF" > "$tmp" && [ -s "$tmp" ] || { rm -f "$tmp"; echo '{"error":"не получилось разобрать конфигурацию — уберите подписку вручную в редакторе конфигурации"}'; return 1; } how=removed fi if [ -x "$MIHOMO_BIN" ] && ! "$MIHOMO_BIN" -t -d "$MIHOMO_DIR" -f "$tmp" >/dev/null 2>&1; then rm -f "$tmp" echo '{"error":"без подписки конфигурация не проходит проверку Mihomo — ничего не меняли. Уберите подписку вручную в редакторе конфигурации"}' return 1 fi pidof mihomo >/dev/null 2>&1 && was=1 cp -f "$MIHOMO_CONF" "$MIHOMO_CONF.bak" 2>/dev/null mv -f "$tmp" "$MIHOMO_CONF" chmod 600 "$MIHOMO_CONF" 2>/dev/null rm -rf "$MIHOMO_DIR/proxy-providers" rm -f "$MIHOMO_DIR/.zm_names" "$MIHOMO_DIR/.zm_names_all" "$MIHOMO_DIR/.zm_filter_user" "$MIHOMO_DIR/.zm_filter_line" "$MIXOMO_EXCL" "$MIXOMO_HIDE" "$MIXOMO_SUB_PREV" /etc/init.d/mihomo restart >/dev/null 2>&1 sleep 2 if [ -n "$was" ] && ! pidof mihomo >/dev/null 2>&1; then cp -f "$MIHOMO_CONF.bak" "$MIHOMO_CONF" 2>/dev/null /etc/init.d/mihomo restart >/dev/null 2>&1 echo '{"error":"Mihomo не запустился без подписки — вернули конфигурацию с подпиской"}' return 1 fi printf '{"ok":true,"mode":"%s","warp":%s}\n' "$how" "$(grep -q 'type:[[:space:]]*wireguard' "$MIHOMO_CONF" 2>/dev/null && echo true || echo false)" } mixomo_subscription_set() { local sub_url="$1" [ "$sub_url" = clear ] && { mixomo_subscription_clear; return; } 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 [ -s "$MIHOMO_CONF" ] && cp -f "$MIHOMO_CONF" "$MIHOMO_CONF.bak" 2>/dev/null rm -f "$MIXOMO_SUB_PREV" [ -s "$MIHOMO_CONF" ] && ! _mx_has_sub && cp -f "$MIHOMO_CONF" "$MIXOMO_SUB_PREV" 2>/dev/null printf '%s\n' \ 'mixed-port: 7890' 'allow-lan: false' 'tcp-concurrent: true' 'mode: rule' \ 'log-level: error' '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" ;; all_on|all_off) _mt_all_set "${id#all_}"; return ;; *) 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" line="" case "$mode" in off) ;; every) case "$value" in 2|4|6|8|10|12|14|16|18|20|22) line="0 */$value * * * $MIXOMO_CRON_CMD" ;; *) echo '{"error":"допустимы только чётные значения от 2 до 22"}'; return 1 ;; esac ;; daily) case "$value" in [0-9]|0[0-9]|1[0-9]|2[0-3]) line="0 $value * * * $MIXOMO_CRON_CMD" ;; ''|*[!0-9]*) echo '{"error":"введите число от 0 до 23"}'; return 1 ;; *) echo '{"error":"допустимый диапазон 0-23"}'; return 1 ;; esac ;; *) echo '{"error":"неизвестный режим"}'; return 1 ;; esac mkdir -p "$(dirname "$CRON_FILE")" sed -i "\\|$MIXOMO_CRON_CMD|d" "$CRON_FILE" 2>/dev/null [ -n "$line" ] && echo "$line" >> "$CRON_FILE" /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; } case "$which" in zashboard|metacubexd) ;; *) echo "ОШИБКА: неизвестная панель"; return 1 ;; esac 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; } [ -d /tmp/zashboard/dist ] || { echo "ОШИБКА: в архиве нет файлов панели — прежняя панель не тронута"; rm -rf "$tmp" /tmp/zashboard; return 1; } rm -rf "$MIHOMO_DIR/ui"; mkdir -p "$MIHOMO_DIR/ui" 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; } rm -rf "$MIHOMO_DIR/ui"; mkdir -p "$MIHOMO_DIR/ui" 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="" AWG_SIP_I1="" AWG_SIP_I2="" 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" grep -qiE '^[[:space:]]*(I2|RandomTrailers|DisableCookies)[[:space:]]*=' "$MIXOMO_WARP_CONF" && echo " · I2, RandomTrailers и DisableCookies в Mihomo не переносим — с ними WARP в Mihomo не запускается. WARP.conf не меняется" 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!="" || hx){ 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 if("i3" in ext) print " i3: " yaml_quote(ext["i3"]) >> OUT if("i4" in ext) print " i4: " yaml_quote(ext["i4"]) >> OUT if("i5" in ext) print " i5: " yaml_quote(ext["i5"]) >> OUT if("s3" in ext) print " s3: " ext["s3"] >> OUT if("s4" in ext) print " s4: " ext["s4"] >> 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" rm -f "$MIXOMO_SUB_PREV" /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="" if [ -f /etc/init.d/zapret ]; then zr=2; pgrep -f "/opt/zapre[t]/" >/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 elif [ -d /usr/lib/opkg/info ]; then { [ -f /usr/lib/opkg/info/https-dns-proxy.control ] || [ -f /usr/lib/opkg/info/https-dns-proxy.list ]; } && doh=2 else _pkg_is_installed 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 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 _st_vpn_dev >/dev/null && { _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="" sf=false if [ "$sr" = 1 ] && stl_state "$(_st_exit)" && [ "$STL_FAILED" = true ]; then sr=2; sf=true; fi if _st_installed; then sx="$(_st_exit)"; [ "$sx" = warp ] && _st_warp_own && sx=own; [ "$sx" = own ] && _st_warp_ext && sx="iface:$(_st_ext_if)"; 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 ] && [ "$(_zm_v6_cached)" = live ] && v6=true local d_zr="$zr" d_zr2="$zr2" d_bt="$bt" d_tg="$tg" d_mx="$mx" d_doh="$doh" d_sr="$sr" d_fk d_awg d_term=0 awgj fkh [ "$zr" = 2 ] && d_zr=4 [ "$zr2" = 2 ] && d_zr2=4 if [ "$bt" = 2 ]; then case "$(/usr/bin/bytetube status 2>/dev/null)" in *'"enabled":true'*) ;; *) d_bt=4 ;; esac; fi [ "$tg" = 2 ] && ! ls /etc/rc.d/S*tg-ws-proxy* >/dev/null 2>&1 && ! ls /etc/rc.d/S*tgws >/dev/null 2>&1 && d_tg=4 [ "$mx" = 2 ] && ! ls /etc/rc.d/S*mihomo >/dev/null 2>&1 && d_mx=4 if [ "$doh" = 2 ]; then ls /etc/rc.d/S*https-dns-proxy >/dev/null 2>&1 || d_doh=4; fi [ "$sr" = 5 ] && d_sr=4 fkh="$(_fk_health)"; d_fk="$fkh"; [ "$d_fk" = 5 ] && d_fk=4 awgj="$(_awg_health)"; d_awg="${awgj%%,*}" case "$awgj" in *'"awg_total":0') [ "$d_awg" = 1 ] && d_awg=4; [ "$d_awg" = 3 ] && d_awg=2 ;; esac if _term_installed; then if _term_running; then d_term=1; elif _term_enabled; then d_term=2; else d_term=4; fi fi local d_ab=0 tss=0 if _ab_on; then if _ab_applied; then d_ab=1; else d_ab=2; fi; fi if _tsc_installed; then if _tsc_running; then if [ "$(cat "$TSC_STATE" 2>/dev/null)" = Running ]; then tss=1; else tss=3; fi elif _tsc_enabled; then tss=2; else tss=5; fi fi printf '{"dots":{"zapret":%s,"zapret2":%s,"bytetube":%s,"tg":%s,"mixomo":%s,"doh":%s,"hosts":%s,"adblock":%s,"steer":%s,"forkop":%s,"awg":%s,"term":%s,"ts":%s},' \ "$d_zr" "$d_zr2" "$d_bt" "$d_tg" "$d_mx" "$d_doh" "$hs" "$d_ab" "$d_sr" "${d_fk:-0}" "${d_awg:-0}" "$d_term" "$(_tsc_dot)" printf '"zapret":%s,"zapret2":%s,"bytetube":%s,"tg":%s,"mixomo":%s,"doh":%s,"hosts":%s,"steer":%s,"steer_off":%s,"steer_fail":%s,"steer_exit":"%s","awg":%s,"forkop":%s,"flow_warn":%s,"ipv6_warn":%s,"reboot_hint":"%s","nav_hidden":"%s","tsc":%s,"htop":%s}\n' \ "$zr" "$zr2" "$bt" "$tg" "$mx" "$doh" "$hs" "$sr" \ "$([ -f /etc/zm-steer/stopped ] && echo true || echo false)" "$sf" "$sx" "$awgj" "$fkh" "$fw" "$v6" "$(esc "$(_zm_reboot_hint_get)")" "$(esc "$(ui_nav_get)")" "$tss" "$([ -x /usr/bin/htop ] && echo true || echo false)" } 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_old() { [ -s "$ZM_VER_OLD" ] && ZM_N="$1" awk -F'\t' '$1 == ENVIRON["ZM_N"] { print $2; exit }' "$ZM_VER_OLD"; } _ver_item() { local cur latest cur="$(_ver_norm "$2")"; latest="$(_ver_norm "$3")" [ -n "$cur" ] || return 0 [ -n "$latest" ] || latest="$(_ver_norm "$(_ver_old "$1")")" [ -n "$latest" ] && ! _st_ver_lt "$(echo "$cur" | tr - .)" "$(echo "$latest" | tr - .)" && latest="$cur" printf '{"name":"%s","installed":"%s","latest":"%s"}' "$(esc "$1")" "$(esc "$cur")" "$(esc "$latest")" } _jf() { jsonfilter -s "$1" -e "$2" 2>/dev/null; } _vl() { [ -n "$ZM_VER_LOCAL" ] && return 0; "$@"; } do_versions_refresh() { local out="" sep="" j tmp="$VERSIONS_CACHE.tmp" ts at via ZM_VER_OLD="$JOBS_DIR/versions.old" sed 's/},{/}\n{/g' "$VERSIONS_CACHE" 2>/dev/null | sed -n 's/.*"name":"\([^"]*\)","installed":"[^"]*","latest":"\([^"]*\)".*/\1\t\2/p' > "$ZM_VER_OLD" 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 [ -f /etc/init.d/zapret ] && add "$(_ver_item Zapret "$ZAPRET_VER" "$ZAPRET_VER")" 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')")" { [ -x "$MIHOMO_BIN" ] || [ -x /etc/init.d/magitrickle ]; } && add "$(_ver_item hev-socks5-tunnel "$(_jf "$j" '@.hev_version')" "$(_vl _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 stl_present && add "$(_ver_item 'Ядро steer' "$(stl_version)" "$(_vl stl_latest)")" _fk_ours && add "$(_ver_item Forkozz "$(_fk_version)" "$(_vl _fk_latest)")" local v [ -n "$ZM_VER_LOCAL" ] || [ -n "$(_ver_feed_latest busybox)" ] || [ -n "$(_zm_busy_job)" ] || { $UPDATE >/dev/null 2>&1; } if _fk_ours && v="$(_fk_sb_var)" && [ -n "$v" ]; then case "$v" in extended) add "$(_ver_item 'sing-box · Расширенный' "$(_fk_sb_ver)" "$(_vl _fk_sbx_latest)")" ;; lite) add "$(_ver_item 'sing-box · Расширенный облегчённый' "$(_fk_sb_ver)" "$(_vl _fk_sbl_latest)")" ;; manual) add "$(_ver_item 'sing-box · установлен вручную' "$(_fk_sb_ver)" "")" ;; tiny) add "$(_ver_item 'sing-box · Облегчённый' "$(_fk_sb_ver)" "$(_vl _ver_feed_latest "$(_fk_sb_pkg)")")" ;; *) add "$(_ver_item 'sing-box · Обычный' "$(_fk_sb_ver)" "$(_vl _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" "$(_vl _ver_feed_latest zapret2)")" fi if [ -x /usr/bin/ciadpi ] || [ -x /usr/bin/byedpi ]; then add "$(_ver_item ByeDPI "$(_awg_pkg_ver byedpi)" "$(_vl _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)" "$(_vl _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)" "$(_vl _ver_feed_latest https-dns-proxy)")" fi if [ -n "$ZM_VER_LOCAL" ]; then ts="$(sed -n 's/^{"ts":"\([^"]*\)".*/\1/p' "$VERSIONS_CACHE" 2>/dev/null)" at="$(sed -n 's/.*"at":\([0-9]*\).*/\1/p' "$VERSIONS_CACHE" 2>/dev/null)" via="$(sed -n 's/.*"via":"\([a-z]*\)".*/\1/p' "$VERSIONS_CACHE" 2>/dev/null)" else ts="$(date '+%d.%m %H:%M')"; at="$(date +%s)"; via="${ZM_VER_VIA:-button}" fi rm -f "$ZM_VER_OLD" mkdir -p "$ZM_STATE_DIR" printf '{"ts":"%s","at":%s,"via":"%s","items":[%s]}\n' "$ts" "${at:-0}" "$via" "$out" > "$tmp" && mv "$tmp" "$VERSIONS_CACHE" if [ -n "$ZM_VER_LOCAL" ]; then echo "==> Установленные версии записаны"; else echo "==> Версии проверены"; fi } _ver_changed() { [ -s "$VERSIONS_CACHE" ] || return 0 [ /opt/zapret-manager-luci/backend.sh -nt "$VERSIONS_CACHE" ] && return 0 [ /usr/lib/opkg/status -nt "$VERSIONS_CACHE" ] && return 0 [ /lib/apk/db/installed -nt "$VERSIONS_CACHE" ] && return 0 [ -n "$(find "$JOBS_DIR" -maxdepth 1 -name '*.log' ! -name 'versions.log' -newer "$VERSIONS_CACHE" 2>/dev/null | head -n 1)" ] } VER_MODE_FILE="$ZM_STATE_DIR/ver.mode" VER_CRON_TAG="# zm-versions" _ver_mode() { case "$(cat "$VER_MODE_FILE" 2>/dev/null)" in open) echo open ;; button) echo button ;; *) echo day ;; esac; } _ver_minute() { local m m="$(cat "$ZM_STATE_DIR/ver.minute" 2>/dev/null)" case "$m" in [0-9]|[1-5][0-9]) ;; *) m="$(awk 'BEGIN { srand(); print int(rand() * 60) }')" case "$m" in [0-9]|[1-5][0-9]) ;; *) m=17 ;; esac mkdir -p "$ZM_STATE_DIR"; echo "$m" > "$ZM_STATE_DIR/ver.minute" ;; esac echo "$m" } _ver_bg() { [ ! -f "$ZM_STATE_DIR/watch.off" ]; } _ver_cron_sync() { local line mkdir -p "$(dirname "$CRON_FILE")"; [ -f "$CRON_FILE" ] || : > "$CRON_FILE" if [ "$(_ver_mode)" = day ] && _ver_bg; then line="$(_ver_minute) 4 * * * /opt/zapret-manager-luci/backend.sh versions_cron >/dev/null 2>&1 $VER_CRON_TAG" grep -qxF "$line" "$CRON_FILE" 2>/dev/null && return 0 grep -vF "$VER_CRON_TAG" "$CRON_FILE" > "$CRON_FILE.zm" 2>/dev/null; cat "$CRON_FILE.zm" > "$CRON_FILE"; rm -f "$CRON_FILE.zm" echo "$line" >> "$CRON_FILE" else grep -qF "$VER_CRON_TAG" "$CRON_FILE" 2>/dev/null || return 0 grep -vF "$VER_CRON_TAG" "$CRON_FILE" > "$CRON_FILE.zm" 2>/dev/null; cat "$CRON_FILE.zm" > "$CRON_FILE"; rm -f "$CRON_FILE.zm" fi [ -n "$ZM_NO_CRON_RESTART" ] || /etc/init.d/cron restart >/dev/null 2>&1 return 0 } _ver_older() { local at at="$(sed -n 's/.*"at":\([0-9]*\).*/\1/p' "$VERSIONS_CACHE" 2>/dev/null)" case "$at" in ''|0|*[!0-9]*) return 0 ;; esac [ $(( $(date +%s) - at )) -ge $(( $1 * 60 )) ] } versions_cron() { { [ "$(_ver_mode)" = day ] && _ver_bg; } || { _ver_cron_sync; return 0; } _job_running versions && return 0 ZM_VER_FORCE=1 ZM_VER_VIA=day job_start versions do_versions_refresh >/dev/null return 0 } versions_mode_set() { case "$1" in day|open|button) ;; *) echo '{"error":"неизвестный способ проверки"}'; return 1 ;; esac mkdir -p "$ZM_STATE_DIR" if [ "$1" = day ]; then rm -f "$VER_MODE_FILE"; else echo "$1" > "$VER_MODE_FILE"; fi _ver_cron_sync versions_status '' } versions_status() { local busy=false mode mode="$(_ver_mode)" if [ "$1" = refresh ]; then _job_running versions || ZM_VER_FORCE=1 ZM_VER_VIA=button job_start versions do_versions_refresh >/dev/null elif [ "$1" = auto ] && [ "$mode" = open ] && _ver_older 10; then _job_running versions || ZM_VER_FORCE=1 ZM_VER_VIA=open job_start versions do_versions_refresh >/dev/null elif [ "$1" = auto ] && [ "$mode" = day ] && _ver_older "$(_ver_bg && echo 1560 || echo 1440)"; then _job_running versions || ZM_VER_FORCE=1 ZM_VER_VIA=day job_start versions do_versions_refresh >/dev/null elif [ "$1" = auto ] && _ver_changed; then _job_running versions || ZM_VER_LOCAL=1 job_start versions do_versions_refresh >/dev/null fi _job_running versions && busy=true if [ -s "$VERSIONS_CACHE" ]; then sed "s/}\$/,\"pending\":$busy,\"mode\":\"$mode\",\"minute\":$(_ver_minute),\"bg\":$(_ver_bg && echo true || echo false)}/" "$VERSIONS_CACHE" else printf '{"ts":"","items":[],"pending":%s,"mode":"%s","minute":%s,"bg":%s}\n' "$busy" "$mode" "$(_ver_minute)" "$(_ver_bg && echo true || echo false)" 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: error" } > "$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 if [ "$ipv6" = 1 ] && ! v6_kernel_ok; then log "IPv6 на роутере выключен (disable_ipv6) — работаю только по IPv4" ipv6=0 fi 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 v_v6ok=0; v6_kernel_ok && v_v6ok=1 v_v6on=0; [ "$ipv6" = 1 ] && [ "$v_v6ok" = 1 ] && v_v6on=1 # hev включён, но не работает — последняя его ошибка из журнала, чтобы панель показала причину v_hevlog="" [ "$enabled" = 1 ] && [ "$v_hev" = 0 ] && v_hevlog=$(logread 2>/dev/null | grep 'hev-socks5-tunnel' | grep '\[E\]' | tail -n 1 \ | sed 's/.*\(\[E\]\)/\1/' | tr -d '"\\\000-\037' | cut -c1-160) 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,"ipv6_ok":%s,"ipv6_on":%s,"hev_log":"%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")" "$(b $v_v6ok)" "$(b $v_v6on)" "$v_hevlog" "$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' } # IPv6 для ytb0 возможен, только если он есть в ядре и не выключен через sysctl: # all — на всех интерфейсах, default — на новых (ytb0 создаётся новым). Файл /proc/net/if_inet6 есть и при выключенном IPv6. v6_kernel_ok() { local p="${YTB_PROC:-/proc}" [ -f "$p/net/if_inet6" ] || return 1 [ "$(cat "$p/sys/net/ipv6/conf/all/disable_ipv6" 2>/dev/null)" = 1 ] && return 1 [ "$(cat "$p/sys/net/ipv6/conf/default/disable_ipv6" 2>/dev/null)" = 1 ] && return 1 return 0 } 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 v6_kernel_ok || 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 v6_kernel_ok || 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 } # После обновления панели: переписать скрипты уже установленного ByeTube (настройки /etc/config/bytetube не трогаются) # и перезапустить его, если скрипты изменились и он включён bytetube_sync() { local f a b [ -x /usr/bin/bytetube ] && [ -x /etc/init.d/bytetube ] || { echo '{"installed":false}'; return 0; } f="/etc/init.d/bytetube /usr/bin/bytetube /usr/libexec/bytetube/common.sh /usr/libexec/bytetube/net.sh /usr/libexec/bytetube/route-up.sh" a="$(cat $f 2>/dev/null | md5sum)" _bytetube_install_payload >/dev/null 2>&1 b="$(cat $f 2>/dev/null | md5sum)" [ "$a" = "$b" ] && { echo '{"installed":true,"changed":false}'; return 0; } if [ "$(uci -q get bytetube.main.enabled)" = 1 ]; then /etc/init.d/bytetube restart >/dev/null 2>&1 echo '{"installed":true,"changed":true,"restarted":true}' else echo '{"installed":true,"changed":true,"restarted":false}' fi } 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" local rc=0 _zm_wipe /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 || rc=1 _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 [ "$ZM_NO_AFTER" = 1 ] && return "$rc" _zm_after_remove "ByeTube" _zm_rm_done "$rc" "ByeTube" } do_bytetube_purge() { local rc=0 ZM_NO_AFTER=1 do_bytetube_uninstall || rc=1 echo "==> Удаляем пакет byedpi" _zm_svc_off byedpi killall ciadpi >/dev/null 2>&1 _zm_pkg_purge byedpi || rc=1 if _mx_installed; then echo "==> hev-socks5-tunnel используется Mixomo — пакет оставляем" else echo "==> Удаляем пакет hev-socks5-tunnel" _zm_svc_off hev-socks5-tunnel _zm_pkg_purge hev-socks5-tunnel || rc=1 fi _zm_rm_listed bytetube && echo " ✓ Файлы ByeTube, byedpi$(_mx_installed || echo " и hev-socks5-tunnel") удалены" || rc=1 _zm_after_remove "ByeTube" if [ "$rc" = 0 ]; then echo "==> Готово: ByeTube удалён полностью, остатков нет"; _zm_rb_note; return 0; fi ZM_RB_NOTE="" echo "ОШИБКА: ByeTube удалён не полностью — что осталось, написано выше. Перезагрузите роутер и нажмите «Удалить» ещё раз" return 1 } 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_pkg() { _pkg_is_installed https-dns-proxy; } _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 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 } _dnsm_present() { [ -x /usr/bin/dns-manager ] || [ -x /usr/libexec/rpcd/dns_manager ]; } _dnsm_guard() { _dnsm_present || return 0 echo '{"error":"DNS на роутере настраивает DNS Manager — меняйте настройки в нём, панель их не трогает"}' return 1 } doh_force_set() { _dnsm_guard || return 1 case "$1" in auto|off) ;; on) set -- auto ;; *) echo '{"error":"неизвестный режим"}'; return 1 ;; esac if [ ! -f "$_doh_file" ] || [ ! -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_installed && { echo "ОШИБКА: стоит $(_fk_name) — он сам шифрует DNS. Удалите $(_fk_name), чтобы поставить 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 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 _zo_doh_follow echo "==> Готово, DNS over HTTPS установлен — выберите сервер ниже" } do_doh_remove() { local r rc=0 echo "==> Останавливаем DNS over HTTPS — dnsmasq возвращается к прежним серверам" _zm_svc_off https-dns-proxy killall https-dns-proxy >/dev/null 2>&1 echo "==> Удаляем пакеты" _zm_pkg_wipe luci-i18n-https-dns-proxy-ru luci-app-https-dns-proxy https-dns-proxy || rc=1 echo "==> Удаляем настройки и правила перехвата DNS" _zm_wipe /etc/config/https-dns-proxy /etc/config/https-dns-proxy-opkg /etc/config/https-dns-proxy.apk-new /etc/init.d/https-dns-proxy /etc/rc.d/*https-dns-proxy "$DOH_FORCE_MODE_FILE" "$DOH_BOOT_FILE" "$DOH_FO_FILE" "$DOH_FO_STATE" "$DOH_FO_EVENTS" "$DOH_OWN_FILE" "$_doh_file.zm-prev" /tmp/zm-doh-failover.lock || rc=1 _doh_fo_cron off _zm_dns_scrub '^127\.0\.0\.1#50[0-9][0-9]$' for r in $(uci -q -X show firewall | sed -n "s/^firewall\.\([^.=]*\)\.name='[^']*[Hh]ttps[-_ ]dns[-_ ]proxy[^']*'\$/\1/p" | sort -u); 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" _zm_rm_listed doh || rc=1 if [ "$(_dn_get ru off)" != off ]; then echo "==> Российские сайты снова идут через обычный DNS роутера" _dn_put ru off _dn_apply norestart fi echo "==> Перезапускаем DNS роутера" /etc/init.d/dnsmasq restart >/dev/null 2>&1 _zo_doh_follow _zm_rm_done "$rc" "DNS over HTTPS" } doh_remove() { _dnsm_guard || return 1 job_start doh_remove do_doh_remove } doh_install() { _dnsm_guard || return 1 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://security.cloudflare-dns.com/*) echo cloudflare_sec ;; https://family.cloudflare-dns.com/*) echo cloudflare_fam ;; https://dns.quad9.net/*) echo quad9 ;; https://dns10.quad9.net/*) echo quad9_open ;; https://xbox-dns.ru/*) echo xbox ;; https://dns.comss.one/*) echo comss ;; https://dns.dns-ai.ru/*) echo dnsai ;; https://dns.bezmezhau.com/*) echo bezmezhau ;; https://common.dot.dns.yandex.net/*) echo yandex ;; 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 ;; https://dns.malw.link/*) echo malw ;; https://dns.zapret.moe/*) echo zapretmoe ;; https://d.webshiftvpn.ru/*) echo webshift ;; https://dns.dagami.ru/*) echo dagami ;; https://freedns.controld.com/p0) echo controld ;; https://freedns.controld.com/p1) echo controld_sec ;; https://freedns.controld.com/family) echo controld_fam ;; https://unfiltered.joindns4.eu/*) echo dns4eu ;; https://protective.joindns4.eu/*) echo dns4eu_sec ;; https://child.joindns4.eu/*) echo dns4eu_fam ;; *) 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_legacy_fix _doh_pkg && installed="true" [ "$installed" = true ] && _doh_fo_on && ! grep -qF "$DOH_FO_CRON" "$CRON_FILE" 2>/dev/null && _doh_fo_cron on 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,"fk_name":"%s","bootstrap_custom":"%s","hosts_extra":%s,"failover":%s,"dnsm":%s,"net":%s,"own":[%s],"zapret_doh":"%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_installed && echo true || echo false)" "$(_fk_name)" "$(esc "$(cat "$DOH_BOOT_FILE" 2>/dev/null)")" "$(_hosts_extra)" "$(_doh_fo_json)" \ "$(_dnsm_present && echo true || echo false)" "$(_dn_json)" "$(_doh_own_json)" \ "$(if [ ! -x /etc/init.d/zapret ] || [ ! -f "$CONF" ]; then echo none; elif [ -f "$ZO_DOH_FLAG" ]; then echo on; else echo off; fi)" } 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" ;; cloudflare_sec) echo "1.1.1.2,1.0.0.2,2606:4700:4700::1112,2606:4700:4700::1002" ;; cloudflare_fam) echo "1.1.1.3,1.0.0.3,2606:4700:4700::1113,2606:4700:4700::1003" ;; quad9) echo "9.9.9.9,149.112.112.112,2620:fe::fe,2620:fe::9" ;; quad9_open) echo "9.9.9.10,149.112.112.10,2620:fe::10,2620:fe::fe:10" ;; yandex) echo "77.88.8.8,77.88.8.1" ;; yandex_family) echo "77.88.8.7,77.88.8.3" ;; yandex_safe) echo "77.88.8.88,77.88.8.2" ;; controld) echo "76.76.2.0,76.76.10.0" ;; controld_sec) echo "76.76.2.1,76.76.10.1" ;; controld_fam) echo "76.76.2.4,76.76.10.4" ;; dns4eu) echo "86.54.11.100,86.54.11.200" ;; dns4eu_sec) echo "86.54.11.1,86.54.11.201" ;; dns4eu_fam) echo "86.54.11.12,86.54.11.212" ;; esac } _doh_ip_ok() { case "$1" in *"$ZM_NL"*) return 1 ;; esac 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 _dnsm_guard || return 1 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 _zm_run 30 /etc/init.d/https-dns-proxy restart >/dev/null 2>&1 /etc/init.d/dnsmasq restart >/dev/null 2>&1 fi printf '{"ok":true,"bootstrap":"%s"}\n' "$(esc "$out")" } _doh_url_of() { case "$1" in cloudflare) echo "https://cloudflare-dns.com/dns-query" ;; google) echo "https://dns.google/dns-query" ;; cloudflare_sec) echo "https://security.cloudflare-dns.com/dns-query" ;; cloudflare_fam) echo "https://family.cloudflare-dns.com/dns-query" ;; quad9) echo "https://dns.quad9.net/dns-query" ;; quad9_open) echo "https://dns10.quad9.net/dns-query" ;; yandex) echo "https://common.dot.dns.yandex.net/dns-query" ;; xbox) echo "https://xbox-dns.ru/dns-query" ;; comss) echo "https://dns.comss.one/dns-query" ;; dnsai) echo "https://dns.dns-ai.ru/dns-query" ;; bezmezhau) echo "https://dns.bezmezhau.com/dns-query" ;; yandex_family) echo "https://family.dot.dns.yandex.net/dns-query" ;; yandex_safe) echo "https://safe.dot.dns.yandex.net/dns-query" ;; geohide_ru) echo "https://geohide.ru/dns-query" ;; geohide_eu) echo "https://eu.geohide.ru/dns-query" ;; geohide_us) echo "https://us.geohide.ru/dns-query" ;; malw) echo "https://dns.malw.link/dns-query" ;; zapretmoe) echo "https://dns.zapret.moe/dns-query" ;; webshift) echo "https://d.webshiftvpn.ru/dns-query" ;; dagami) echo "https://dns.dagami.ru/dns-query" ;; controld) echo "https://freedns.controld.com/p0" ;; controld_sec) echo "https://freedns.controld.com/p1" ;; controld_fam) echo "https://freedns.controld.com/family" ;; dns4eu) echo "https://unfiltered.joindns4.eu/dns-query" ;; dns4eu_sec) echo "https://protective.joindns4.eu/dns-query" ;; dns4eu_fam) echo "https://child.joindns4.eu/dns-query" ;; https://*) printf '%s' "$1" | grep -qE '^https://[A-Za-z0-9.-]+\.[A-Za-z0-9-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~%/+=&?-]*)?$' || return 1 [ "${#1}" -le 200 ] || return 1 echo "$1" ;; *) return 1 ;; esac } _doh_boots_for() { local b="" [ -s "$DOH_BOOT_FILE" ] && b="$(head -n1 "$DOH_BOOT_FILE")" [ -n "$b" ] || b="$(_doh_boot_of "$1")" echo "${b:-1.1.1.1,8.8.8.8}" } _doh_t() { local s="$1" shift _zm_tmo "$s" "$@" } _doh_host_ips() { local host="$1" b ips n=0 case "$host" in *[!0-9.]*) ;; *) echo "$host"; return 0 ;; esac for b in $(printf '%s' "$2" | tr ',' ' '); do case "$b" in *:*) continue ;; esac [ "$n" -ge 2 ] && break n=$((n + 1)) ips="$(_doh_t 3 nslookup -type=a "$host" "$b" 2>/dev/null | awk ' /^Name:/ { f = 1 } f && /^Address/ { for (i = 2; i <= NF; i++) if ($i ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) { print $i; break } }' | head -n 3 | tr '\n' ',')" [ -n "$ips" ] && { echo "${ips%,}"; return 0; } done return 1 } _doh_wire() { [ -s "$1" ] || { echo bad; return; } if ! command -v hexdump >/dev/null 2>&1 && ! command -v od >/dev/null 2>&1; then [ "$(wc -c < "$1")" -ge 12 ] && echo ok || echo bad return fi { hexdump -v -e '1/1 "%02x "' "$1" 2>/dev/null || od -An -v -tx1 "$1" 2>/dev/null; } | awk -v ID="$2" -v NX="$3" ' function hx(s) { return (index(H, substr(s, 1, 1)) - 1) * 16 + index(H, substr(s, 2, 1)) - 1 } function nm(p, l) { while (p <= n) { l = b[p]; if (l == 0) return p + 1; if (l >= 192) return p + 2; p += l + 1 } return n + 1 } BEGIN { H = "0123456789abcdef" } { for (i = 1; i <= NF; i++) b[++n] = hx(tolower($i)) } END { if (n < 12 || b[1] * 256 + b[2] != ID || b[3] < 128) { print "bad"; exit } rc = b[4] % 16; qd = b[5] * 256 + b[6]; an = b[7] * 256 + b[8] if (NX) { print (rc == 0 || rc == 3) ? "ok" : "rcode"; exit } if (rc != 0) { print "rcode"; exit } if (an == 0) { print "empty"; exit } p = 13 for (i = 0; i < qd; i++) p = nm(p) + 4 a = 0; z = 0 for (i = 0; i < an && p + 10 <= n; i++) { p = nm(p); t = b[p] * 256 + b[p + 1]; l = b[p + 8] * 256 + b[p + 9]; p += 10 if (t == 1 && l == 4) { a++; if (b[p] == 0 || b[p] == 127) z++ } p += l } print (a && a == z) ? "sink" : "ok" }' } _doh_fail_of() { case "$1" in 6) echo resolve ;; 7) echo connect ;; 28) if [ "$2" = 0 ]; then echo timeout; elif [ "$3" = 0 ]; then echo tlsblock; else echo slow; fi ;; 35|56) if [ "$2" != 0 ] && [ "$3" = 0 ]; then echo tlsblock; else echo connect; fi ;; 51|53|58|59|60|77|83|90|91) echo tls ;; *) echo connect ;; esac } _doh_rank() { case "$1" in resolve) echo 1 ;; connect) echo 2 ;; timeout) echo 3 ;; tlsblock) echo 4 ;; tls) echo 5 ;; slow) echo 6 ;; http_*) echo 7 ;; bad) echo 8 ;; rcode|empty) echo 9 ;; sink) echo 10 ;; *) echo 0 ;; esac } _doh_pos() { awk -v t="$1" 'BEGIN { gsub(/,/, ".", t); print (t + 0 > 0) ? 1 : 0 }'; } _doh_ms() { awk -v t="$1" 'BEGIN { gsub(/,/, ".", t); v = t * 1000; if (v < 1) v = 1; printf "%d", v + 0.5 }'; } _doh_probe() { local url="$1" t="${3:-/tmp/zm-doh.$$}" rest hp host port ips ip res w rc l1 l2 r="" best="" k n=0 lab local code1 tc1 ta1 tt1 ct1 ec1 code2 tt2 ct2 ec2 W='%{http_code}|%{time_connect}|%{time_appconnect}|%{time_total}|%{content_type}|%{exitcode}\n' rest="${url#*://}"; hp="${rest%%/*}" case "$hp" in *:*) host="${hp%%:*}"; port="${hp##*:}" ;; *) host="$hp"; case "$url" in http://*) port=80 ;; *) port=443 ;; esac ;; esac lab="$(printf '%08d' $(( ($$ * 7919 + $(date +%s)) % 100000000 )))" printf '\053\147\001\000\000\001\000\000\000\000\000\000\012zm%s\007example\003com\000\000\001\000\001' "$lab" > "$t.q1" printf '\022\064\001\000\000\001\000\000\000\000\000\000\007example\003com\000\000\001\000\001' > "$t.q2" ips="$(_doh_host_ips "$host" "$2")" case "$host" in *[!0-9.]*) ;; *) ips="" ;; esac for ip in $(printf '%s' "${ips:--}" | tr ',' ' '); do [ "$n" -ge 2 ] && break n=$((n + 1)) res=""; [ "$ip" = - ] || res="--resolve $host:$port:$ip" rm -f "$t.b1" "$t.b2" w="$(curl -q --fail-early --noproxy '*' -s --connect-timeout 3 --max-time 4 $res \ -H 'Content-Type: application/dns-message' -H 'Accept: application/dns-message' \ --data-binary "@$t.q1" -o "$t.b1" -w "$W" "$url" --next --noproxy '*' -s --connect-timeout 3 --max-time 4 $res \ -H 'Content-Type: application/dns-message' -H 'Accept: application/dns-message' \ --data-binary "@$t.q2" -o "$t.b2" -w "$W" "$url" 2>/dev/null)" rc=$? l1="$(printf '%s\n' "$w" | sed -n 1p)"; l2="$(printf '%s\n' "$w" | sed -n 2p)" IFS='|' read -r code1 tc1 ta1 tt1 ct1 ec1 </dev/null 2>&1 && return 0 o="$(_doh_t 3 nslookup -type=a "zm$$-$(date +%s).example.com" 127.0.0.1 2>&1)" && return 0 printf '%s' "$o" | grep -qi 'NXDOMAIN' } _doh_live() { local o n n="zm$(printf '%08d' $(( ($$ * 7919 + $(date +%s)) % 100000000 ))).example.com" o="$(_doh_t 4 nslookup -type=a "$n" "127.0.0.1:${1:-5053}" 2>&1)" case "$o" in *NXDOMAIN*|*"Name:"*) return 0 ;; *SERVFAIL*|*REFUSED*|*[Rr]efused*|*"timed out"*|*"no servers"*) return 1 ;; esac _doh_local_ok "$1" } _doh_live_real() { local o n port="${1:-5053}" n="zm$(printf '%08d' $(( ($$ * 7919 + $(date +%s)) % 100000000 ))).example.com" o="$(_doh_t 5 nslookup -type=a "$n" "127.0.0.1:$port" 2>&1)" case "$o" in *NXDOMAIN*|*"Name:"*) ;; *) return 1 ;; esac o="$(_doh_t 5 nslookup -type=a example.com "127.0.0.1:$port" 2>&1)" case "$o" in *"Name:"*) return 0 ;; esac return 1 } _doh_wait_real() { local t0 t0="$(date +%s)" while :; do sleep 1 _doh_live_real "$1" && return 0 [ $(( $(date +%s) - t0 )) -ge "${2:-10}" ] && return 1 done } _doh_wait_local() { local t0 port="$1" t0="$(date +%s)" while :; do sleep 1 _doh_local_ok "$port" && return 0 [ $(( $(date +%s) - t0 )) -ge 8 ] && return 1 done } doh_check() { local url port boots loc=false r pp t="/tmp/zm-doh.$$" url="$(uci -q get 'https-dns-proxy.@https-dns-proxy[0].resolver_url')" if [ -z "$url" ] || ! /etc/init.d/https-dns-proxy running >/dev/null 2>&1; then echo '{"skip":true}' return 0 fi port="$(uci -q get 'https-dns-proxy.@https-dns-proxy[0].listen_port')" boots="$(uci -q get 'https-dns-proxy.@https-dns-proxy[0].bootstrap_dns')" [ -n "$boots" ] || boots="$(_doh_boots_for "$(_doh_provider_of "$url")")" _doh_probe "$url" "$boots" "$t" > "$t.r" 2>/dev/null & pp=$! { _doh_live "${port:-5053}" || { sleep 1; _doh_live "${port:-5053}"; }; } && loc=true wait "$pp" 2>/dev/null r="$(cat "$t.r" 2>/dev/null)" rm -f "$t.r" printf '{"local":%s,%s}\n' "$loc" "$(_doh_res_json "${r:-fail connect}")" } doh_set() { local provider="$1" r _dnsm_guard || return 1 _doh_url_of "$provider" >/dev/null || { echo '{"error":"адрес сервера не подходит — нужна ссылка вида https://…/dns-query"}'; return 1; } 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 if [ "$(_doh_cur_id)" = "$provider" ] && [ "$(_doh_fo_get pref "")" != "$provider" ] && [ "$(_doh_st_get mode normal)" != direct ] \ && /etc/init.d/https-dns-proxy running >/dev/null 2>&1 && _doh_live 5053; then _doh_fo_put pref "$provider" _doh_fo_put active "$provider" rm -f "$DOH_FO_STATE" _doh_fo_event "$(_doh_name "$provider") теперь основной сервер — DNS уже идёт через него" _zo_doh_follow printf '{"ok":true,"provider":"%s","kept":true}\n' "$(esc "$provider")" return 0 fi ZO_DOH_PLAN="$(_doh_url_of "$provider")" _zo_doh_follow ZO_DOH_PLAN="" r="$(_doh_apply "$provider")" || { printf '%s\n' "$r"; _zo_doh_follow; return 1; } _doh_fo_put pref "$provider" _doh_fo_put active "$provider" rm -f "$DOH_FO_STATE" _ab_dns_bg printf '%s\n' "$r" } _doh_apply() { local provider="$1" url bootstrap="" n prev="" force url="$(_doh_url_of "$provider")" || { echo '{"error":"адрес сервера не подходит — нужна ссылка вида https://…/dns-query"}'; return 1; } bootstrap="$(_doh_boot_of "$provider")" [ -s "$DOH_BOOT_FILE" ] && bootstrap="$(head -n1 "$DOH_BOOT_FILE")" force="$(_doh_force_want)" [ -f "$_doh_file" ] && cp -p "$_doh_file" "$_doh_file.zm-prev" 2>/dev/null && prev=1 { _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.zmtmp" && mv -f "$_doh_file.zmtmp" "$_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 if ! _doh_wait_local 5053; then if [ -n "$prev" ]; then mv -f "$_doh_file.zm-prev" "$_doh_file" /etc/init.d/https-dns-proxy restart >/dev/null 2>&1 /etc/init.d/dnsmasq restart >/dev/null 2>&1 echo '{"error":"Через этот сервер DNS на роутере не заработал — вернули прежние настройки"}' else echo '{"error":"Через этот сервер DNS на роутере не заработал — выберите другой"}' fi return 1 fi rm -f "$_doh_file.zm-prev" printf '{"ok":true,"provider":"%s"}\n' "$(esc "$provider")" } DOH_FO_FILE="/opt/zapret-manager-luci/doh_failover" DOH_FO_STATE="/tmp/zm-doh-failover.state" DOH_FO_EVENTS="/tmp/zm-doh-failover.events" DOH_FO_CRON="# zm-dohwatch" _doh_fo_get() { local v; v="$(_kv_last "$DOH_FO_FILE" "$1")"; echo "${v:-$2}"; } _doh_fo_put() { mkdir -p "$(dirname "$DOH_FO_FILE")" { grep -v "^$1=" "$DOH_FO_FILE" 2>/dev/null; echo "$1=$2"; } > "$DOH_FO_FILE.zm" && mv -f "$DOH_FO_FILE.zm" "$DOH_FO_FILE" } _doh_st_get() { local v; v="$(_kv_last "$DOH_FO_STATE" "$1")"; echo "${v:-$2}"; } _doh_st_put() { local k v [ -f "$DOH_FO_STATE" ] || : > "$DOH_FO_STATE" while [ $# -ge 2 ]; do k="$1"; v="$2"; shift 2 { grep -v "^$k=" "$DOH_FO_STATE"; echo "$k=$v"; } > "$DOH_FO_STATE.zm" && mv -f "$DOH_FO_STATE.zm" "$DOH_FO_STATE" done } _doh_fo_event() { logger -t zapret-manager "DoH: $1" { tail -n 9 "$DOH_FO_EVENTS" 2>/dev/null; printf '%s|%s\n' "$(date +%s)" "$1"; } > "$DOH_FO_EVENTS.zm" && mv -f "$DOH_FO_EVENTS.zm" "$DOH_FO_EVENTS" } _doh_fo_on() { [ "$(_doh_fo_get fo 0)" = 1 ] || [ "$(_doh_fo_get direct 0)" = 1 ]; } _doh_name() { case "$1" in google) echo Google ;; cloudflare) echo Cloudflare ;; quad9) echo "Quad9 · Защита от угроз" ;; xbox) echo XBOX ;; comss) echo Comss ;; dnsai) echo DNS-AI ;; bezmezhau) echo Bezmezhau ;; cloudflare_sec) echo "Cloudflare · Защита от угроз" ;; cloudflare_fam) echo "Cloudflare · Семейный фильтр" ;; quad9_open) echo "Quad9 · Без фильтрации" ;; controld_sec) echo "Control D · Защита от угроз" ;; controld_fam) echo "Control D · Семейный фильтр" ;; dns4eu_sec) echo "DNS4EU · Защита от угроз" ;; dns4eu_fam) echo "DNS4EU · Семейный фильтр" ;; yandex) echo "Яндекс" ;; yandex_family) echo "Яндекс · Семейный фильтр" ;; yandex_safe) echo "Яндекс · Защита от угроз" ;; geohide_ru) echo "GeoHide RU" ;; geohide_eu) echo "GeoHide EU" ;; geohide_us) echo "GeoHide US" ;; malw) echo MALW ;; zapretmoe) echo zapret.moe ;; webshift) echo WebShift ;; dagami) echo Dagami ;; controld) echo "Control D" ;; dns4eu) echo DNS4EU ;; https://*) _doh_own_title "$1" | grep . || printf '%s' "$1" | sed 's#^https://\([^/]*\).*#\1#' ;; *) echo "$1" ;; esac } _doh_cur_id() { local u p [ -n "$(uci -q get 'https-dns-proxy.@https-dns-proxy[1].resolver_url')" ] && return 0 u="$(uci -q get 'https-dns-proxy.@https-dns-proxy[0].resolver_url')" [ -n "$u" ] || return 0 p="$(_doh_provider_of "$u")" echo "${p:-$u}" } _doh_ok() { case "$(_doh_probe "$(_doh_url_of "$1")" "$(_doh_boots_for "$1")" "/tmp/zm-dohfo.$$")" in "ok "*) return 0 ;; esac; return 1; } _doh_cur_alive() { local port port="$(uci -q get 'https-dns-proxy.@https-dns-proxy[0].listen_port')"; port="${port:-5053}" _doh_live_real "$port" && return 0 sleep 2 _doh_live_real "$port" && return 0 _doh_ok "$1" || return 1 _zm_run 30 /etc/init.d/https-dns-proxy restart >/dev/null 2>&1 _doh_wait_real "$port" 10 && { _doh_fo_event "$(_doh_name "$1") отвечает, а DNS на роутере завис — перезапустили https-dns-proxy"; return 0; } return 1 } _doh_fo_chain() { local id _doh_fo_get chain "" | tr ',' '\n' | while IFS= read -r id; do [ -n "$id" ] && _doh_url_of "$id" >/dev/null && echo "$id" done return 0 } _doh_fo_pool() { local pref="$1" skip="$2" id for id in $(_doh_fo_chain "$pref"); do [ "$id" = "$skip" ] || [ "$id" = "$pref" ] || echo "$id"; done } doh_watch() { local lk="/tmp/zm-doh-failover.lock" cur pref mode fails okc c run=0 t _doh_fo_on || { _doh_fo_cron off; return 0; } [ -f "$_doh_file" ] && _doh_pkg || return 0 _fk_installed && return 0 _dnsm_present && return 0 _doh_legacy_fix mkdir "$lk" 2>/dev/null || { [ -n "$(find "$lk" -maxdepth 0 -mmin +5 2>/dev/null)" ] && rmdir "$lk" 2>/dev/null; return 0; } mode="$(_doh_st_get mode normal)" /etc/init.d/https-dns-proxy running >/dev/null 2>&1 && run=1 cur="$(_doh_cur_id)" if [ -z "$cur" ]; then rmdir "$lk" 2>/dev/null; return 0; fi pref="$(_doh_fo_get pref "")" [ -n "$pref" ] && _doh_url_of "$pref" >/dev/null || { pref="$cur"; _doh_fo_put pref "$pref"; } t="$(_doh_fo_get active "")" if [ -n "$t" ] && [ "$t" != "$cur" ]; then pref="$cur"; _doh_fo_put pref "$cur"; rm -f "$DOH_FO_STATE"; mode=normal; fi _doh_fo_put active "$cur" if [ "$mode" = direct ]; then for c in "$pref" $([ "$(_doh_fo_get fo 0)" = 1 ] && _doh_fo_pool "$pref" ""); do _doh_ok "$c" || continue [ "$c" = "$cur" ] || _doh_apply "$c" >/dev/null 2>&1 || continue _doh_fo_put active "$c" /etc/init.d/https-dns-proxy start >/dev/null 2>&1 t=0; while ! _doh_local_ok 5053 && [ "$t" -lt 8 ]; do sleep 1; t=$((t + 1)); done _doh_st_put mode "$([ "$c" = "$pref" ] && echo normal || echo backup)" fails 0 okc 0 since "$(date +%s)" _doh_fo_event "$(_doh_name "$c") отвечает — DNS снова идёт через DoH" break done rmdir "$lk" 2>/dev/null return 0 fi if [ "$run" != 1 ]; then /etc/init.d/https-dns-proxy enabled >/dev/null 2>&1 || { rmdir "$lk" 2>/dev/null; return 0; } _zm_run 30 /etc/init.d/https-dns-proxy start >/dev/null 2>&1 if _doh_wait_real 5053 10 && /etc/init.d/https-dns-proxy running >/dev/null 2>&1; then _doh_st_put fails 0 _doh_fo_event "служба https-dns-proxy остановилась — запустили её снова, DNS идёт через $(_doh_name "$cur")" rmdir "$lk" 2>/dev/null return 0 fi fi fails="$(_doh_st_get fails 0)"; okc="$(_doh_st_get okc 0)" if [ "$cur" != "$pref" ] && [ "$(_doh_fo_get fo 0)" = 1 ]; then if _doh_ok "$pref"; then okc=$((okc + 1)); else okc=0; fi if [ "$okc" -ge 2 ] && _doh_apply "$pref" >/dev/null 2>&1; then _doh_fo_put active "$pref" _doh_st_put mode normal fails 0 okc 0 since "$(date +%s)" _doh_fo_event "$(_doh_name "$pref") снова отвечает — вернулись на него с $(_doh_name "$cur")" rmdir "$lk" 2>/dev/null return 0 fi _doh_st_put okc "$okc" fi if /etc/init.d/https-dns-proxy running >/dev/null 2>&1 && _doh_cur_alive "$cur"; then _doh_st_put fails 0 rmdir "$lk" 2>/dev/null return 0 fi fails=$((fails + 1)); _doh_st_put fails "$fails" [ "$fails" -ge 2 ] || { rmdir "$lk" 2>/dev/null; return 0; } if [ "$(_doh_fo_get fo 0)" = 1 ]; then for c in $([ "$cur" != "$pref" ] && echo "$pref") $(_doh_fo_pool "$pref" "$cur"); do _doh_ok "$c" || continue _doh_apply "$c" >/dev/null 2>&1 || continue _doh_fo_put active "$c" _doh_st_put mode "$([ "$c" = "$pref" ] && echo normal || echo backup)" fails 0 okc 0 since "$(date +%s)" _doh_fo_event "$(_doh_name "$cur") не отвечает — DNS переключён на $(_doh_name "$c")" rmdir "$lk" 2>/dev/null return 0 done fi if [ "$(_doh_fo_get direct 0)" = 1 ]; then /etc/init.d/https-dns-proxy stop >/dev/null 2>&1 /etc/init.d/dnsmasq restart >/dev/null 2>&1 _doh_st_put mode direct fails 0 okc 0 since "$(date +%s)" _doh_fo_event "не отвечает ни один сервер — DNS временно идёт без DoH, через обычные серверы" elif [ "$fails" = 2 ]; then _doh_fo_event "$(_doh_name "$cur") не отвечает, запасные из цепочки тоже — DoH не выключаем, ждём, когда какой-нибудь ответит" fi rmdir "$lk" 2>/dev/null return 0 } _doh_fo_cron() { local line="*/2 * * * * /opt/zapret-manager-luci/backend.sh doh_watch >/dev/null 2>&1 $DOH_FO_CRON" if [ "$1" = on ]; then grep -qxF "$line" "$CRON_FILE" 2>/dev/null && return 0 mkdir -p "$(dirname "$CRON_FILE")"; touch "$CRON_FILE" grep -vF "$DOH_FO_CRON" "$CRON_FILE" > "$CRON_FILE.zm" 2>/dev/null; cat "$CRON_FILE.zm" > "$CRON_FILE"; rm -f "$CRON_FILE.zm" echo "$line" >> "$CRON_FILE" else grep -qF "$DOH_FO_CRON" "$CRON_FILE" 2>/dev/null || return 0 grep -vF "$DOH_FO_CRON" "$CRON_FILE" > "$CRON_FILE.zm" 2>/dev/null; cat "$CRON_FILE.zm" > "$CRON_FILE"; rm -f "$CRON_FILE.zm" fi /etc/init.d/cron restart >/dev/null 2>&1 return 0 } _doh_fo_json() { local ev ch="" id sep="" pref pref="$(_doh_fo_get pref "")"; [ -n "$pref" ] || pref="$(_doh_cur_id)" for id in $(_doh_fo_chain "$pref"); do ch="$ch$sep\"$(esc "$id")\""; sep=","; done ev="$(awk -F'|' 'BEGIN { s = "" } NF >= 2 { m = $0; sub(/^[^|]*\|/, "", m); gsub(/["\\]/, "", m); printf "%s{\"at\":%d,\"text\":\"%s\"}", s, $1, m; s = "," }' "$DOH_FO_EVENTS" 2>/dev/null)" printf '{"fo":%s,"direct":%s,"pref":"%s","chain":[%s],"mode":"%s","since":%s,"fails":%s,"events":[%s]}' \ "$([ "$(_doh_fo_get fo 0)" = 1 ] && echo true || echo false)" "$([ "$(_doh_fo_get direct 0)" = 1 ] && echo true || echo false)" \ "$(esc "$(_doh_fo_get pref "")")" "$ch" \ "$(esc "$(_doh_st_get mode normal)")" "$(_doh_st_get since 0)" "$(_doh_st_get fails 0 | tr -cd 0-9)" "$ev" } doh_failover_set() { local kv k v fo direct chain="" setch="" id n=0 pref bad="" _dnsm_guard || return 1 fo="$(_doh_fo_get fo 0)"; direct="$(_doh_fo_get direct 0)" pref="$(_doh_fo_get pref "")"; [ -n "$pref" ] || pref="$(_doh_cur_id)" set -f for kv in $(printf '%s' "$1" | tr -c 'A-Za-z0-9._~%/+=&?:,-' ' '); do k="${kv%%=*}"; v="${kv#*=}" case "$k" in fo) [ "$v" = 1 ] && fo=1 || fo=0 ;; direct) [ "$v" = 1 ] && direct=1 || direct=0 ;; chain) setch=1 for id in $(printf '%s' "$v" | tr ',' ' '); do _doh_url_of "$id" >/dev/null || { bad="$id"; continue; } [ "$id" = "$pref" ] && continue case ",$chain," in *",$id,"*) continue ;; esac [ "$n" -ge 3 ] && break chain="$chain${chain:+,}$id"; n=$((n + 1)) done ;; esac done set +f [ -z "$bad" ] || { printf '{"error":"Адрес запасного сервера не подходит: %s"}\n' "$(esc "$bad")"; return 1; } _doh_fo_put fo "$fo"; _doh_fo_put direct "$direct" [ -n "$setch" ] && _doh_fo_put chain "$chain" [ -n "$(_doh_fo_get pref "")" ] || { v="$(_doh_cur_id)"; [ -n "$v" ] && _doh_fo_put pref "$v"; } if [ "$fo" = 1 ] || [ "$direct" = 1 ]; then _doh_fo_cron on else _doh_fo_cron off if [ "$(_doh_st_get mode normal)" = direct ]; then /etc/init.d/https-dns-proxy start >/dev/null 2>&1 _doh_fo_event "автопереключение выключено — DNS снова идёт через DoH" fi rm -f "$DOH_FO_STATE" fi _zo_doh_follow printf '%s\n' "$(_doh_fo_json)" } DOH_OWN_FILE="/opt/zapret-manager-luci/doh_own" DOH_OWN_MAX=12 _doh_own_urls() { cut -f1 "$DOH_OWN_FILE" 2>/dev/null | grep '^https://'; } _doh_own_has() { _doh_own_urls | grep -qxF "$1"; } _doh_own_title() { [ -s "$DOH_OWN_FILE" ] && ZM_U="$1" awk -F'\t' '$1 == ENVIRON["ZM_U"] { print $2; exit }' "$DOH_OWN_FILE"; } _doh_own_json() { [ -s "$DOH_OWN_FILE" ] || return 0 awk -F'\t' '$1 ~ /^https:\/\// { n2 = $2; gsub(/["\\]/, "", n2); printf "%s{\"url\":\"%s\",\"name\":\"%s\"}", (n++ ? "," : ""), $1, n2 }' "$DOH_OWN_FILE" } _doh_own_put() { mkdir -p "$(dirname "$DOH_OWN_FILE")" touch "$DOH_OWN_FILE" ZM_U="$1" ZM_N="$2" awk -F'\t' 'BEGIN { OFS = "\t" } $1 == ENVIRON["ZM_U"] { print $1, ENVIRON["ZM_N"]; f = 1; next } NF { print } END { if (!f) print ENVIRON["ZM_U"], ENVIRON["ZM_N"] }' \ "$DOH_OWN_FILE" > "$DOH_OWN_FILE.zm" && mv -f "$DOH_OWN_FILE.zm" "$DOH_OWN_FILE" } _doh_own_drop() { [ -f "$DOH_OWN_FILE" ] || return 0 ZM_U="$1" awk -F'\t' '$1 != ENVIRON["ZM_U"] && NF' "$DOH_OWN_FILE" > "$DOH_OWN_FILE.zm" && mv -f "$DOH_OWN_FILE.zm" "$DOH_OWN_FILE" [ -s "$DOH_OWN_FILE" ] || rm -f "$DOH_OWN_FILE" } _doh_legacy_url() { case "$1" in comss_ru) echo "https://dns.comss.ru/dns-query Comss RU" ;; mafioznik) echo "https://dns.mafioznik.com/dns-query Mafioznik" ;; astracat) echo "https://dns.astrakat.ru/dns-query AstraCat" ;; adguard) echo "https://unfiltered.adguard-dns.com/dns-query AdGuard" ;; esac } _doh_legacy_fix() { local id l u cur cur="$(uci -q get 'https-dns-proxy.@https-dns-proxy[0].resolver_url')" case "$cur" in https://dns.comss.ru/*|https://dns.mafioznik.com/*|https://dns.astrakat.ru/*|https://unfiltered.adguard-dns.com/*) ;; *) grep -qE '^(pref|active|chain)=(.*,)?(comss_ru|mafioznik|astracat|adguard)(,|$)' "$DOH_FO_FILE" 2>/dev/null || return 0 ;; esac for id in comss_ru mafioznik astracat adguard; do l="$(_doh_legacy_url "$id")"; u="${l%% *}" if grep -qE "^(pref|active|chain)=(.*,)?$id(,|\$)" "$DOH_FO_FILE" 2>/dev/null; then sed -i -E "/^(pref|active|chain)=/ s#(=|,)$id(,|\$)#\1$u\2#g" "$DOH_FO_FILE" elif [ "$cur" != "$u" ]; then continue fi _doh_own_has "$u" || _doh_own_put "$u" "${l#* }" done return 0 } doh_own_set() { local act rest url name cur pref ch _dnsm_guard || return 1 _doh_legacy_fix act="${1%% *}"; rest="${1#"$act"}"; rest="${rest# }" url="${rest%% *}"; name="${rest#"$url"}" name="$(printf '%s' "$name" | tr -d '\000-\037\177"\\<>' | sed 's/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//')" case "$act" in add) case "$url" in https://*) ;; *) url="" ;; esac [ -n "$url" ] && _doh_url_of "$url" >/dev/null || { echo '{"error":"Адрес не подходит — нужна ссылка вида https://dns.example.com/dns-query"}'; return 1; } [ -z "$(_doh_provider_of "$url")" ] || { printf '{"error":"Этот сервер уже есть в списке — %s"}\n' "$(esc "$(_doh_name "$(_doh_provider_of "$url")")")"; return 1; } [ "${#name}" -le 96 ] || { echo '{"error":"Название слишком длинное — не больше 32 букв"}'; return 1; } if ! _doh_own_has "$url" && [ "$(_doh_own_urls | wc -l)" -ge "$DOH_OWN_MAX" ]; then printf '{"error":"В списке «Свои» уже %s серверов — уберите ненужный"}\n' "$DOH_OWN_MAX"; return 1 fi _doh_own_put "$url" "$name" ;; del) case "$url" in https://*) ;; *) echo '{"error":"Нет такого сервера"}'; return 1 ;; esac cur="$(_doh_cur_id)" [ "$cur" != "$url" ] || { echo '{"error":"Сейчас DNS идёт через этот сервер — сначала выберите другой"}'; return 1; } pref="$(_doh_fo_get pref "")" if [ "$pref" = "$url" ] && _doh_fo_on; then echo '{"error":"Это основной сервер цепочки «Если сервер перестал отвечать» — сначала выберите другой"}'; return 1 fi _doh_own_drop "$url" if grep -q '^chain=' "$DOH_FO_FILE" 2>/dev/null; then ch="$(_doh_fo_get chain "" | tr ',' '\n' | grep -vxF "$url" | grep . | tr '\n' ',')" _doh_fo_put chain "${ch%,}" fi _zo_doh_follow ;; *) echo '{"error":"неизвестное действие"}'; return 1 ;; esac printf '{"ok":true,"own":[%s],"failover":%s}\n' "$(_doh_own_json)" "$(_doh_fo_json)" } DN_FILE="/opt/zapret-manager-luci/dns_net" DN_OWN="/opt/zapret-manager-luci/dns_net.owned" DN_CANARY="use-application-dns.net mask.icloud.com mask-h2.icloud.com" DN_RU_ZONES="ru su xn--p1ai" DN_YANDEX="77.88.8.8 77.88.8.1" _dn_get() { local v; v="$(_kv_last "$DN_FILE" "$1")"; echo "${v:-$2}"; } _dn_put() { mkdir -p "$(dirname "$DN_FILE")" { grep -v "^$1=" "$DN_FILE" 2>/dev/null; echo "$1=$2"; } > "$DN_FILE.zm" && mv -f "$DN_FILE.zm" "$DN_FILE" } _dn_secs() { uci -q -X show dhcp 2>/dev/null | sed -n "s/^dhcp\.\([A-Za-z0-9_]*\)=dnsmasq\$/\1/p"; } _dn_isp() { local f for f in /tmp/resolv.conf.d/resolv.conf.auto /tmp/resolv.conf.auto; do [ -s "$f" ] || continue awk '$1 == "nameserver" && ($2 ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ || ($2 ~ /^[0-9A-Fa-f:]+$/ && $2 ~ /:.*:/)) && $2 !~ /^(127\.|0\.0\.0\.0$|::1$|[Ff][Ee]80:)/ && !u[$2]++ { print $2 }' "$f" | head -n 4 | tr '\n' ',' | sed 's/,$//' return 0 done } _dn_ru_ips() { case "$(_dn_get ru off)" in yandex) echo "$DN_YANDEX" ;; isp) _dn_get ispips "" | tr ',' ' ' ;; esac } _dn_has() { uci -q get "dhcp.$1.$2" 2>/dev/null | tr ' ' '\n' | grep -qxF "$3"; } _dn_add() { _dn_has "$1" "$2" "$3" && return 0 uci -q add_list "dhcp.$1.$2=$3" && echo "$1 $2 $3" >> "$DN_OWN" } _dn_drop() { local sec opt val keep="$DN_OWN.keep" [ -s "$DN_OWN" ] || { rm -f "$DN_OWN"; return 0; } : > "$keep" while read -r sec opt val; do [ -n "$val" ] || continue if [ "$1" = all ] || { [ "$1" = canary ] && [ "$opt" = address ]; } || { [ "$1" = ru ] && [ "$opt" = server ]; }; then uci -q del_list "dhcp.$sec.$opt=$val" else echo "$sec $opt $val" >> "$keep" fi done < "$DN_OWN" if [ -s "$keep" ]; then mv -f "$keep" "$DN_OWN"; else rm -f "$keep" "$DN_OWN"; fi } _dn_snap() { uci -q -X show dhcp 2>/dev/null | grep -E '^dhcp\.[^.]*\.(address|server)='; } _dn_apply() { local before sec d ip ips before="$(_dn_snap)" _dn_drop all ips="$(_dn_ru_ips)" for sec in $(_dn_secs); do if [ "$(_dn_get canary 0)" = 1 ]; then for d in $DN_CANARY; do _dn_add "$sec" address "/$d/"; done fi for d in $DN_RU_ZONES; do for ip in $ips; do _dn_add "$sec" server "/$d/$ip"; done done done [ "$before" = "$(_dn_snap)" ] && { uci -q commit dhcp; return 0; } uci -q commit dhcp [ "$1" = norestart ] || /etc/init.d/dnsmasq restart >/dev/null 2>&1 return 0 } _dn_json() { local doh=false uci -q -X show dhcp 2>/dev/null | grep -E '^dhcp\.[^.]*\.server=' | grep -qF "'/use-application-dns.net/'" && doh=true printf '{"canary":%s,"canary_doh":%s,"ru":"%s","ru_ips":"%s","isp":"%s"}' \ "$([ "$(_dn_get canary 0)" = 1 ] && echo true || echo false)" "$doh" "$(esc "$(_dn_get ru off)")" \ "$(esc "$(_dn_ru_ips | tr ' ' ',')")" "$(esc "$(_dn_isp)")" } dns_net_set() { local kv k v canary ru ips _dnsm_guard || return 1 canary="$(_dn_get canary 0)"; ru="$(_dn_get ru off)" for kv in $(printf '%s' "$1" | tr -c 'A-Za-z0-9=_-' ' '); do k="${kv%%=*}"; v="${kv#*=}" case "$k" in canary) if [ "$v" = 1 ]; then canary=1; else canary=0; fi ;; ru) case "$v" in off|yandex|isp) ru="$v" ;; *) echo '{"error":"неизвестный режим для российских сайтов"}'; return 1 ;; esac ;; esac done if [ "$ru" != off ] && [ "$ru" != "$(_dn_get ru off)" ] && _fk_installed; then printf '{"error":"Стоит %s — он сам решает, куда идут запросы DNS. Российские сайты оставлены как есть"}\n' "$(_fk_name)" return 1 fi if [ "$ru" = isp ]; then ips="$(_dn_isp)" [ -n "$ips" ] || { echo '{"error":"Провайдер не выдал роутеру адреса DNS — выберите Яндекс DNS"}'; return 1; } _dn_put ispips "$ips" fi _dn_put canary "$canary" _dn_put ru "$ru" _dn_apply printf '{"ok":true,"net":%s}\n' "$(_dn_json)" } _dn_watch() { local now [ "$(_dn_get ru off)" = isp ] || return 0 _dnsm_present && return 0 now="$(_dn_isp)" [ -n "$now" ] && [ "$now" != "$(_dn_get ispips "")" ] || return 0 _dn_put ispips "$now" _dn_apply logger -t zapret-manager "Российские сайты: DNS провайдера сменился — теперь $(printf '%s' "$now" | sed 's/,/, /g')" } dns_net_gone() { [ -s "$DN_OWN" ] || { rm -f "$DN_FILE" "$DN_OWN"; return 0; } _dn_drop all uci -q commit dhcp rm -f "$DN_FILE" "$DN_OWN" /etc/init.d/dnsmasq restart >/dev/null 2>&1 return 0 } RB_SHARE="/usr/share/zm-redbtn" ZM_LISTS_DIR="/opt/zapret-manager-luci/lists" ZM_LISTS_ITD="${GH_RAW}/itdoginfo/allow-domains/main" ZM_LISTS_V2F="${GH_RAW}/v2fly/domain-list-community/master/data" _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_instagram.lst) echo "$ZM_LISTS_V2F/instagram" ;; svc_instagram_extra.lst) echo "$RB_SHARE/svc/instagram_extra.lst" ;; svc_facebook.lst) echo "$ZM_LISTS_V2F/facebook" ;; svc_messenger.lst) echo "$ZM_LISTS_V2F/messenger" ;; svc_whatsapp.lst) echo "$ZM_LISTS_V2F/whatsapp" ;; svc_whatsapp_extra.lst) echo "$RB_SHARE/svc/whatsapp_extra.lst" ;; svc_google_ai_extra.lst) echo "$RB_SHARE/svc/google_ai_extra.lst" ;; svc_tiktok_extra.lst) echo "$RB_SHARE/svc/tiktok_extra.lst" ;; svc_v2meta.lst) echo "$ZM_LISTS_V2F/meta" ;; svc_threads.lst) echo "$ZM_LISTS_V2F/threads" ;; svc_oculus.lst) echo "$ZM_LISTS_V2F/oculus" ;; 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" ;; 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 case "$u" in /*) [ -s "$u" ] || { echo " !! $f: нет встроенного списка" >&2; return 1; } mkdir -p "$ZM_LISTS_DIR" 2>/dev/null tmp="$ZM_LISTS_DIR/.$f.$$" cp -f "$u" "$tmp" 2>/dev/null && _rb_list_norm "$f" "$tmp" > "$tmp.ok" rm -f "$tmp" if [ -s "$tmp.ok" ]; then if cmp -s "$tmp.ok" "$p"; then rm -f "$tmp.ok"; else mv -f "$tmp.ok" "$p"; fi echo " ✓ $f: встроенный, строк $(wc -l < "$p" | tr -d ' ')" >&2 return 0 fi rm -f "$tmp.ok" return 1 ;; esac 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" -v conf="$RB_SHARE/services.conf" 'BEGIN { while ((getline l < conf) > 0) { if (l ~ /^#/) continue; split(l, a, "|"); if (a[1] == "") continue; b[a[1]] = 1; m = split(a[8], c, ","); for (i = 1; i <= m; i++) if (c[i] != "") b[c[i]] = 1 } } $9 != "" && $9 != "Свои списки каталога" { k = $1; sub(/^c_/, "", k); if (index(skip, " " k " ")) next; if (k ~ /^itdoginfo_/ && (substr(k, 11) in b)) next; 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() { ST_MODE_RAW="" [ -f "$ST_WARP_MODE" ] && { read -r ST_MODE_RAW < "$ST_WARP_MODE" || :; } 2>/dev/null case "$ST_MODE_RAW" in own) ST_WARP_N=1; ST_WIF1="$ST_OWN_IF"; ST_WCONF1="$ST_WARP_OWN" ;; iface:?*) ST_WARP_N=1; ST_WIF1="${ST_MODE_RAW#iface:}"; ST_WCONF1="$ST_DIR/warp.ext.none" ;; *) ST_WARP_N="$ST_WARP_MAX"; ST_WIF1="zmwarp"; ST_WCONF1="$ST_DIR/warp.conf" ;; esac 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" 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_VPN_PFX="zmv" ST_SEC="" _stl_say() { echo "==> $*"; } _st_onfail() { [ "$(cat /etc/zm-steer/onfail 2>/dev/null)" = drop ] && echo drop || echo direct; } _st_dnsmode() { [ "$(cat /etc/zm-steer/dns.mode 2>/dev/null)" = router ] && echo router || echo tun; } _st_realip() { [ "$(cat /etc/zm-steer/realip.mode 2>/dev/null)" = off ] && echo off || echo on; } _st_dnsopt_save() { mkdir -p /etc/zm-steer rm -f /etc/zm-steer/realip case "$1" in dnsmode) if [ "$2" = router ]; then echo router > /etc/zm-steer/dns.mode; else rm -f /etc/zm-steer/dns.mode; fi ;; realip) if [ "$2" = off ]; then echo off > /etc/zm-steer/realip.mode; else rm -f /etc/zm-steer/realip.mode; fi ;; esac } _stl_t() { _zm_tmo "$@"; } _stl_out() { case "$1" in vpn) echo "$STL_VPN_OUT" ;; iface) echo "zm_if${ST_SEC#s}" ;; *) 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() { _zm_re "$1" '^[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|vless) [ -x "$STL_SBIN/steer-vless" ] ;; hy2|hysteria2) [ -x "$STL_SBIN/steer-hysteria2" ] ;; proxy) [ -x "$STL_SBIN/steer-proxy" ] ;; *) 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; 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" ;; hy2) mods="$mods hysteria2" ;; proxy) mods="$mods proxy" ;; 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 [ -n "$STL_REINSTALL" ] && [ -z "$legacy" ]; then "$STL_INIT" stop >/dev/null 2>&1 if [ "$PKG" = apk ]; then n=""; for p in $pkgs; do _pkg_is_installed "$p" && n="$n $p"; done [ -n "$n" ] && $DELETE $n >&2 $INSTALL $files >&2; rc=$? [ "$rc" = 0 ] || { $INSTALL $files >&2; rc=$?; } else $INSTALL --force-reinstall $files >&2; rc=$? fi elif [ "$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" local rc=0 [ -n "$mods" ] && { _zm_pkg_wipe $mods >&2 || rc=1; } [ -n "$core" ] && { _zm_pkg_wipe $core >&2 || rc=1; } return $rc } stl_pkgs_installed() { local p m for p in $(for m in $STL_MODS; do echo "steer-$m"; done) steer-extended steer-core steer libsteer libsteer-wolfssl; do _pkg_is_installed "$p" && printf '%s ' "$p" done } 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" rm -f "$STL_SOCK" rmdir "$STL_ETC" 2>/dev/null [ "$n" -gt 0 ] && echo " ✓ Убраны оставшиеся правила ядра steer: $n" return 0 } stl_spec_whose() { [ -s "$STL_YAML" ] && { echo foreign; return; } [ -s "$STL_SPEC" ] || { echo none; return; } grep -qE '"zm_(warp|vpn[0-9]*|if[2-9]|direct)"' "$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" ] && [ -n "$out" ] || 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}" x="" [ "$(_st_realip)" = on ] && x=',"resolve":"realip"' [ -z "$ZM_STL_NODNS" ] && [ "$(_st_dnsmode)" = tun ] && { x="$x,\"dns\":\"zm_tun$dnt\""; _stl_dns_need "$dnt" "$out"; } if [ -n "$sfm$sfa" ]; then [ -n "$sfm" ] && rules="$rules${rules:+,}{\"name\":$(_stl_js "$(_stl_ident "${lid}~m")"),\"for\":[$sfm],\"to\":$(_stl_js "$lid"),\"out\":\"$out\"$x}" [ -n "$sfa" ] && rules="$rules${rules:+,}{\"name\":$(_stl_js "$(_stl_ident "${lid}~a")"),\"for\":[$sfa],\"to\":$(_stl_js "$lid"),\"out\":\"$out\"$x}" else rules="$rules${rules:+,}{\"name\":$(_stl_js "$lab"),\"to\":$(_stl_js "$lid"),\"out\":\"$out\"$x}" fi } _stl_dns_need() { case " $dnsn " in *" $1:$2 "*) ;; *) dnsn="$dnsn $1:$2" ;; esac; } _stl_tun_json() { local o m n o="$(_stl_js "$1"):{\"kind\":\"tunnel\",\"protocol\":\"$2\",\"device\":\"$6\",\"subscription\":$(_stl_js "$3")" n="$(printf '%s' "$4" | tr -cd '0-9,' | sed 's/,,*/,/g; s/^,//; s/,$//')" [ "$4" != - ] && [ -n "$n" ] && o="$o,\"nodes\":[$n]" m=""; [ "$5" = - ] || m="$(printf '%s' "$5" | tr '|,' '\n\n' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | grep . | awk '!s[tolower($0)]++' | while IFS= read -r n; do printf ',%s' "$(_stl_js "$n")"; done | sed 's/^,//')" [ -n "$m" ] && o="$o,\"exclude_name\":[$m]" printf '%s' "$o" } _stl_vpn_outs() { local tab k p b c d n o="" m="" tab="$(printf '\t')" n="$(grep -c "^tun$tab" "$1")" while IFS="$tab" read -r k p b c; do [ "$k" = tun ] || continue case " $STL_VPN_PROTOS " in *" $p "*) ;; *) continue ;; esac if [ "$n" -le 1 ]; then o="$(_stl_tun_json "$STL_VPN_OUT" "$p" "$2" "$b" "$c" "$STL_VPN_DEV"),\"on_fail\":\"$(_st_onfail)\"}" else d="$(_stl_member "$p")" o="$o,$(_stl_tun_json "$d" "$p" "$2" "$b" "$c" "$d")}" m="$m${m:+,}\"$d\"" fi done < "$1" [ "$n" -le 1 ] || o="\"$STL_VPN_OUT\":{\"kind\":\"group\",\"pick\":\"order\",\"members\":[$m],\"ipv6\":\"off\",\"on_fail\":\"$(_st_onfail)\"}$o" printf '%s' "$o" } _stl_sec_vpn_outs() { local t="$STL_TMP/secvpn.$$" r awk -F'\t' -v s="$2" 'BEGIN { OFS = "\t" } $1 == "stun" && $2 == s { $1 = "tun"; $2 = ""; sub(/^tun\t\t/, "tun\t"); print }' "$1" > "$t" r="$(STL_VPN_OUT="zm_vpn${2#s}"; STL_VPN_DEV="zm_vpn${2#s}"; STL_VPN_PFX="zmv${2#s}"; _stl_vpn_outs "$t" "$3")" rm -f "$t" printf '%s' "$r" } _stl_client() { STL_CID="$(_stl_ident "d_$1")" case "$clients" in *"\"$STL_CID\":"*) ;; *) case "$1" in *:*) clients="$clients${clients:+,}\"$STL_CID\":{\"mac\":[$(_stl_js "$1")]}" ;; *) clients="$clients${clients:+,}\"$STL_CID\":{\"addr\":[$(_stl_js "$1")]}" ;; esac ;; esac } _stl_spec() { local tab k a b c d m lists="" rules="" outs="" lan="" lid="" lab="" srs="" dom="" pfx="" local clients="" drules="" cid dout byp="" xd="" xp="" xc="" xr="" x="" dnsj="" dnsn="" dnt="" local body wp=latency main="" mout="" out="" sec="" sfm="" sfa="" so="" t u dvs="" STL_CID="" mf="$1" tab="$(printf '\t')" mkdir -p "$STL_TMP" while IFS="$tab" read -r k a b c; do case "$k" in main) main="$a" ;; vpn) [ -n "$main" ] || main=vpn ;; wpick) [ "$a" = order ] && wp=order ;; esac done < "$mf" [ -n "$main" ] || main=warp case "$main" in vpn) mout="$STL_VPN_OUT" ;; warp) mout="$STL_WARP_OUT" ;; *) mout="" ;; esac while IFS="$tab" read -r k a b c; do case "$k" in so) case "$b" in vpn) t="$(_stl_sec_vpn_outs "$mf" "$a" "$c")"; [ -n "$t" ] && { outs="$outs${outs:+,}$t"; so="$so $a:zm_vpn${a#s}"; } ;; iface) printf '%s' "$c" | grep -qE '^[A-Za-z0-9_.-]{1,15}$' || continue outs="$outs${outs:+,}\"zm_if${a#s}\":{\"kind\":\"interface\",\"device\":$(_stl_js "$c"),\"ipv6\":\"off\",\"on_fail\":\"$(_st_onfail)\"}" so="$so $a:zm_if${a#s}" ;; warp) so="$so $a:$STL_WARP_OUT" ;; esac ;; esac done < "$mf" out="$mout" 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 t="\"$STL_WARP_OUT\":{\"kind\":\"interface\",\"device\":$(_stl_js "$1"),\"ipv6\":\"off\",\"on_fail\":\"$(_st_onfail)\"}" else m=""; u=""; for d; do m="$m${m:+,}$(_stl_js "$d")"; u="$u,$(_stl_js "$d"):{\"kind\":\"interface\",\"device\":$(_stl_js "$d"),\"ipv6\":\"off\"}"; done d=""; [ "$wp" = latency ] && d=',"tolerance":200,"interval":600' t="\"$STL_WARP_OUT\":{\"kind\":\"group\",\"pick\":\"$wp\"$d,\"members\":[$m],\"ipv6\":\"off\",\"on_fail\":\"$(_st_onfail)\"}$u" fi outs="$t${outs:+,}$outs" ;; vpn) [ "$main" = vpn ] && { t="$(_stl_vpn_outs "$mf" "$a")"; [ -n "$t" ] && outs="$t${outs:+,}$outs"; } ;; sec) _stl_rule; lid=""; srs=""; dom=""; pfx="" sec="$a"; out=""; dnt="${a#s}"; sfm=""; sfa="" for t in $so; do [ "${t%%:*}" = "$a" ] && out="${t#*:}"; done [ -n "$out" ] || continue if [ "$out" = "$mout" ]; then dnt=""; elif [ "$out" = "$STL_WARP_OUT" ]; then dnt=w; fi while IFS="$tab" read -r u d m x; do [ "$u" = sdev ] && [ "$d" = "$a" ] && [ "$m" = only ] || continue _stl_client "$x" case "$x" in *:*) sfm="$sfm${sfm:+,}\"$STL_CID\"" ;; *) sfa="$sfa${sfa:+,}\"$STL_CID\"" ;; esac done < "$mf" x="" ;; svc) _stl_rule; lid="$(_stl_ident "${sec:+${sec}_}$a")"; lab="$(_stl_label "$b${sec:+ · ${sec#s}}" "${sec:+${sec}_}$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")" ;; xdom) xd="$xd${xd:+,}$(_stl_js "$a")" ;; xpfx) xp="$xp${xp:+,}$(_stl_js "$a")" ;; dev) if [ "$a" = byp ]; then dout="$ST_DIRECT_OUT"; byp=1; else dout="$mout"; fi [ -n "$dout" ] || continue case " $dvs " in *" $b "*) continue ;; esac dvs="$dvs $b" _stl_client "$b" [ "$a" = byp ] || xc="$xc $STL_CID" drules="$drules${drules:+,}{\"name\":$(_stl_js "$(_stl_ident "${a}_$b")"),\"for\":[\"$STL_CID\"],\"to\":\"all\",\"out\":\"$dout\",\"scope\":\"device\"}" ;; sdev) [ "$b" = all ] || continue dout=""; for t in $so; do [ "${t%%:*}" = "$a" ] && dout="${t#*:}"; done [ -n "$dout" ] || continue case " $dvs " in *" $c "*) continue ;; esac dvs="$dvs $c" _stl_client "$c" xc="$xc $STL_CID" drules="$drules${drules:+,}{\"name\":$(_stl_js "$(_stl_ident "${a}_all_$c")"),\"for\":[\"$STL_CID\"],\"to\":\"all\",\"out\":\"$dout\",\"scope\":\"device\"}" ;; esac done < "$mf" _stl_rule [ -n "$rules$drules" ] || return 1 if [ -n "$xd$xp" ]; then body="" [ -n "$xd" ] && body="\"domains_file\":[$xd]" [ -n "$xp" ] && body="$body${body:+,}\"prefixes_file\":[$xp]" lists="\"$ST_EXCL_LIST\":{$body}${lists:+,}$lists" rules="{\"name\":\"Исключения\",\"to\":\"$ST_EXCL_LIST\",\"out\":\"$ST_DIRECT_OUT\",\"resolve\":\"realip\"}${rules:+,}$rules" for cid in $(printf '%s\n' $xc | awk '!s[$0]++'); do xr="$xr${xr:+,}{\"name\":$(_stl_js "$(_stl_ident "x_$cid")"),\"for\":[\"$cid\"],\"to\":\"$ST_EXCL_LIST\",\"out\":\"$ST_DIRECT_OUT\",\"scope\":\"device\",\"resolve\":\"realip\"}" done [ -n "$xr" ] && drules="$xr${drules:+,}$drules" byp=1 fi [ -n "$byp" ] && outs="$outs${outs:+,}\"$ST_DIRECT_OUT\":{\"kind\":\"direct\"}" [ -n "$drules" ] && [ -n "$rules" ] && drules="$drules," rules="$drules$rules" [ -n "$clients" ] && clients="\"clients\":{$clients}," if [ -n "$dnsn" ]; then dnsj="" for t in $dnsn; do u="zm_tun${t%%:*}"; d="${t#*:}" dnsj="$dnsj${dnsj:+,}\"$u\":{\"servers\":[\"${u}_cf\",\"${u}_gg\",\"zm_dir_ya\"],\"mode\":\"failover\"}," dnsj="$dnsj\"${u}_cf\":{\"url\":\"https://cloudflare-dns.com/dns-query\",\"ips\":[\"1.1.1.1\",\"1.0.0.1\"],\"out\":\"$d\"}," dnsj="$dnsj\"${u}_gg\":{\"url\":\"https://dns.google/dns-query\",\"ips\":[\"8.8.8.8\",\"8.8.4.4\"],\"out\":\"$d\"}" done dnsj="\"dns\":{\"upstreams\":{$dnsj,\"zm_dir_ya\":{\"url\":\"https://common.dot.dns.yandex.net/dns-query\",\"ips\":[\"77.88.8.8\",\"77.88.8.1\"]}}}," fi printf '{"version":2,%s"lan":{"devices":[%s]},%s"lists":{%s},"outputs":{%s},"rules":[%s]}\n' "$dnsj" "${lan:-\"br-lan\"}" "$clients" "$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 if grep -q '"zm_tun' "$new" && ( ZM_STL_NODNS=1; _stl_spec "$1" ) > "$new" && err="$(_stl_check "$new")"; then _stl_say "Ядро steer не умеет DNS через туннель — правила применены без него. Обновите ядро steer, чтобы адреса сервисов узнавались через туннель" else rm -f "$new" echo "ОШИБКА: ядро steer отвергло настройку" printf '%s\n' "$err" | tail -n 5 return 1 fi 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 m o="$(_stl_out "$1")" STL_UP=false; STL_FAILED=false; STL_DEV=""; STL_PSTATE=""; STL_PNODE=""; STL_PTOTAL=""; STL_NODE=""; STL_NWHY="" STL_KIND=""; STL_SEL=""; STL_PROTO=""; STL_PIDX="" stl_present || return 1 if [ -n "$ZM_STL_CACHE" ] && [ -n "$STL_ST_J" ]; then j="$STL_ST_J"; else j="$(_stl_t 10 steer status 2>/dev/null)"; fi case "$j" in '{'*) ;; *) return 1 ;; esac eval "$(printf '%s' "$j" | jsonfilter -e "STL_KIND=@.outputs.$o.kind" -e "STL_SEL=@.outputs.$o.group.selected" \ -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" \ -e "STL_NWHY=@.outputs.$o.node_down.why" 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 if [ "$1" = vpn ]; then m="$o" [ "$STL_KIND" = group ] && printf '%s' "$STL_SEL" | grep -qE "^${STL_VPN_PFX}_[a-z0-9]+\$" && m="$STL_SEL" local n1="" n2="" n3="" i1="" i2="" w2="" eval "$(printf '%s' "$j" | jsonfilter -e "STL_PROTO=@.outputs.$m.kind" -e "n1=@.outputs.$m.vless.node" -e "n2=@.outputs.$m.proxy.node" \ -e "n3=@.outputs.$m.hysteria2.node" -e "i1=@.outputs.$m.vless.active[0].index" -e "i2=@.outputs.$m.proxy.active[0].index" \ -e "w2=@.outputs.$m.node_down.why" 2>/dev/null)" STL_NODE="${n1:-${n2:-$n3}}"; STL_PIDX="${i1:-$i2}" [ -z "$STL_NWHY" ] && [ "$m" != "$o" ] && STL_NWHY="$w2" [ "$STL_KIND" = group ] && [ "$STL_FAILED" = true ] && [ -z "$STL_PSTATE" ] && { STL_PSTATE=failed; STL_PTOTAL=0; } fi [ "$STL_UP" = true ] && STL_NWHY="" 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 case "$STL_PIDX" in *[!0-9]*) STL_PIDX="" ;; esac return 0 } 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_run() { local t="" x="$STL_ETC/.zm-explain.$$.json" if [ -s "$STL_SPEC" ] && grep -q '"scope":"device"' "$STL_SPEC" 2>/dev/null; then sed 's/"scope":"device"/"scope":"device","enabled":false/g' "$STL_SPEC" > "$x" 2>/dev/null && t="$(_stl_t 20 steer explain --spec "$x" "$1" 2>&1)" rm -f "$x" case "$t" in *' -> '*) printf '%s\n' "$t"; return 0 ;; esac fi _stl_t 20 steer explain "$1" 2>&1 } 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_explain_run "$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 ;; zm_vpn|zm_vpn[2-9]|zmv_*|zmv[2-9]_*) STL_X_VERDICT=vpn ;; zm_if[2-9]) STL_X_VERDICT=iface ;; "$ST_DIRECT_OUT") STL_X_VERDICT=direct ;; *) 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 elif printf '%s' "$t" | grep -q 'резолвер ответил, но адреса не дал'; then STL_X_VERDICT=noaddr elif printf '%s' "$t" | grep -q 'резолвер steer не ответил'; then STL_X_VERDICT=nodns elif printf '%s' "$t" | grep -q 'нет ни одного правила по доменам'; then STL_X_VERDICT=nodomains; fi } ZM_ROUTE_PROBES='www static cdn api m' 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_VPN_PROTOS="vless hysteria2 trojan shadowsocks socks http vmess" STL_VPN_DEVS="zm_vpn zmv_vless zmv_hy2 zmv_trojan zmv_ss zmv_socks zmv_http zmv_vmess" STL_LINK_RE='^(vless|vmess|trojan|ss|socks|socks4|socks4a|socks5|http|https|hysteria2|hy2)://' _stl_mod_of() { case "$1" in vless) echo vless ;; hysteria2) echo hysteria2 ;; *) echo proxy ;; esac; } _stl_member() { case "$1" in hysteria2) echo "${2:-$STL_VPN_PFX}_hy2" ;; shadowsocks) echo "${2:-$STL_VPN_PFX}_ss" ;; *) echo "${2:-$STL_VPN_PFX}_$1" ;; esac; } stl_sub_norm() { rm -f "$2" "$2.map" LC_ALL=C awk -v OUT="$2" -v MAP="$2.map" ' BEGIN { A = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; for (i = 1; i <= 64; i++) B64[substr(A, i, 1)] = i - 1 } function b64d(s, o, ch, i, n, v, w, x, y, k) { gsub(/[ \t\r\n=]/, "", s); gsub(/-/, "+", s); gsub(/_/, "/", s) if (s ~ /[^A-Za-z0-9+\/]/) return "" o = ""; ch = ""; k = 0; n = length(s) for (i = 1; i + 3 <= n; i += 4) { v = B64[substr(s, i, 1)] * 262144 + B64[substr(s, i + 1, 1)] * 4096 + B64[substr(s, i + 2, 1)] * 64 + B64[substr(s, i + 3, 1)] w = int(v / 65536); x = int(v / 256) % 256; y = v % 256 ch = ch sprintf("%c%c%c", w, x, y) if (++k >= 128) { o = o ch; ch = ""; k = 0 } } if (n - i == 2) { v = B64[substr(s, i, 1)] * 262144 + B64[substr(s, i + 1, 1)] * 4096 + B64[substr(s, i + 2, 1)] * 64; ch = ch sprintf("%c%c", int(v / 65536), int(v / 256) % 256) } else if (n - i == 1) { v = B64[substr(s, i, 1)] * 262144 + B64[substr(s, i + 1, 1)] * 4096; ch = ch sprintf("%c", int(v / 65536)) } return o ch } function hexv(h) { return index("0123456789abcdef", tolower(h)) - 1 } function frag(s, o, i, n, a, b, v) { o = ""; n = length(s) for (i = 1; i <= n; i++) { if (substr(s, i, 1) == "%" && i + 2 <= n) { a = hexv(substr(s, i + 1, 1)); b = hexv(substr(s, i + 2, 1)) if (a >= 8 && b >= 0) { v = a * 16 + b; o = o sprintf("%c", v); i += 2; continue } } o = o substr(s, i, 1) } return o } function fam(sc) { sc = tolower(sc) if (sc == "vless") return "vless" if (sc == "hysteria2" || sc == "hy2") return "hysteria2" if (sc == "trojan") return "trojan" if (sc == "ss") return "shadowsocks" if (sc ~ /^socks/) return "socks" if (sc == "http" || sc == "https") return "http" if (sc == "vmess") return "vmess" return "" } function jfield(j, k, r) { if (match(j, "\"" k "\"[ \t]*:[ \t]*\"[^\"]*\"")) { r = substr(j, RSTART, RLENGTH); sub(/^"[^"]*"[ \t]*:[ \t]*"/, "", r); sub(/"$/, "", r); return r } if (match(j, "\"" k "\"[ \t]*:[ \t]*[0-9]+")) { r = substr(j, RSTART, RLENGTH); sub(/^.*:[ \t]*/, "", r); return r } return "" } function hostport(l, f, r, j, t) { H = ""; P = "" r = l; sub(/^[A-Za-z0-9]+:\/\//, "", r) if (f == "vmess") { sub(/#.*/, "", r); j = b64d(r); H = jfield(j, "add"); P = jfield(j, "port"); return } if (f == "shadowsocks") { t = r; sub(/[?#].*$/, "", t); if (index(t, "@") == 0) { j = b64d(t); if (index(j, "@")) { sub(/^.*@/, "", j); r = j } } } sub(/[\/?#].*$/, "", r) sub(/^.*@/, "", r) if (r ~ /^\[/) { H = r; sub(/\].*$/, "", H); sub(/^\[/, "", H); P = r; sub(/^.*\]:?/, "", P); return } H = r; sub(/:.*$/, "", H); P = r; if (index(P, ":")) sub(/^[^:]*:/, "", P); else P = "" } { buf = buf $0 "\n" } END { gsub(/\r/, "", buf) t = buf; sub(/^[ \t\n]+/, "", t) if (t ~ /^[\{\[]/) { printf "%s", buf > OUT; printf "" > MAP; exit 0 } if (!index(buf, "://")) { d = b64d(buf); if (index(d, "://")) buf = d } gsub(/\r/, "", buf) n = split(buf, L, "\n"); ln = 0 for (i = 1; i <= n; i++) { l = L[i]; gsub(/^[ \t]+|[ \t]+$/, "", l) if (l == "") continue if (match(l, /^[A-Za-z0-9]+:\/\//)) { sc = substr(l, 1, RLENGTH - 3) h = index(l, "#") if (h) l = substr(l, 1, h) frag(substr(l, h + 1)) gsub(/[\t]/, " ", l) print l > OUT; ln++ f = fam(sc) if (f != "") { hostport(l, f); printf "%d\t%s\t%s\t%s\n", ln, f, tolower(H), P > MAP } } else { print l > OUT; ln++ } } printf "" > MAP } ' "$1" 2>/dev/null [ -s "$2" ] } stl_sub_mods() { local m="vpn" if head -c 256 "$1" 2>/dev/null | tr -d ' \t\r\n' | grep -q '^[[{]'; then grep -qiE '"(protocol|type)"[[:space:]]*:[[:space:]]*"(hysteria2?|hy2)"' "$1" && m="$m hy2" grep -qiE '"(protocol|type)"[[:space:]]*:[[:space:]]*"(trojan|shadowsocks|socks|http|vmess)"' "$1" && m="$m proxy" else grep -qiE '^(hysteria2|hy2)://' "$1" && m="$m hy2" grep -qiE '^(trojan|ss|socks4a?|socks5?|https?|vmess)://' "$1" && m="$m proxy" fi echo "$m" } stl_sub_nodes() { local m { for m in vless hysteria2 proxy; do [ -x "$STL_SBIN/steer-$m" ] || continue printf '@%s\t' "$m"; _stl_t 30 steer "$m-nodes" "$1" 2>/dev/null | tr '\n\t' ' '; echo done; } | LC_ALL=C awk -v MAP="$1.map" -v META="$3" ' function junesc(s, o, i, n, c, d) { o = ""; n = length(s) for (i = 1; i <= n; i++) { c = substr(s, i, 1) if (c == "\\" && i < n) { d = substr(s, ++i, 1) if (d == "u") { i += 4; o = o " " } else if (d == "n" || d == "t" || d == "r" || d == "b" || d == "f") o = o " " else o = o d } else o = o c } gsub(/\t/, " ", o) return o } function sfield(ch, k, r) { if (match(ch, "\"" k "\":\"([^\"\\\\]|\\\\.)*\"")) { r = substr(ch, RSTART + length(k) + 4, RLENGTH - length(k) - 5); return junesc(r) } return "" } function nfield(ch, k, r) { if (match(ch, "\"" k "\":-?[0-9]+")) { r = substr(ch, RSTART + length(k) + 3, RLENGTH - length(k) - 3); return r + 0 } return -1 } function bfield(ch, k) { return (index(ch, "\"" k "\":true") ? 1 : 0) } BEGIN { FS = "\t" if (MAP != "") while ((getline l < MAP) > 0) { split(l, a, "\t"); key = a[2] "|" a[3] "|" a[4]; Q[key] = Q[key] (Q[key] == "" ? "" : " ") a[1] } seq = 0; usable = 0; skipped = 0; reasons = "" } { mod = substr($1, 2); j = $0; sub(/^[^\t]*\t/, "", j) if (match(j, /"usable":[0-9]+/)) usable += substr(j, RSTART + 9, RLENGTH - 9) if (match(j, /"skipped":[0-9]+/)) skipped += substr(j, RSTART + 10, RLENGTH - 10) r = j while (match(r, /"reason":"([^"\\]|\\.)*"/)) { x = substr(r, RSTART + 10, RLENGTH - 11); r = substr(r, RSTART + RLENGTH) if (!(x in RS_)) { RS_[x] = 1; reasons = reasons (reasons == "" ? "" : ",") "{\"reason\":\"" x "\"}" } } p = index(j, "\"nodes\":["); if (!p) next j = substr(j, p + 9) q = index(j, "],\"skipped_reasons\""); if (q) j = substr(j, 1, q - 1) n = split(j, C, /\{"index":/) for (i = 2; i <= n; i++) { ch = "{\"index\":" C[i] fidx = nfield(ch, "index"); name = sfield(ch, "name"); host = tolower(sfield(ch, "host")); port = nfield(ch, "port") ty = sfield(ch, "type"); sec = sfield(ch, "security"); vis = bfield(ch, "vision") if (mod == "vless") { proto = "vless"; tr = ty } else if (mod == "hysteria2") { proto = "hysteria2"; tr = "quic" } else { proto = ty; tr = sfield(ch, "transport") } if (proto == "") continue pidx = (mod == "proxy") ? PC[proto]++ : fidx key = proto "|" host "|" port; pos = "" if (Q[key] != "") { pos = Q[key]; sub(/ .*/, "", pos); if (index(Q[key], " ")) sub(/^[^ ]* /, "", Q[key]); else Q[key] = "" } if (pos == "") pos = 100000 + seq seq++ if (name == "") name = host ":" port printf "%d\t%s\t%d\t%d\t%s\t%s\t%s\t%d\n", pos, proto, pidx, fidx, name, tr, sec, vis } } END { if (META != "") printf "usable=%d\nskipped=%d\nreasons=[%s]\n", usable, skipped, reasons > META } ' | sort -n -k1,1 | LC_ALL=C awk -F'\t' 'BEGIN { OFS = "\t" } { k = $5; c = ++D[k]; if (c > 1) k = k " (" c ")"; while (k in U) { c++; k = $5 " (" c ")" } U[k] = 1 print NR - 1, $2, $3, $4, k, $6, $7, $8 }' > "$2" } stl_sub_check() { local t="$STL_TMP/subcheck.$$" mkdir -p "$STL_TMP" stl_sub_nodes "$1" "$t" "$t.meta" STL_SUB_USABLE="$(grep -c . "$t" 2>/dev/null)" STL_SUB_WHY="$(sed -n 's/^reasons=//p' "$t.meta" 2>/dev/null | grep -o '"reason":"[^"]*"' | sed 's/^"reason":"//; s/"$//' | head -n 3)" STL_SUB_PROTOS="$(cut -f2 "$t" 2>/dev/null | awk '!s[$0]++' | tr '\n' ' ')" rm -f "$t" "$t.meta" [ "${STL_SUB_USABLE:-0}" -gt 0 ] 2>/dev/null } stl_probe() { local j j="$(_stl_t 40 steer "$2-probe" "$1" --node "$3" --timeout 8 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() { if _st_warp_ext; then [ "$(uci -q get "network.$ST_WARP_IF.proto")" = amneziawg ]; else _st_owns "net $ST_WARP_IF" && [ -s "$ST_WARP_CONF" ]; fi } _st_warp_own() { case "$ST_MODE_RAW" in own|iface:?*) return 0 ;; esac; return 1; } _st_warp_ownconf() { [ "$ST_MODE_RAW" = own ]; } _st_warp_ext() { case "$ST_MODE_RAW" in iface:?*) return 0 ;; esac; return 1; } _st_ext_if() { case "$ST_MODE_RAW" in iface:?*) printf '%s\n' "${ST_MODE_RAW#iface:}" ;; esac; } _st_uses_if() { [ -n "$1" ] && _st_installed || return 1; { _st_warp_ext && [ "$(_st_ext_if)" = "$1" ]; } || _st_sec_has_if "$1"; } _st_sec_has_if() { local s; for s in $(_st_secs); do [ "$(_st_sec_kind "$s")" = iface ] && [ "$(_st_sec_iface "$s")" = "$1" ] && return 0; done; return 1; } _st_warp_modename() { if _st_warp_ext; then echo iface; elif _st_warp_ownconf; then echo own; else echo auto; fi; } _st_wnet() { _st_owns "net $1" || { _st_warp_ext && [ "$1" = "$ST_WIF1" ]; }; } _st_wpeer() { if [ -n "$(uci -q get "network.${1}_peer")" ]; then echo "${1}_peer"; else _awg_peer_sec "$1"; fi; } # Секция выключена отдельно: её сервисы и устройства идут напрямую, остальные секции работают _st_sec_off() { case "${1:-main}" in main) [ -f "$ST_SEC_DIR/main.off" ] && [ -s "$ST_SEC_LIST" ] ;; *) [ -f "$ST_SEC_DIR/$1/off" ] ;; esac } _st_sel() { _st_sec_off "$ST_SEC" && return 0; _st_sel_raw; } _st_sel_raw() { [ -s "$ST_SEL" ] || return 0; local id; for id in $(cat "$ST_SEL"); do _rb_routable "$id" || continue; [ "$id" = custom ] && [ ! -s "$ST_CUSTOM.lst" ] && [ ! -s "$ST_CUSTOM.pfx" ] && continue; echo "$id"; done; } ST_DEVS="$ST_DIR/devices" ST_DIRECT_OUT="zm_direct" _st_dev_key() { local k k="$(printf '%s' "$1" | tr 'A-F' 'a-f' | tr -d ' \t\r\n')" case "$k" in [0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]:[0-9a-f][0-9a-f]) [ "$k" = 00:00:00:00:00:00 ] && return 1; printf '%s' "$k" ;; *.*.*.*) printf '%s' "$k" | grep -qE '^(25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])){3}$' || return 1; printf '%s' "$k" ;; *) return 1 ;; esac } _st_devs_all() { _st_sec_off "$ST_SEC" && return 1; grep -q "^all " "$ST_DEVS" 2>/dev/null; } _st_devs_n() { local n; n="$(grep -c "^$1 " "$ST_DEVS" 2>/dev/null)"; echo "${n:-0}"; } _st_work() { [ -n "$(_st_sel)" ] || _st_devs_all || { [ -z "$ST_SEC" ] && _st_secs_work; }; } _st_work_main() { [ -n "$(_st_sel)" ] || _st_devs_all; } ST_EXCL_LIST="zm_excl" _st_excl_model() { [ -s "$ST_DIR/user/exclude.lst" ] && printf 'xdom\t%s\n' "$ST_DIR/user/exclude.lst" [ -s "$ST_DIR/user/exclude.pfx" ] && printf 'xpfx\t%s\n' "$ST_DIR/user/exclude.pfx" return 0 } do_steer_excl() { local rc nd np _st_phase rules rm -f "$ST_STOP_FLAG" nd="$(cat "$ST_DIR/user/exclude.lst" 2>/dev/null | grep -c .)"; np="$(cat "$ST_DIR/user/exclude.pfx" 2>/dev/null | grep -c .)" _rb_say "Исключения: доменов $nd, IP и подсетей $np" if _st_owns "steer-spec" && _st_work; then if _st_use_vpn; then _st_spec_quick; else _st_spec_quick_warp; fi rc=$? if [ "$rc" = 0 ]; then _rb_say "Готово: исключения применены, списки сервисов не перекачивались"; return 0; fi [ "$rc" = 1 ] && return 1 fi do_steer_apply } _st_dev_model() { [ -s "$ST_DEVS" ] || return 0 awk -F'\t' '($1 == "all" || $1 == "byp") && $2 != "" && !s[$2]++ { printf "dev\t%s\t%s\t%s\n", $1, $2, $3 }' "$ST_DEVS" } _st_devs_json() { [ -s "$ST_DEVS" ] || return 0 awk -F'\t' -v q='"' 'function j(v, o, i, c) { o = ""; for (i = 1; i <= length(v); i++) { c = substr(v, i, 1); if (c == "\\" || c == q) o = o "\\" c; else if (c ~ /[\t\r\n]/) o = o " "; else o = o c } return o } ($1 == "all" || $1 == "byp" || $1 == "only") && $2 != "" { printf "%s{\"mode\":\"%s\",\"key\":\"%s\",\"name\":\"%s\"}", (n++ ? "," : ""), $1, j($2), j($3) }' "$ST_DEVS" } steer_devices() { local lan t="$ST_RUN/devs.$$" mkdir -p "$ST_RUN" lan=" $(_zm_lan_devs) " { [ -f /tmp/dhcp.leases ] && awk '{ print "L", tolower($2), $3, $4 }' /tmp/dhcp.leases uci -q -X show dhcp 2>/dev/null | awk -F= ' /^dhcp\.[^.]*=host$/ { split($1, a, "."); h[a[2]] = 1; next } /^dhcp\.[^.]*\.(mac|ip|name)=/ { split($1, a, "."); v = $2; gsub(/\x27/, "", v); x[a[2], a[3]] = v } END { for (k in h) if (x[k, "mac"] != "") { split(x[k, "mac"], m, " "); print "S", tolower(m[1]), (x[k, "ip"] == "" ? "-" : x[k, "ip"]), (x[k, "name"] == "" ? "*" : x[k, "name"]) } }' [ -f /proc/net/arp ] && awk -v lan="$lan" 'NR > 1 && $3 != "0x0" && index(lan, " " $6 " ") { print "A", tolower($4), $1, "*" }' /proc/net/arp } > "$t" 2>/dev/null awk -v q='"' 'function j(v, o, i, c) { o = ""; for (i = 1; i <= length(v); i++) { c = substr(v, i, 1); if (c == "\\" || c == q) o = o "\\" c; else if (c ~ /[\t\r\n]/) o = o " "; else o = o c } return o } $2 !~ /^[0-9a-f][0-9a-f](:[0-9a-f][0-9a-f]){5}$/ || $2 == "00:00:00:00:00:00" { next } $3 != "-" && $3 !~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ { $3 = "-" } { m = $2; if (!(m in seen)) { seen[m] = 1; ord[++n] = m } if ($3 != "-" && (ip[m] == "" || $1 == "L")) ip[m] = $3 if ($4 != "*" && $4 != "" && (nm[m] == "" || $1 == "S")) nm[m] = $4 } END { printf "{\"devices\":["; for (i = 1; i <= n; i++) { m = ord[i]; printf "%s{\"mac\":\"%s\",\"ip\":\"%s\",\"name\":\"%s\"}", (i > 1 ? "," : ""), m, ip[m], j(nm[m]) }; printf "]}\n" }' "$t" rm -f "$t" } _st_devs_save() { local t="$ST_DEVS.tmp" m k n line mkdir -p "$ST_VDIR" : > "$t" printf '%s\n' "$1" | tr -d '\r' | while IFS= read -r line; do [ -n "$line" ] || continue m="${line%%|*}"; line="${line#*|}"; k="${line%%|*}"; n="${line#*|}"; [ "$n" = "$line" ] && n="" if [ -n "$ST_SEC" ]; then case "$m" in all|only) ;; *) continue ;; esac else case "$m" in all|byp) ;; *) continue ;; esac; fi k="$(_st_dev_key "$k")" || continue n="$(printf '%s' "$n" | tr -d '\t"\\' | cut -c1-48)" grep -q " $k " "$t" 2>/dev/null && continue printf '%s\t%s\t%s\t\n' "$m" "$k" "$n" >> "$t" done sed -i 's/\t$//' "$t" if [ -s "$t" ]; then mv -f "$t" "$ST_DEVS"; else rm -f "$t" "$ST_DEVS"; fi return 0 } do_steer_devs() { local rc _st_phase rules rm -f "$ST_STOP_FLAG" if [ -n "$ST_SEC" ]; then _rb_say "Секция «$(_st_sec_name "$ST_SEC")», устройства: весь трафик через секцию — $(_st_devs_n all), правила секции только для — $(_st_devs_n only)" else _rb_say "Устройства: весь трафик через туннель — $(_st_devs_n all), мимо Steer — $(_st_devs_n byp)"; fi if _st_owns "steer-spec" && _st_work; then if _st_use_vpn; then _st_spec_quick; else _st_spec_quick_warp; fi rc=$? if [ "$rc" = 0 ]; then _rb_say "Готово: правила устройств применены, списки сервисов не перекачивались"; return 0; fi [ "$rc" = 1 ] && return 1 fi do_steer_apply } mkdir -p "$ST_RUN" 2>/dev/null _st_splify2_pkg() { grep -qx 'P:luci-app-splify2' /lib/apk/db/installed 2>/dev/null || [ -f /usr/lib/opkg/info/luci-app-splify2.control ]; } _st_splify2() { [ -x /usr/libexec/rpcd/splify2 ] || return 1 _st_splify2_pkg || [ -f /www/luci-static/resources/view/splify2/home.js ] } _st_blocker() { local b if _st_splify2 && [ "$(stl_spec_whose)" != zm ]; 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" [ -z "$feat" ] && [ -s "$ST_SUB" ] && feat="$(_st_sub_feats)" if stl_v2 && [ "$cur" = "$want" ] && _st_feats_ok $feat; 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" elif stl_v2; then _rb_say "Ставим модули ядра steer для протоколов подписки"; 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_sub_feats() { if [ -s "$ST_SUB" ]; then stl_sub_mods "$ST_SUB"; else echo vpn; fi; } _st_feats_ok() { local f; for f; do stl_has "$f" || return 1; done; 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_MANUAL="/etc/zm-awg/kmod-manual" _awg_kmod_pin_bad() { local out AWG_KPIN=""; AWG_KMV="" if [ "$PKG" = apk ]; then out="$(apk add --simulate --allow-untrusted "$1" 2>&1)" && return 1 AWG_KPIN="$(printf '%s\n' "$out" | sed -n 's/.*kmod-amneziawg-[^[]*\[kernel=\([^]]*\)\].*/\1/p' | head -n1)" AWG_KMV="$(printf '%s\n' "$out" | sed -n 's/.*kmod-amneziawg-\([^[ ]*\)\[kernel=.*/\1/p' | head -n1)" else out="$(opkg install --noaction "$1" 2>&1)" AWG_KPIN="$(printf '%s\n' "$out" | sed -n 's/.*\*[[:space:]]*kernel (= *\([^)]*\)).*/\1/p' | head -n1)" fi [ -n "$AWG_KPIN" ] } _awg_kmod_have() { _pkg_is_installed kmod-amneziawg && return 0 [ -s "$AWG_KMOD_MANUAL" ] && [ -f "/lib/modules/$(uname -r)/amneziawg.ko" ] } _awg_kmod_ver() { if [ -s "$AWG_KMOD_MANUAL" ] && [ -f "/lib/modules/$(uname -r)/amneziawg.ko" ]; then awk '{ print $1; exit }' "$AWG_KMOD_MANUAL" else _awg_pkg_ver kmod-amneziawg fi } _awg_kmod_manual_rm() { [ -f "$AWG_KMOD_MANUAL" ] || return 0 rm -f "/lib/modules/$(uname -r)/amneziawg.ko" "$AWG_KMOD_MANUAL" } _awg_kmod_manual() { local f="$1" d="$ST_RUN/kx" ko dir kv cur kv="$(uname -r)"; cur="$(_awg_pkg_ver kernel)" _rb_warn "Ядро прошивки ($cur) собрано не так, как в официальной OpenWrt (модулю нужно $AWG_KPIN) — пакетный менеджер не даёт поставить модуль" _rb_say "Ставим модуль ядра в обход этой проверки" if [ "$PKG" != apk ]; then $INSTALL --force-depends "$f" return fi rm -rf "$d"; mkdir -p "$d" if ! apk extract --allow-untrusted --destination "$d" "$f" >/dev/null 2>&1; then echo " ✗ apk не смог распаковать пакет модуля" rm -rf "$d"; return 1 fi ko="$(find "$d/lib/modules" -name amneziawg.ko 2>/dev/null | head -n1)" if [ -z "$ko" ]; then echo " ✗ в пакете нет amneziawg.ko" rm -rf "$d"; return 1 fi dir="${ko#"$d"/lib/modules/}"; dir="${dir%%/*}" if [ "$dir" != "$kv" ]; then echo " ✗ модуль собран для ядра $dir, а у роутера ядро $kv" rm -rf "$d"; return 2 fi if ! mkdir -p "/lib/modules/$kv" || ! cp "$ko" "/lib/modules/$kv/amneziawg.ko"; then echo " ✗ не удалось записать модуль в /lib/modules/$kv — мало места на флеше?" rm -f "/lib/modules/$kv/amneziawg.ko"; rm -rf "$d"; return 1 fi rm -rf "$d" mkdir -p "${AWG_KMOD_MANUAL%/*}" echo "${AWG_KMV:-?} $kv" > "$AWG_KMOD_MANUAL" _pkg_is_installed kmod-amneziawg || _zm_run 120 apk add --force-non-repository --virtual kmod-amneziawg >/dev/null 2>&1 || { echo " ✗ apk не принял отметку о модуле — amneziawg-tools не встанет"; return 1; } echo " ✓ Модуль положен в /lib/modules/$kv" return 0 } _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="" why="" mrc _st_awg_loaded && was_loaded=1 kver0="$(_awg_kmod_ver)" _awg_kmod_have || need=" kmod-amneziawg" [ "$mode" = update ] && need=" kmod-amneziawg" if [ "$mode" = update ] || ! _pkg_is_installed amneziawg-tools || ! command -v awg >/dev/null 2>&1; then need="$need amneziawg-tools"; fi 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 [ "$p" = kmod-amneziawg ] && _awg_kmod_pin_bad "$f"; then _awg_kmod_manual "$f"; mrc=$? if [ "$mrc" = 0 ]; then [ "${ST_AWG_OWN:-1}" = 1 ] && _st_own "pkg $p" else ok=0 [ "$mrc" = 2 ] && why=kver [ "$why" = kver ] || why=pin fi elif $INSTALL $force "$f"; then [ "$p" = kmod-amneziawg ] && rm -f "$AWG_KMOD_MANUAL" [ "${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 [ -n "$why" ] && break _rb_warn "Здесь нет всех пакетов — пробуем другой источник" done _rb_rpcd_ensure if [ "$ok" != 1 ]; then case "$why" in kver) echo "ОШИБКА: готовый модуль AmneziaWG собран под ядро официальной OpenWrt $rel, а у роутера ядро $(uname -r)" echo "!! Прошивка не официальная (${tgt}/${sub}). Поставьте официальную OpenWrt $rel или прошивку со встроенным AmneziaWG" ;; pin) echo "ОШИБКА: ядро прошивки ($(_awg_pkg_ver kernel)) отличается от официальной OpenWrt $rel — модуль AmneziaWG поставить не удалось" echo "!! Поставьте официальную OpenWrt $rel (${tgt}/${sub}) или прошивку со встроенным AmneziaWG" ;; *) echo "ОШИБКА: для OpenWrt $rel ($arch, ${tgt}/${sub}) нет готовых пакетов AmneziaWG" ;; esac return 1 fi fi kver1="$(_awg_kmod_ver)" 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 want="$({ for i in $(_st_wifs_every); do { _st_owns "net $i" || _st_wifs_all | grep -qx "$i"; } && echo "$i"; done; _st_ext_zoneless && echo "$ST_WIF1"; _st_secs_zoneless; } | awk '!x[$0]++' | 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" } ST_EXT_FWD="zmwarp_ext" _st_secs_zoneless() { local i z; for i in $(_st_secs_ifaces); do z="$(_zm_zone_of_net "$i")"; { [ -z "$z" ] || [ "$z" = "$ST_WARP_ZONE" ]; } && echo "$i"; done; } _st_fwd_has() { local f for f in $(uci -q -X show firewall | sed -n "s/^firewall\.\([^.=]*\)=forwarding\$/\1/p"); do [ "$f" = "$3" ] && continue [ "$(uci -q get "firewall.$f.src")" = "$1" ] && [ "$(uci -q get "firewall.$f.dest")" = "$2" ] && return 0 done return 1 } _st_secs_fw() { local i z lz s r keep="" ch=0 zl="" lz="$(_zm_lan_zone)" for i in $(_st_secs_ifaces); do z="$(_zm_zone_of_net "$i")" if [ -z "$z" ] || [ "$z" = "$ST_WARP_ZONE" ]; then zl=1; continue; fi s="$(_st_zone_sec "$z")" if [ "$z" = "$lz" ]; then [ -n "$s" ] && [ "$(uci -q get "firewall.$s.masq")" != 1 ] && _rb_warn "$i стоит в зоне локальной сети «$z» без маскарадинга (NAT) — сайты через него не откроются. Уберите $i из этой зоны (Сеть → Межсетевой экран)" continue fi r="zmsec_$i" if ! _st_fwd_has "$lz" "$z" "$r"; then keep="$keep $r" if [ "$(uci -q get "firewall.$r.dest")" != "$z" ] || [ "$(uci -q get "firewall.$r.src")" != "$lz" ]; then uci -q delete "firewall.$r" uci set "firewall.$r=forwarding" uci set "firewall.$r.src=$lz" uci set "firewall.$r.dest=$z" _st_own "fw $r" ch=1 _rb_say "Разрешаем устройствам сети ходить в зону «$z» интерфейса $i" fi fi [ -n "$s" ] && [ "$(uci -q get "firewall.$s.masq")" != 1 ] && _rb_warn "В зоне «$z» выключен маскарадинг (NAT) — без него сайты через $i не откроются. Включите его в Сеть → Межсетевой экран" done for r in $(sed -n 's/^fw \(zmsec_[A-Za-z0-9_]*\)$/\1/p' "$ST_OWNED" 2>/dev/null); do case " $keep " in *" $r "*) continue ;; esac uci -q delete "firewall.$r" sed -i "\\|^fw $r\$|d" "$ST_OWNED" 2>/dev/null ch=1 done [ "$ch" = 1 ] && { uci commit firewall; /etc/init.d/firewall reload >/dev/null 2>&1; } if [ -n "$zl" ] || [ "$(uci -q get "firewall.$ST_WARP_ZONE")" = zone ]; then _st_warp_zone; fi return 0 } _st_ext_zoneless() { local z _st_warp_ext || return 1 z="$(_zm_zone_of_net "$ST_WIF1")" [ -z "$z" ] || [ "$z" = "$ST_WARP_ZONE" ] } _st_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; } _st_ext_fwd() { local i="$ST_WIF1" z s lz f has="" _st_warp_ext || return 0 z="$(_zm_zone_of_net "$i")" if [ -z "$z" ] || [ "$z" = "$ST_WARP_ZONE" ]; then _st_ext_release_fwd; return 0; fi lz="$(_zm_lan_zone)" s="$(_st_zone_sec "$z")" if [ "$z" = "$lz" ]; then _st_ext_release_fwd [ -n "$s" ] && [ "$(uci -q get "firewall.$s.masq")" != 1 ] && _rb_warn "$i стоит в зоне локальной сети «$z» без маскарадинга (NAT) — сайты через него не откроются. Уберите $i из этой зоны (Сеть → Межсетевой экран), Steer сам добавит его в свою зону" return 0 fi for f in $(uci -q -X show firewall | sed -n "s/^firewall\.\([^.=]*\)=forwarding\$/\1/p"); do [ "$f" = "$ST_EXT_FWD" ] && continue [ "$(uci -q get "firewall.$f.src")" = "$lz" ] && [ "$(uci -q get "firewall.$f.dest")" = "$z" ] && has=1 done if [ -n "$has" ]; then _st_ext_release_fwd elif [ "$(uci -q get "firewall.$ST_EXT_FWD.dest")" != "$z" ] || [ "$(uci -q get "firewall.$ST_EXT_FWD.src")" != "$lz" ]; then uci -q delete "firewall.$ST_EXT_FWD" uci set "firewall.$ST_EXT_FWD=forwarding" uci set "firewall.$ST_EXT_FWD.src=$lz" uci set "firewall.$ST_EXT_FWD.dest=$z" uci commit firewall _st_own "fw $ST_EXT_FWD" /etc/init.d/firewall reload >/dev/null 2>&1 _rb_say "Разрешаем устройствам сети ходить в зону «$z» интерфейса $i" fi [ -n "$s" ] && [ "$(uci -q get "firewall.$s.masq")" != 1 ] && _rb_warn "В зоне «$z» выключен маскарадинг (NAT) — без него сайты через $i не откроются. Включите его в Сеть → Межсетевой экран" return 0 } _st_ext_release_fwd() { [ "$(uci -q get "firewall.$ST_EXT_FWD")" = forwarding ] && _st_owns "fw $ST_EXT_FWD" || return 0 uci -q delete "firewall.$ST_EXT_FWD" uci commit firewall sed -i "\|^fw $ST_EXT_FWD\$|d" "$ST_OWNED" 2>/dev/null /etc/init.d/firewall reload >/dev/null 2>&1 return 0 } _st_ext_leave() { local i _st_warp_ext || return 0 i="$(_st_ext_if)" _st_ext_release_fwd _rb_say "Интерфейс $i больше не используется Steer — он остаётся на вкладке AmneziaWG как был" } _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; [ "$2" = RU ] && return 0; return 1; } _st_if_ru() { local g _st_warp_is_ru "$2" && return 0 g="$(awk -v i="$1" -v c="$2" '$1 == i && ($2 == c || c == "" || c == "?") { print $4; exit }' "$ST_WARP_GEO" 2>/dev/null)" [ "$g" = RU ] } _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_PAR=8 ST_WARP_PRE_WAIT=6 _st_warp_pre_clean() { local i=1; while [ "$i" -le "$ST_WARP_PAR" ]; do ip link del "zmws$i" >/dev/null 2>&1; i=$((i + 1)); done; } _st_warp_pre1() { local dev="$1" ep="$2" key="$3" addr="$4" i1="$5" out="$6" conf="$6.conf" i=0 hs ip link del "$dev" >/dev/null 2>&1 ip link add dev "$dev" type amneziawg >/dev/null 2>&1 || { echo fail > "$out"; return 0; } ip addr add "$addr" dev "$dev" >/dev/null 2>&1 ip link set "$dev" up >/dev/null 2>&1 ( umask 077; printf '%s\n' "[Interface]" "PrivateKey = $key" \ "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 = $AWG_WARP_PEER" "AllowedIPs = 0.0.0.0/0" "Endpoint = $ep" "PersistentKeepalive = 5" > "$conf" ) if ! awg setconf "$dev" "$conf" >/dev/null 2>&1; then if [ -z "$i1" ] || ! sed -i '/^I1 = /d' "$conf" || ! awg setconf "$dev" "$conf" >/dev/null 2>&1; then rm -f "$conf"; ip link del "$dev" >/dev/null 2>&1; echo fail > "$out"; return 0 fi fi rm -f "$conf" while [ "$i" -lt $((ST_WARP_PRE_WAIT * 4)) ]; do hs=$(awg show "$dev" latest-handshakes 2>/dev/null | awk '{ print $2; exit }') [ "${hs:-0}" -gt 0 ] 2>/dev/null && { echo ok > "$out"; break; } i=$((i + 1)) sleep 0.25 2>/dev/null || sleep 1 done [ -s "$out" ] || echo no > "$out" ip link del "$dev" >/dev/null 2>&1 return 0 } _st_warp_prescreen() { local dev="$1" key addr i1 d="$ST_RUN/pre.$$" k=0 n=0 ep fail=0 shift command -v awg >/dev/null 2>&1 || return 2 key="$(awg show "$dev" private-key 2>/dev/null)" [ -n "$key" ] || return 2 addr="$(uci -q get "network.$dev.addresses" | tr ' ' '\n' | grep -v ':' | head -n1)" [ -n "$addr" ] || addr="$AWG_BOOT_IP/32" i1="$(uci -q get "network.$dev.awg_i1")" rm -rf "$d"; mkdir -p "$d" for ep; do _st_stopped && break k=$((k + 1)); n=$((n + 1)) _st_warp_pre1 "zmws$n" "$ep" "$key" "$addr" "$i1" "$d/$k" & [ "$n" -ge "$ST_WARP_PAR" ] && { wait; n=0; } done wait k=0 for ep; do k=$((k + 1)) case "$(cat "$d/$k" 2>/dev/null)" in ok) echo "$ep" ;; fail|'') fail=$((fail + 1)) ;; esac done rm -rf "$d" _st_warp_pre_clean [ "$k" -gt 0 ] && [ "$fail" -ge "$k" ] && return 2 return 0 } _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 local ST_WARP_HS_WAIT="$ST_WARP_HS_WAIT" eps="" got rc [ "$4" = ext ] && { list="$ST_WARP_PORTS_EXT"; ST_WARP_HS_WAIT=3; } for p in $list; do eps="$eps $3:$p"; done got="$(_st_warp_prescreen "$1" $eps)"; rc=$? if [ "$rc" = 0 ]; then for p in $(printf '%s\n' $got | sed 's/.*://'); do ok="${ok:+$ok }$p" [ "$(echo "$ok" | wc -w)" -ge "$ST_WARP_MAX" ] && break done fi if [ "$rc" = 2 ]; then 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 fi [ -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 ccc 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 eps="" alive p0="${ports%% *}" for ip in $cand; do case "$busyip" in *" $ip "*) ;; *) eps="$eps $ip:$p0" ;; esac; done if [ -n "$eps" ] && alive="$(_st_warp_prescreen "$dev" $eps)"; then alive="$(printf '%s\n' $alive | sed 's/:[0-9]*$//')" [ -n "$alive" ] && echo " рукопожатие разом: ответили $(echo $alive | wc -w) из $(echo $eps | wc -w) точек" >&2 cand="$(printf '%s\n' $alive $cand | awk 'NF && !s[$0]++')" fi 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=""; ccc="" if meta="$(_cf_meta "$dev")"; then set -- $meta; colo="$1"; seen="$2"; ccc="$3"; 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" "$ccc"; 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.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 [ -s "$r.ru" ] && ! [ -s "$r" ] && ! [ -s "$r.same" ] && echo " ответили только российские узлы ($(awk '{ print $5 }' "$r.ru" | sort -u | tr '\n' ' ' | sed 's/ $//')) — их не берём: заблокированное через них не открывается" >&2 for f in "$r" "$r.same" "$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 ;; *.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_PICK="$ST_DIR/warp.pick" ST_WARP_ORDER="$ST_DIR/warp.order" _st_warp_pick() { [ "$(cat "$ST_WARP_PICK" 2>/dev/null)" = order ] && echo order || echo latency; } _st_warp_use() { [ -s "$ST_WARP_UP" ] || return 0 local i c good="" all="" while read -r i c; do [ -n "$i" ] || continue all="$all$i " _st_if_ru "$i" "$c" || good="$good$i " done < "$ST_WARP_UP" printf '%s' "${good:-$all}" } _st_warp_reorder() { [ -s "$ST_WARP_UP" ] || return 0 awk '{ print $1, $2 }' "$ST_WARP_UP" > "$ST_WARP_UP.tmp" _st_warp_order "$ST_WARP_UP.tmp" mv -f "$ST_WARP_UP.tmp" "$ST_WARP_UP" awk '{ printf "%s%s", (NR > 1 ? ", " : ""), $2 }' "$ST_WARP_UP" > "$ST_DIR/warp.colo" _st_tgws_warp } _st_colo_guard() { local f="$ST_RUN/colo.last" i c n changed=0 moved="" t _st_installed || return 0 [ -f "$ST_OFF" ] && return 0 [ "$(_st_exit)" = warp ] || return 0 _st_warp_own && return 0 [ -s "$ST_WARP_UP" ] || return 0 _st_running && return 0 [ -n "$(_st_blocker)" ] && return 0 mkdir -p "$ST_RUN" if [ -f "$f" ]; then t="$(date -r "$f" +%s 2>/dev/null || echo 0)" [ $(( $(date +%s) - t )) -lt 300 ] && return 0 fi : > "$f" : > "$ST_RUN/warp.up.chk" while read -r i c; do [ -n "$i" ] || continue n="$c" if _st_warp_alive_if "$i"; then n="$(_st_colo_of "$i")"; [ -n "$n" ] || n="$c"; fi if [ "$n" != "$c" ]; then changed=1 _st_warp_is_ru "$n" && ! _st_warp_is_ru "$c" && moved="$moved $i:$n" fi echo "$i $n" >> "$ST_RUN/warp.up.chk" done < "$ST_WARP_UP" if [ "$changed" != 1 ]; then rm -f "$ST_RUN/warp.up.chk" for i in $(awk '{ print $1 }' "$ST_WARP_UP"); do _st_warp_use | grep -qx "$i" && continue grep -q "\"$i\"" "$STL_SPEC" 2>/dev/null && { job_start steer do_steer_warp_pick keep >/dev/null; break; } done return 0 fi _st_running && { rm -f "$ST_RUN/warp.up.chk"; return 0; } _st_warp_use > "$ST_RUN/warp.up.was" mv -f "$ST_RUN/warp.up.chk" "$ST_WARP_UP" _st_warp_reorder _st_warp_geo $(awk '{ print $1 }' "$ST_WARP_UP") for t in $moved; do i="${t%%:*}"; case "$i" in zmwarp) n=1 ;; *) n="${i#zmwarp}" ;; esac _st_wfix_say "WARP $n переключился на российский узел ${t##*:} — выведен из работы, трафик идёт через остальные туннели" done if [ "$(_st_warp_use | tr '\n' ' ')" != "$(tr '\n' ' ' < "$ST_RUN/warp.up.was")" ]; then rm -f "$ST_RUN/warp.up.was" job_start steer do_steer_warp_pick keep >/dev/null else rm -f "$ST_RUN/warp.up.was" fi return 0 } do_steer_warp_pick() { _st_phase rules rm -f "$ST_STOP_FLAG" if [ "$1" != keep ]; then mkdir -p "$ST_DIR" if [ "$1" = order ]; then echo order > "$ST_WARP_PICK" printf '%s\n' $2 > "$ST_WARP_ORDER" _rb_say "Основной туннель WARP — первый живой по вашему порядку" else rm -f "$ST_WARP_PICK" _rb_say "Основной туннель WARP — самый быстрый из живых" fi _st_warp_reorder else _rb_say "Состав туннелей WARP изменился — обновляем правила" fi set -- $(_st_warp_use) [ $# -gt 0 ] && echo " → в работе: $(echo "$*" | sed 's/zmwarp\([0-9]\)/WARP \1/g; s/zmwarp/WARP 1/g; s/ /, /g')" _st_respec _rb_say "Готово" } _st_warp_order() { local f="$1" i c k pos=0 fresh=0 cls 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 if [ "$(_st_warp_pick)" = order ]; then k=$(awk -v i="$i" '$1 == i { print NR; exit }' "$ST_WARP_ORDER" 2>/dev/null) [ -n "$k" ] || k=$((900 + pos)) fi cls=0; _st_if_ru "$i" "$c" && cls=1 printf '%d %012d %03d %s %s\n' "$cls" "$k" "$pos" "$i" "$c" >> "$f.key" done < "$f" sort "$f.key" | awk '{ print $4, $5 }' > "$f" rm -f "$f.key" } _st_own_conf_ok() { local kv="$ST_RUN/own.chk.$$" ep rc=0 mkdir -p "$ST_RUN" _awg_conf_kv "$1" > "$kv" ep="$(_awg_cv "$kv" peer endpoint)" [ -n "$(_awg_cv "$kv" interface privatekey)" ] && [ -n "$(_awg_cv "$kv" peer publickey)" ] && [ -n "$(_awg_cv "$kv" interface address)" ] || rc=1 case "$ep" in \[?*\]:?*|?*:?*) ;; *) rc=1 ;; esac rm -f "$kv" return $rc } _st_warp_own_iface() { local f="$1" kv="$ST_RUN/own.kv" priv pub psk addr a ep host port keep mtu 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 ext="" p _st_warp_ext && ext=1 _st_install_awg || return 1 _st_awg_loaded || modprobe amneziawg >/dev/null 2>&1 if [ -n "$ext" ]; then [ "$(uci -q get "network.$i.proto")" = amneziawg ] || { echo "ОШИБКА: интерфейса $i больше нет — выберите другой в «Подключении» Steer или вернитесь к автоматическому WARP"; return 1; } [ "$(uci -q get "network.$i.auto")" = 0 ] && _rb_say "Интерфейс $i был выключен — включаем" p="$(_st_wpeer "$i")" [ -n "$p" ] && [ "$(uci -q get "network.$p.route_allowed_ips")" = 1 ] && _rb_warn "У $i включено «Весь трафик» — через него идёт весь интернет роутера, а не только сервисы Steer. Выключить можно на вкладке AmneziaWG" _st_ext_fwd elif [ "$(uci -q get "network.$i.proto")" != amneziawg ]; then _st_own_restore [ -s "$ST_WARP_CONF" ] || { echo "ОШИБКА: своего конфига WARP нет — вставьте его в «Подключении» Steer"; 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 "$([ -n "$ext" ] && echo "Поднимаем интерфейс $i" || echo "Поднимаем свой туннель WARP")" if _st_warp_alive_if "$i" || ! _st_warp_ready_if "$i"; then ubus call "network.interface.$i" up >/dev/null 2>&1; else ifup "$i" >/dev/null 2>&1; fi 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" if [ -n "$ext" ]; then echo "ОШИБКА: интерфейс $i не отвечает — почините его на вкладке AmneziaWG («Точка входа», «Новый WARP») или включите там автопочинку" else echo "ОШИБКА: сервер из вашего конфига не отвечает — проверьте ключи и Endpoint" fi 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" p="$(_st_wpeer "$i")" _rb_say "$([ -n "$ext" ] && echo "Интерфейс $i работает" || echo "Свой WARP работает"): $(uci -q get "network.$p.endpoint_host"):$(uci -q get "network.$p.endpoint_port")${col:+, колония $col}" col="$(_st_geo_line)" [ -n "$col" ] && _rb_say "Сайты видят $([ -n "$ext" ] && echo "$i" || echo 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="" 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 в разных колониях" mkdir -p "$ST_RUN" n=2 while [ "$n" -le "$ST_WARP_N" ]; do rm -f "$ST_RUN/reg.$n.log" "$ST_RUN/reg.$n.rc" if ! _st_with "$n" _st_warp_field PrivateKey 2>/dev/null | grep -q .; then ( ZM_REG_N="$n"; _st_with "$n" _st_warp_register > "$ST_RUN/reg.$n.log" 2>&1; echo $? > "$ST_RUN/reg.$n.rc" ) & fi n=$((n + 1)) done n=1 while [ "$n" -le "$ST_WARP_N" ]; do if [ "$n" -gt 1 ]; then wait; [ -f "$ST_RUN/reg.$n.log" ] && cat "$ST_RUN/reg.$n.log"; fi if { [ -f "$ST_RUN/reg.$n.rc" ] && [ "$(cat "$ST_RUN/reg.$n.rc")" = 0 ]; } || { [ ! -f "$ST_RUN/reg.$n.rc" ] && _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 ] && { wait; 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 if _st_warp_is_ru "$3"; then _rb_warn "WARP $n: точка $1:$2 ведёт в российский узел $3 — такой туннель не используем" ifdown "$i" >/dev/null 2>&1 continue fi busy="$busy $3"; busyip="$busyip $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 не поднялся ни через одну зарубежную точку входа. Российские узлы (Домодедово и другие) Steer не использует — заблокированное через них не открывается. Попробуйте «Найти новую точку входа» позже или VPN-подписку" 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" 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 itdoginfo_meta " 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[2] !~ /^[0-9]+$/) 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 (tolower(id " " f[e, "source"]) ~ /b4geoip|itdoginfo/) fo[e] = 1 if (index(skip, " " tolower(id) " ") && !fo[e]) { bad[e] = 1; continue } if (f[e, "source_name"] == "" && f[e, "source"] == "" && !fo[e]) { 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/ || (e !~ /^domain_lists/ && f[e, "kind"] != "" && f[e, "kind"] != "domains")) ? "s" : "d") > idx; sets++ } } for (j = 1; j <= cnt; j++) { e = order[j] if (e ~ /^categories/ || 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 == "" && fo[e]) grp = (tolower(f[e, "id"] " " f[e, "source"]) ~ /b4geoip/) ? "b4geoip" : "itdoginfo (allow-domains)" if (grp == "") return if (tolower(f[e, "id"] " " f[e, "source"]) ~ /itdoginfo/) { 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 if [ "$1" = force ]; then t=0 while [ "$t" -lt 10 ] && [ -d "$lk" ]; do sleep 1; t=$((t + 1)); done 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 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) - $(awk '{ print $1 + 0; exit }' "$ST_DIR/catalog.err" 2>/dev/null | grep . || 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 if ! _st_fetch "$(_st_srs_url "$set")" "$f"; then rm -f "$f" [ -s "$d/$set.dom" ] || [ -s "$d/$set.pfx" ] || return 1 _rb_warn "Набор $set не скачался — беру прошлую копию" >&2 touch "$ST_RUN/srs.$set.done" return 0 fi 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" if [ ! -s "$d/$set.dom.tmp" ] && [ ! -s "$d/$set.pfx.tmp" ]; then rm -f "$f" "$d/$set".*.tmp [ -s "$d/$set.dom" ] || [ -s "$d/$set.pfx" ] || return 1 _rb_warn "Набор $set скачался пустым — беру прошлую копию" >&2 touch "$ST_RUN/srs.$set.done" return 0 fi 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_CUSTOM.lst" 2>/dev/null | grep -c .), подсетей $(cat "$ST_CUSTOM.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 [ -n "$sets" ]; then for f in $(_rb_svc_field "$id" 3 | tr ',' ' '); do case "$f" in *_extra.lst) f="$(_st_pkg_lists "$f")"; [ -n "$f" ] && dom="$dom $f" ;; esac done fi 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_CUSTOM.lst" ] && dom="$ST_CUSTOM.lst" [ "$id" = custom ] && [ -s "$ST_CUSTOM.pfx" ] && pfx="$ST_CUSTOM.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_head() { local id s k m=none printf 'lan'; for id in $(_st_lan_devs); do printf '\t%s' "$id"; done; echo if _st_sec_off main || { ! _st_work_main && _st_secs_work; }; then m=none elif _st_use_vpn; then m=vpn; elif _st_warp_on || ! _st_secs_work; then m=warp; fi printf 'main\t%s\n' "$m" if [ "$m" = vpn ]; then printf 'vpn\t%s\n' "$ST_SUB" _st_sub_filter fi if [ "$m" = warp ] || { _st_warp_on && _st_secs_warp; }; then printf 'wpick\t%s\n' "$(_st_warp_pick)" printf 'warp' if [ -s "$ST_WARP_UP" ]; then for id in $(_st_warp_use); do printf '\t%s' "$id"; done; else printf '\t%s' "$ST_WARP_IF"; fi echo fi _st_dev_model for s in $(_st_secs); do _st_sec_live "$s" || continue k="$(_st_sec_kind "$s")" case "$k" in vpn) printf 'so\t%s\tvpn\t%s\n' "$s" "$ST_SEC_DIR/$s/sub.txt"; _st_sec_with "$s" _st_sub_filter | sed "s/^tun /stun $s /" ;; iface) printf 'so\t%s\tiface\t%s\n' "$s" "$(_st_sec_iface "$s")" ;; *) printf 'so\t%s\twarp\n' "$s" ;; esac [ -s "$ST_SEC_DIR/$s/devices" ] && awk -F'\t' -v s="$s" '($1 == "all" || $1 == "only") && $2 != "" && !x[$2]++ { printf "sdev\t%s\t%s\t%s\n", s, $1, $2 }' "$ST_SEC_DIR/$s/devices" done _st_excl_model } _st_model() { local id s rm -f "$ST_RUN"/used.* "$ST_RUN"/srs.*.done mkdir -p "$ST_RUN" _st_cust_split _st_model_head for id; do _st_svc_lists "$id" done for s in $(_st_secs); do _st_sec_live "$s" || continue rm -f "$ST_RUN"/used.* printf 'sec\t%s\n' "$s" for id in $(_st_sec_with "$s" _st_sel); do _st_sec_with "$s" _st_svc_lists "$id" done done } _st_spec_apply() { local m="$ST_RUN/model" { [ $# -gt 0 ] || _st_secs_work; } && _rb_say "Скачиваем списки выбранных сервисов" _st_model "$@" > "$m" if ! grep -q '^svc ' "$m"; then if [ $# -gt 0 ] || ! grep -qE '^(dev all|sdev [^ ]* all) ' "$m"; then rm -f "$m"; echo "ОШИБКА: для выбранных сервисов нет ни одного списка"; return 1; fi fi stl_apply "$m" || { rm -f "$m"; return 1; } grep -v '^\(lan\|main\|warp\|wpick\|vpn\|tun\|so\|stun\|sdev\|dev\|xdom\|xpfx\) ' "$m" > "$ST_DIR/model.svc" 2>/dev/null 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_sec_with main _st_kick0; } _st_kick0() { _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_devs_all || _st_secs_work; } && _st_spec_apply $sel; then return 0; fi stl_restart _rb_say "Steer перезапущен" } _st_dns_conflict() { return 1 } do_steer_dns_fix() { _doh_force_apply [ -f "$ST_OFF" ] && return 0 [ -z "$(_st_blocker)" ] || return 0 stl_enabled && stl_restart return 0 } _st_cron_restart() { local i stl_enabled || return 0 [ -z "$(_st_blocker)" ] || return 0 for i in $(awk '{print $1}' "$ST_WARP_UP" 2>/dev/null); do ifup "$i"; done sleep 15 stl_restart } ST_CRON_KEEP="$ST_DIR/autorestart" _st_cron_refresh() { local cur cur="$(_st_cron_get)" if [ -z "$cur" ]; then [ -s "$ST_CRON_KEEP" ] && _st_installed && _st_cron_set "$(cat "$ST_CRON_KEEP")" >/dev/null 2>&1 return 0 fi 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 min line=$(grep -F "$ST_CRON_TAG" "$CRON_FILE" 2>/dev/null | head -n1) [ -n "$line" ] || return 0 min=$(echo "$line" | awk '{print $1}') hour=$(echo "$line" | awk '{print $2}') case "$min" in */*) echo "everym:${min#*/}"; return 0 ;; esac case "$hour" in */*) echo "every:${hour#*/}" ;; *) case "$min" in ''|0|00|*[!0-9]*) echo "daily:$hour" ;; ?) echo "daily:$hour:0$min" ;; *) echo "daily:$hour:$min" ;; esac ;; esac } _st_cron_set() { local mode="${1%%:*}" value="${1#*:}" spec keep="$1" h m case "$mode" in off) spec="" ;; every) case "$value" in ''|*[!0-9]*|???*) echo '{"error":"введите интервал от 1 до 23 часов"}'; return 1 ;; esac value="${value#0}" [ "${value:-0}" -ge 1 ] && [ "$value" -le 23 ] || { echo '{"error":"введите интервал от 1 до 23 часов"}'; return 1; } spec="0 */$value * * *"; keep="every:$value" ;; everym) case "$value" in ''|*[!0-9]*|???*) echo '{"error":"введите интервал от 10 до 59 минут"}'; return 1 ;; esac [ "$value" -ge 10 ] && [ "$value" -le 59 ] || { echo '{"error":"введите интервал от 10 до 59 минут"}'; return 1; } spec="*/$value * * * *" ;; daily) h="${value%%:*}"; m=0 case "$value" in *:*) m="${value#*:}" ;; esac case "$h" in ''|*[!0-9]*|???*) echo '{"error":"введите время от 00:00 до 23:59"}'; return 1 ;; esac case "$m" in ''|*[!0-9]*|???*) echo '{"error":"введите время от 00:00 до 23:59"}'; return 1 ;; esac case "$h" in 0?) h="${h#0}" ;; esac case "$m" in 0?) m="${m#0}" ;; esac [ "$h" -le 23 ] && [ "$m" -le 59 ] || { echo '{"error":"введите время от 00:00 до 23:59"}'; return 1; } spec="$m $h * * *" if [ "$m" = 0 ]; then keep="daily:$h"; elif [ "$m" -lt 10 ]; then keep="daily:$h:0$m"; else keep="daily:$h:$m"; fi ;; *) 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" if [ -n "$spec" ] && [ -d "$ST_DIR" ]; then echo "$keep" > "$ST_CRON_KEEP"; else rm -f "$ST_CRON_KEEP"; fi /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() { _st_sec_with main _st_apply0 "$@"; } _st_secs_fit() { local have want s have="$(awk -F'\t' '$1 == "sec" { printf "%s ", $2 }' "$ST_DIR/model.svc" 2>/dev/null)" want="$(for s in $(_st_secs); do _st_sec_live "$s" && printf '%s ' "$s"; done)" [ "$have" = "$want" ] } _st_secs_say() { local s k n w _st_sec_off main && echo " · Секция «$(_st_sec_name main)» выключена — её сервисы идут напрямую" for s in $(_st_secs); do _st_sec_off "$s" && { echo " · Секция «$(_st_sec_name "$s")» выключена — её сервисы идут напрямую"; continue; } w="$(_st_sec_with "$s" _st_sel | tr '\n' ' ')" n="$(_st_sec_with "$s" _st_devs_n all)" [ -n "$(echo $w)" ] || [ "$n" -gt 0 ] || continue k="$(_st_sec_kind "$s")" w="$(_rb_svc_names "$w")"; [ "$n" -gt 0 ] && w="${w:+$w; }весь трафик устройств: $n" if ! _st_sec_live "$s"; then case "$k" in vpn) _rb_warn "Секция «$(_st_sec_name "$s")»: у неё нет подписки VPN — её сервисы пока идут напрямую" ;; iface) _rb_warn "Секция «$(_st_sec_name "$s")»: интерфейса $(_st_sec_iface "$s") нет — её сервисы пока идут напрямую" ;; *) _rb_warn "Секция «$(_st_sec_name "$s")»: WARP не подключён — её сервисы пока идут напрямую" ;; esac continue fi case "$k" in vpn) _rb_say "Секция «$(_st_sec_name "$s")» через VPN ($(_st_sec_with "$s" _st_sub_label)): $w" ;; iface) _rb_say "Секция «$(_st_sec_name "$s")» через $(_st_sec_iface "$s"): $w" ;; *) _rb_say "Секция «$(_st_sec_name "$s")» через WARP: $w" ;; esac done } _st_apply0() { 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)" ] && ! _st_devs_all && ! _st_secs_work; then _st_spec_clear _st_down _rb_say "Ничего не выбрано — туннель и Steer выключены" return 0 fi local ex mw="" ex="$(_st_exit)" _st_work_main && mw=1 case "$ex" in vpn) _st_vpn_zone on ;; warp) [ -z "$mw" ] && ! _st_secs_warp || [ "$1" = tunnel_ready ] || _st_warp_resume || _st_warp_up || return 1 ;; *) if ! _st_secs_work; then _st_spec_clear _st_down _rb_warn "Туннеля нет — подключите WARP или подписку VPN, и выбранные сервисы пойдут через него" return 0 fi [ -n "$mw" ] && _rb_warn "У основной секции нет туннеля — подключите WARP или подписку VPN, её сервисы пока идут напрямую" ;; esac if [ "$ex" = vpn ] && _st_warp_on && _st_secs_warp && [ "$1" != tunnel_ready ]; then _st_warp_resume || _st_warp_up || return 1; fi [ -n "$(_st_secs_vpn)" ] && _st_vpn_zone on _st_secs_fw local what dn what="$(_rb_svc_names "$sel")" dn="$(_st_devs_n all)" [ "$dn" -gt 0 ] && what="${what:+$what; }весь трафик устройств: $dn" if [ -n "$mw" ]; then if [ "$ex" = vpn ]; then _rb_say "$([ -n "$(_st_secs)" ] && printf 'Секция «%s» через VPN' "$(_st_sec_name main)" || echo "Через VPN") ($(_st_sub_label)): $what" elif [ "$ex" = warp ]; then _rb_say "$([ -n "$(_st_secs)" ] && printf 'Секция «%s» через WARP' "$(_st_sec_name main)" || echo "Через WARP"): $what"; fi fi _st_secs_say [ "$(_st_devs_n byp)" -gt 0 ] && echo " · Мимо Steer ходят устройства: $(_st_devs_n byp)" _st_engine_ready || return 1 _st_spec_apply $sel } _st_engine_ready() { local f f="$(_st_feats_all)" stl_v2 && { [ -z "$(echo $f)" ] || _st_feats_ok $f; } && return 0 _st_install_steer $f } _st_failopen() { stl_state "$(_st_exit)" && [ "$STL_FAILED" = true ]; } ST_HEAL_STAMP="$ST_DIR/heal.reinstall" _st_heal_wait() { local f="$1" i=0 while [ "$i" -lt 10 ]; do sleep 3; i=$((i + 1)) stl_running || continue stl_diag_lines > "$f" [ -s "$f" ] && ! grep -q '^fail' "$f" && return 0 done return 1 } ST_HEAL_CAUSE="$ST_DIR/heal.cause" _st_heal_cause() { awk -F'\t' '$1 == "fail" { print $2 }' "$1" | while IFS= read -r w; do _st_diag_node "$w" || printf '%s\n' "$w"; done | sort; } _st_heal() { local f="$1" ver c="$ST_RUN/heal.cause.now" mkdir -p "$ST_RUN" "$ST_DIR" _st_heal_cause "$f" > "$c" if [ -s "$ST_HEAL_CAUSE" ] && cmp -s "$c" "$ST_HEAL_CAUSE" && [ -z "$(find "$ST_HEAL_CAUSE" -mmin +360 2>/dev/null)" ]; then ST_HEAL_DID="эту же поломку панель уже пыталась исправить в $(date -r "$ST_HEAL_CAUSE" +%H:%M 2>/dev/null) — не помогло" echo " ! Эту же поломку ядра уже пробовали исправить в $(date -r "$ST_HEAL_CAUSE" +%H:%M 2>/dev/null) — не помогло, второй раз по кругу не чиним" rm -f "$c" return 1 fi ST_HEAL_DID="перезапуск ядра не помог" _rb_say "Чиним сами: перезапускаем ядро steer" if stl_restart && _st_heal_wait "$f"; then echo " ✓ После перезапуска ядра всё работает" rm -f "$c" "$ST_HEAL_CAUSE" return 0 fi mv -f "$c" "$ST_HEAL_CAUSE" if [ -f "$ST_HEAL_STAMP" ] && [ -z "$(find "$ST_HEAL_STAMP" -mmin +30 2>/dev/null)" ]; then echo " ! Перезапуск не помог, а ядро уже переустанавливали меньше получаса назад — второй раз не трогаем" stl_diag_lines > "$f" return 1 fi mkdir -p "$ST_DIR"; touch "$ST_HEAL_STAMP" ST_HEAL_DID="не помогли ни перезапуск, ни переустановка ядра" ver="$(stl_version)"; _stl_ver_ok "$ver" || ver="$(stl_latest)" _rb_say "Перезапуск не помог — переустанавливаем ядро steer $ver" if ! STL_REINSTALL=1 stl_engine_install "$ver" $(_st_use_vpn && _st_sub_feats); then echo " ✗ Переустановить ядро не удалось — проверьте интернет и нажмите «Применить» ещё раз" stl_running || stl_restart >/dev/null 2>&1 stl_diag_lines > "$f" return 1 fi stl_running || stl_restart >/dev/null 2>&1 if _st_heal_wait "$f"; then echo " ✓ После переустановки ядра всё работает" rm -f "$ST_HEAL_CAUSE" return 0 fi return 1 } _st_selfcheck() { _st_sec_with main _st_selfcheck0; } _st_sec_check1() { local s="$1" nm o rc tr ip nm="$(_st_sec_name "$s")" case "$(_st_sec_kind "$s")" in vpn) o="$(_st_sec_with "$s" _st_vpn_check 60)"; rc=$? printf '%s\n' "$o" | sed "s/^\[ OK \] VPN/[ OK ] Секция «$nm» · VPN/; s/^\[FAIL\] VPN/[FAIL] Секция «$nm» · VPN/" return $rc ;; iface) o="$(_st_sec_iface "$s")" tr="$(curl -s --interface "$o" --connect-timeout 5 --max-time 10 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null)" ip="$(printf '%s\n' "$tr" | sed -n 's/^ip=//p')" if [ -n "$ip" ]; then echo "[ OK ] Секция «$nm» · $o: трафик идёт (выход $ip)"; return 0; fi echo "[FAIL] Секция «$nm» · $o: трафик через интерфейс не идёт — почините его на вкладке AmneziaWG" return 1 ;; *) tr="$(curl -s --interface "$(_st_warp_first)" --connect-timeout 5 --max-time 10 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null)" case "$tr" in *ip=*) echo "[ OK ] Секция «$nm» · WARP: трафик идёт ($(printf '%s\n' "$tr" | sed -n 's/^colo=//p'))"; return 0 ;; esac echo "[FAIL] Секция «$nm» · WARP: трафик через туннель не идёт" return 1 ;; esac } _st_selfcheck0() { local f="$ST_RUN/diag.json" n i v what why bad=0 trace healed="" did="" vdead="" core="" moved="" s _rb_say "Проверяем, что всё работает" for s in $(_st_secs); do _st_sec_live "$s" && _st_sec_work "$s" && { _st_sec_check1 "$s" || bad=1; }; done if _st_work_main && _st_use_vpn; then if ! _st_vpn_check; then if [ -s "$ST_SUB_NODE" ] && _st_node_down; then vdead=1 if _st_node_failover; then vdead=""; moved=1; else bad=1; fi else bad=1 _st_node_down && vdead=1 fi fi stl_running && echo "[ OK ] Служба Steer запущена" || { echo "[FAIL] Служба Steer не запущена"; bad=1; } elif _st_work_main && _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')" local tn="Свой туннель"; _st_warp_ext && tn="Интерфейс $ST_WARP_IF" if [ -n "$dip" ] && [ "$tip" = "$dip" ]; then echo "[FAIL] $tn: выход $tip совпадает с IP провайдера — $(_st_warp_ext && echo "трафик идёт мимо него" || echo "конфиг не пускает трафик в интернет")"; bad=1 else echo "[ OK ] $tn: трафик идёт (выход $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 _st_work && _st_failopen; then if [ -z "$vdead" ]; then _rb_warn "Ядро steer считает туннель нерабочим — ждём, пока его сторож проверит туннель снова" i=0 while [ "$i" -lt 30 ] && _st_failopen; do sleep 2; i=$((i + 1)); done fi if _st_failopen; then if [ "$(_st_onfail)" = drop ]; then echo "[FAIL] Туннель: ядро steer не может пустить через него трафик — выбранные сервисы остановлены" else echo "[FAIL] Туннель: ядро steer не может пустить через него трафик — сервисы идут напрямую, через провайдера"; fi logread 2>/dev/null | grep -F 'steer[warn]' | tail -n 6 | sed 's/^[^]]*\]: / /' bad=1 else echo "[ OK ] Ядро steer снова ведёт трафик в туннель" fi else _st_work && echo "[ OK ] Ядро steer ведёт выбранные сервисы в туннель" fi stl_diag_lines > "$f" while IFS="$(printf '\t')" read -r v what why; do [ "$v" = fail ] && ! _st_diag_node "$what" && core=1 done < "$f" if [ -z "$core" ] && [ -z "$moved" ] && grep -q '^fail' "$f"; then echo " · Ядро steer исправно — не отвечает узел или сервер туннеля; перезапуск и переустановка ядра тут не помогут" fi if [ -n "$core" ] && [ -z "$ST_HEALING" ] && _st_work && [ ! -f "$ST_OFF" ]; then while IFS="$(printf '\t')" read -r v what why; do [ "$v" = fail ] && ! _st_diag_node "$what" && echo "!! $what" done < "$f" ST_HEALING=1 if _st_heal "$f"; then healed=1; fi ST_HEALING="" did="$ST_HEAL_DID" _rb_say "Проверяем ещё раз" fi while IFS="$(printf '\t')" read -r v what why; do case "$v" in ok) echo "[ OK ] $what" ;; warn) echo "!! $what${why:+ — $why}" ;; fail) [ -n "$moved" ] && _st_diag_node "$what" && continue case "$why" in *'init.d/steer'*|*ерезапустите*|*ереустановите*) [ -n "$did" ] && why="$did; пришлите журнал в чат поддержки" ;; esac echo "[FAIL] $what${why:+ — $why}"; bad=1 ;; esac done < "$f" rm -f "$f" [ -n "$healed" ] && [ "$bad" = 0 ] && echo " · Поломку ядра панель исправила сама" 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" rm -f "$ST_HEAL_STAMP" blk="$(_st_blocker)" _fk_installed && { echo "ОШИБКА: стоит $(_fk_name) — он сам направляет сервисы в туннель. Удалите $(_fk_name), чтобы поставить 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 ] && _st_work; 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" ] && _st_work; 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_ext_leave _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" ] && _st_work; then _st_phase rules _st_apply tunnel_ready || return 1 _st_phase check sleep 2 _st_selfcheck fi _rb_say "Готово, свой WARP подключён" } do_steer_warp_iface() { local i="$1" was _st_phase awg rm -f "$ST_STOP_FLAG" _st_installed || { echo "ОШИБКА: сначала установите Steer"; return 1; } [ -n "$(_st_blocker)" ] && { echo "ОШИБКА: ядро steer сейчас настраивает не Zapret Manager"; return 1; } [ "$(uci -q get "network.$i.proto")" = amneziawg ] || { echo "ОШИБКА: интерфейса $i нет — создайте его на вкладке AmneziaWG"; return 1; } _ensure_deps _st_install_awg || return 1 _st_phase tunnel _rb_say "Интерфейс AmneziaWG: сервисы Steer пойдут через $i" was="$(_st_warp_modename)" if [ "$was" = auto ]; 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 выключаем — они сохранятся, вернуться к ним можно в один клик" elif [ "$was" = own ]; then _st_owns "net $ST_OWN_IF" && _rb_say "Свой туннель $ST_OWN_IF выключаем — он сохранится, вернуться к нему можно в один клик" elif [ "$(_st_ext_if)" != "$i" ]; then _st_ext_leave fi _st_warp_park $(_st_wifs_auto) "$ST_OWN_IF" echo "iface:$i" > "$ST_WARP_MODE" _st_mode_vars rm -f "$ST_WARP_UP" "$ST_DIR/warp.colo" _st_awg_loaded || modprobe amneziawg >/dev/null 2>&1 _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" ] && _st_work; then _st_phase rules _st_apply tunnel_ready || return 1 _st_phase check sleep 2 _st_selfcheck fi _awf_ifaces | grep -qx "$i" && [ -z "$(_awf_min)" ] && _rb_say "Совет: включите автопочинку на вкладке AmneziaWG — сломается $i, роутер сам подберёт ему новую точку входа" _rb_say "Готово, сервисы идут через $i" } do_steer_ext_pick() { local i _st_phase warp rm -f "$ST_STOP_FLAG" _st_warp_ext || { echo "ОШИБКА: Steer сейчас работает не через интерфейс AmneziaWG"; return 1; } i="$(_st_ext_if)" do_awg_pick "$i" || return 1 _awg_steer_sync "$i" force _rb_say "Готово" } 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_warp_ownconf && _st_owns "net $ST_OWN_IF" && _rb_say "Свой туннель $ST_OWN_IF выключаем — он сохранится, вернуться к нему можно в один клик" _st_ext_leave rm -f "$ST_WARP_MODE" _st_mode_vars _st_warp_park "$ST_OWN_IF" _st_warp_zone 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" ] && _st_work; 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 — замените его в «Подключении» 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 if _st_warp_is_ru "$3"; then ifdown "$i" >/dev/null 2>&1 echo "ОШИБКА: WARP $n — нашлась только точка через российский узел $3, её не берём; попробуйте позже или новые ключи" return 1 fi _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_respec _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 _st_if_ru "$1" "$(awk -v i="$1" '$1 == i { print $2; exit }' "$ST_WARP_UP" 2>/dev/null)" && 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_RUN/wfix.last" "$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 _st_work || return 0 now="$(date +%s)" mkdir -p "$ST_RUN" echo "$now" > "$ST_RUN/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)" \ "$(awk '{ print $1 + 0; exit }' "$ST_RUN/wfix.last" 2>/dev/null | grep . || 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 ']}' } _st_spec_quick0() { local m="$ST_RUN/model" f [ -f "$ST_DIR/model.svc" ] && _st_owns "steer-spec" && stl_running || return 2 for f in $(awk -F'\t' '$1 == "srs" || $1 == "dom" || $1 == "pfx" { print $2 }' "$ST_DIR/model.svc"); do [ -s "$f" ] || return 2; done _st_secs_fit || return 2 if { ! _st_use_vpn && _st_warp_on; } || { _st_warp_on && _st_secs_warp; }; then [ -s "$ST_WARP_UP" ] || return 2; fi _st_engine_ready || return 1 _st_vpn_any && _st_vpn_zone on _st_secs_fw mkdir -p "$ST_RUN" { _st_model_head cat "$ST_DIR/model.svc" } > "$m" stl_apply "$m"; f=$? rm -f "$m" return $f } _st_spec_quick() { _st_sec_with main _st_spec_quick0; } _st_spec_quick_warp() { _st_sec_with main _st_spec_quick0; } _st_respec() { local rc if _st_use_vpn; then _st_spec_quick; else _st_spec_quick_warp; fi rc=$? [ "$rc" = 0 ] && { echo " ✓ Правила обновлены, списки сервисов не перекачивались"; return 0; } _st_kick } do_steer_dnsopt() { _st_phase rules rm -f "$ST_STOP_FLAG" _st_dnsopt_save "$1" "$2" case "$1:$2" in dnsmode:router) _rb_say "Адреса выбранных сервисов узнаёт DNS роутера" ;; dnsmode:*) _rb_say "Адреса выбранных сервисов узнаются через туннель — как при полном VPN" ;; realip:on) _rb_say "Точно по IP: роутер отдаёт настоящие адреса выбранных сервисов" ;; realip:*) _rb_say "Точно по IP выключено: роутер отдаёт подменные адреса" ;; esac _st_respec _rb_say "Готово" } do_steer_onfail() { _st_phase rules rm -f "$ST_STOP_FLAG" mkdir -p "$ST_DIR" if [ "$1" = drop ]; then echo drop > "$ST_DIR/onfail" _rb_say "Туннель недоступен — выбранные сервисы останавливаются, мимо туннеля не идут" else rm -f "$ST_DIR/onfail" _rb_say "Туннель недоступен — выбранные сервисы идут напрямую" fi _st_respec _rb_say "Готово" } _st_node_label() { local n; n="$(cat "$ST_SUB_NODE" 2>/dev/null)"; [ -n "$n" ] && printf '«%s»' "$n" || printf 'автовыбор'; } do_steer_switch() { local prev="${1:--}" rc why pol rm -f "$ST_STOP_FLAG" _st_installed || { echo "ОШИБКА: Steer ещё не установлен"; return 1; } [ -n "$(_st_blocker)" ] && { echo "ОШИБКА: настройку ядра steer сейчас ведёт не Zapret Manager"; return 1; } _st_phase rules _rb_say "Переключаем VPN: $(_st_node_label) — списки сервисов и правила не трогаем" _st_spec_quick; rc=$? if [ "$rc" = 2 ]; then _rb_say "Списки ещё не собирались — применяем настройку целиком" do_steer_apply; return $? fi [ "$rc" = 0 ] || return 1 _st_phase check if _st_vpn_check 30; then _rb_say "Готово"; return 0; fi why="$STL_NWHY" pol="$(_st_node_pol)" if [ "$pol" = hold ] && [ -s "$ST_SUB_NODE" ]; then _rb_warn "Узел $(_st_node_label) не отвечает${why:+ ($why)} — держим его, как задано в «Если выбранный узел не отвечает»: $(_st_fail_text), пока он не ответит" return 0 fi [ "$pol" = back ] && [ -s "$ST_SUB_NODE" ] && { mkdir -p "$ST_DIR"; head -n1 "$ST_SUB_NODE" > "$ST_SUB_HOME"; } case "$prev" in -) _rb_say "Готово, но VPN сейчас не отвечает — сторож Steer переключит на рабочий узел сам"; return 0 ;; auto) [ -s "$ST_SUB_NODE" ] || { _rb_say "Сторож Steer сам переберёт узлы"; return 0; } _rb_warn "Узел $(_st_node_label) не отвечает${why:+ ($why)} — возвращаем автовыбор" mkdir -p "$ST_DIR"; head -n1 "$ST_SUB_NODE" > "$ST_SUB_FELL"; rm -f "$ST_SUB_NODE" ;; *) [ "$prev" = "$(cat "$ST_SUB_NODE" 2>/dev/null)" ] && return 0 _rb_warn "Узел $(_st_node_label) не отвечает${why:+ ($why)} — возвращаем прежний «$prev»" mkdir -p "$ST_DIR"; head -n1 "$ST_SUB_NODE" > "$ST_SUB_FELL" if _st_key_in "$(_st_nodes)" "$prev"; then printf '%s\n' "$prev" > "$ST_SUB_NODE"; else rm -f "$ST_SUB_NODE"; fi ;; esac _st_spec_quick || return 1 if _st_vpn_check 30; then _rb_say "Готово: VPN снова работает через $(_st_node_label)$([ -s "$ST_SUB_HOME" ] && printf ' — на «%s» вернёмся, когда он ответит' "$(head -n1 "$ST_SUB_HOME")")" return 0 fi _rb_warn "И прежний выбор не отвечает — сторож Steer будет искать рабочий узел сам" return 0 } 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; } [ -n "$(_st_blocker)" ] && { echo "ОШИБКА: ядро steer сейчас настраивает не Zapret Manager"; 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 ещё не подключён — подключите его в «Подключении» Steer" return 1 } do_steer_warp_restart() { _st_phase warp _st_need_warp || return 1 rm -f "$ST_STOP_FLAG" _rb_say "Перезапускаем туннель WARP" _st_warp_up || return 1 _st_respec _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_respec _rb_say "Готово, точка входа подобрана" } do_steer_warp_recreate() { _st_phase warp _st_need_warp || return 1 _st_warp_own && { echo "ОШИБКА: в этом режиме ключи задаёт ваш конфиг или интерфейс AmneziaWG — новые ключи Steer не получает"; 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_respec _rb_say "Готово, WARP пересоздан" } do_steer_remove() { _st_phase remove rm -f "$ST_HEAL_STAMP" _rb_say "Удаляем Steer и туннель WARP" local foreign="" p pkgs="" rc=0 keepf="$JOBS_DIR/steer.keep.$$" keptif="" kept=0 alien="" ownpk="" case "$(_st_blocker)" in splify2) foreign=splify2 ;; steer) foreign="чужой настройке (правила в $STL_ETC писала не панель)" ;; esac [ -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 echo " ✓ Ядро steer остановлено, правила панели сняты" [ "$(stl_spec_whose)" = foreign ] && alien=1 fi local wi netrl=0 gone="" : > "$keepf" [ -n "$foreign" ] && _zm_engine_refs engine > "$keepf" 2>/dev/null for wi in $(_st_wifs_every); do _st_owns "net $wi" || continue if [ -n "$foreign" ] && grep -qsw -- "$wi" "$STL_SPEC" "$STL_YAML"; then keptif="$keptif $wi"; continue; fi ifdown "$wi" >/dev/null 2>&1 uci -q delete "network.$wi" uci -q delete "network.${wi}_peer" netrl=1 gone="$gone $wi" done [ "$netrl" = 1 ] && { uci commit network; /etc/init.d/network reload >/dev/null 2>&1; echo " ✓ Туннели удалены:$gone"; } [ -n "$keptif" ] && _rb_warn "Туннели$keptif оставлены — они записаны в правилах ядра steer, которые ведёт $foreign" _st_ext_release_fwd for p in $(sed -n 's/^fw \(zmsec_[A-Za-z0-9_]*\)$/\1/p' "$ST_OWNED" 2>/dev/null); do uci -q delete "firewall.$p"; uci commit firewall; done if [ -z "$keptif" ] && _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 echo " ✓ Зона файрвола туннеля убрана" 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 || grep -qF "$ST_PING_TAG" "$CRON_FILE" 2>/dev/null; then sed -i "\\|$ST_CRON_TAG|d; \\|# zm-subupd|d; \\|$ST_WFIX_TAG|d; \\|$ST_PING_TAG|d" "$CRON_FILE" /etc/init.d/cron restart >/dev/null 2>&1 echo " ✓ Задания расписания Steer убраны" fi if [ -n "$foreign" ] && grep -qsw -- "$STL_VPN_DEV" "$STL_SPEC" "$STL_YAML"; then :; else _st_vpn_zone off; fi for p in $(sed -n 's/^pkg \(steer[a-z0-9-]*\)$/\1/p' "$ST_OWNED" 2>/dev/null); do pkgs="$pkgs $p"; done if [ -z "$foreign" ] && [ -z "$alien" ]; then for p in $(stl_pkgs_installed); do case " $pkgs " in *" $p "*) ;; *) pkgs="$pkgs $p" ;; esac; done fi [ -n "$pkgs" ] && { stl_engine_remove $pkgs || rc=1; } _st_owns "pkg conntrack" && { _zm_pkg_purge conntrack || rc=1; } 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" && { _zm_pkg_purge "$p" || rc=1; } done _pkg_is_installed kmod-amneziawg || _awg_kmod_manual_rm fi _rb_rpcd_ensure [ "$rc" = 0 ] || ownpk="$(grep '^pkg ' "$ST_OWNED" 2>/dev/null)" if [ -n "$foreign" ] && grep -q "^$ST_DIR/" "$keepf" 2>/dev/null; then find "$ST_DIR" \( -type f -o -type l \) 2>/dev/null | while IFS= read -r p; do grep -qxF "$p" "$keepf" || rm -f "$p"; done find "$ST_DIR" -depth -type d -exec rmdir {} \; 2>/dev/null kept="$(find "$ST_DIR" -type f 2>/dev/null | grep -c .)" _zm_wipe "$ST_RUN" "$ST_TGWS_WARP" || rc=1 else _zm_wipe "$ST_DIR" "$ST_RUN" "$ST_TGWS_WARP" && echo " ✓ Файлы, настройки и подписки Steer удалены" || rc=1 fi rm -f "$keepf" [ -n "$foreign" ] || stl_leftovers if [ "$rc" != 0 ]; then [ -n "$ownpk" ] && { mkdir -p "$ST_DIR"; printf '%s\n' "$ownpk" > "$ST_OWNED"; } stl_present && _st_own "engine" fi _zm_after_remove "Steer" if [ "$rc" = 0 ] && [ -n "$alien" ] && stl_present; then _rb_warn "Ядро steer оставлено: в нём правила, которые писала не панель ($STL_ETC)" echo "==> Готово: Steer панели удалён. Ядро steer осталось на роутере — им пользуется чужая настройка" _zm_rb_note return 0 fi if [ "$rc" = 0 ] && [ -z "$foreign" ] && stl_present; then _rb_warn "Команда steer всё ещё есть на роутере: $(command -v steer). Она стоит не из пакета — панель её не удаляет" echo "==> Готово: Steer панели удалён, но само ядро steer осталось — см. строку выше" _zm_rb_note return 0 fi if [ "$rc" = 0 ] && [ -n "$foreign" ] && [ "${kept:-0}" = 0 ] && [ -z "$keptif" ]; then echo "==> Готово: Steer панели удалён. Ядро steer оставлено — оно нужно $foreign" _zm_rb_note return 0 fi if [ "$rc" = 0 ] && { [ "${kept:-0}" -gt 0 ] || [ -n "$keptif" ]; }; then [ "${kept:-0}" -gt 0 ] && _rb_warn "В $ST_DIR оставлено файлов: $kept — на них ссылаются правила ядра steer, которые ведёт $foreign. Без них ядро не запустится" echo "==> Готово: Steer панели удалён. Оставлено только то, чем пользуется $foreign" _zm_rb_note return 0 fi _zm_rm_done "$rc" "Steer" } steer_status() { _st_cust_split ZM_STL_CACHE=1; STL_ST_J="" stl_present && STL_ST_J="$(_stl_t 10 steer status 2>/dev/null)" 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="" id sel=" " skip w k r _st_running && running=true [ -f "$ST_PHASE_FILE" ] && phase=$(cat "$ST_PHASE_FILE") blk="$(_st_blocker)" _st_installed && installed=true [ -z "$blk" ] && [ "$installed" = false ] && _fk_installed && blk="$(_fk_foreign && echo netshift || echo 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_wnet "$ST_WARP_IF"; then k="$(_st_wpeer "$ST_WARP_IF")" host="$(uci -q get "network.$k.endpoint_host")" port="$(uci -q get "network.$k.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 ] && /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_installed || _fk_installed; } && _st_cat_bg [ -n "$(_st_wfix_min)" ] && _st_wfix_cron sel=" $(_st_sel_raw | tr '\n' ' ') " local cust=0 { [ -s "$ST_CUSTOM.lst" ] || [ -s "$ST_CUSTOM.pfx" ]; } && cust=1 svc="$(_rb_svc_src | awk -F'|' -v sel="$sel" -v skipf="$ST_SKIP" -v dcf="$ST_DOMCNT" -v idxf="$ST_CAT_IDX" -v cust="$cust" ' 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 } function hasdom( m, i, s) { if ($1 == "custom") return cust + 0 if ($1 in dc) return dc[$1] > 0 if ($3 != "" || $5 != "" || $6 != "") return 1 m = split($8, s, ",") for (i = 1; i <= m; i++) if (s[i] != "" && (!(s[i] in ik) || ik[s[i]] != "s")) return 1 return 0 } BEGIN { while ((getline l < skipf) > 0) sk[l] = 1 while ((getline l < dcf) > 0) { split(l, a, " "); if (a[1] != "") dc[a[1]] = a[2] + 0 } while ((getline l < idxf) > 0) { split(l, a, "|"); if (a[1] != "") ik[a[1]] = a[4] } } $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,\"dom\":%s}", (n++ ? "," : ""), $1, j($2), j($9), (index(sel, " " $1 " ") ? "true" : "false"), (($1 in sk) ? "true" : "false"), (hasdom() ? "true" : "false") }')" local vexit vup=false vsub=false latest="" ext=false won=false failed=false active="" wfail=false vexit="$(_st_exit)" _st_warp_on && won=true stl_v2 && { [ ! -s "$ST_SUB" ] || _st_feats_ok $(_st_sub_feats); } && 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 if [ "$run" = true ] && [ "$won" = true ] && _st_owns "steer-spec"; then if [ "$vexit" = warp ]; then wfail="$failed"; elif stl_state warp; then wfail="$STL_FAILED"; fi fi _st_vpn_dev >/dev/null && 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_iface":"%s","warp_pick":"%s","onfail":"%s","warp_own_saved":%s,"tunnels":%s,"warp_active":"%s","catalog":%s,"wfix":%s,"lists_via":%s,"failopen":%s,"warp_failed":%s,"hosts_extra":%s,"dnsmode":"%s","realip":"%s","devices":[%s],"services":[%s],"main_name":"%s","main_off":%s,"sec_max":%s,"sec_vpn_max":%s,"sections":[%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_modename)" "$(esc "$(_st_ext_if)")" "$(_st_warp_pick)" "$(_st_onfail)" "$(_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" "$wfail" "$(_hosts_extra)" "$(_st_dnsmode)" "$(_st_realip)" "$(_st_devs_json)" "$svc" "$(esc "$(_st_sec_name main)")" "$(_st_sec_off main && echo true || echo false)" "$ST_SEC_MAX" "$ST_SEC_VPN_MAX" "$(_st_secs_json)" } _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 pos isru pp while [ "$n" -le "$ST_WARP_N" ]; do i="$(_st_wif "$n")" if _st_wnet "$i"; then up=false; age=""; rx=0; tx=0 [ -d "/sys/class/net/$i" ] && up=true pp="$(_st_wpeer "$i")" host="$(uci -q get "network.$pp.endpoint_host")" port="$(uci -q get "network.$pp.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 pos="$(awk -v i="$i" '$1 == i { print NR; exit }' "$ST_WARP_UP" 2>/dev/null)" isru=false; [ -n "$colo" ] && _st_if_ru "$i" "$colo" && isru=true out="$out$sep{\"n\":$n,\"if\":\"$i\",\"pos\":${pos:-0},\"ru\":$isru,\"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_CUSTOM="$ST_USER_DIR/custom" _st_ufile() { if [ "$1" = custom ]; then printf '%s' "$ST_CUSTOM"; else printf '%s' "$ST_USER_DIR/$1"; fi; } _st_cust_split() { local s m="$ST_DIR/custom.split" [ -f "$m" ] && return 0 [ -d "$ST_DIR" ] || return 0 for s in $(_st_secs); do _rb_in custom "$ST_SEC_DIR/$s/services" || continue [ -s "$ST_SEC_DIR/$s/custom.lst" ] || [ -s "$ST_SEC_DIR/$s/custom.pfx" ] && continue [ -s "$ST_USER_DIR/custom.lst" ] && cp -f "$ST_USER_DIR/custom.lst" "$ST_SEC_DIR/$s/custom.lst" [ -s "$ST_USER_DIR/custom.pfx" ] && cp -f "$ST_USER_DIR/custom.pfx" "$ST_SEC_DIR/$s/custom.pfx" done : > "$m" } _st_list_effective() { local id="$1" set f any=0 if [ "$id" = exclude ]; then ST_LIST_SRC=user; cat "$ST_USER_DIR/$id.lst" "$ST_USER_DIR/$id.pfx" 2>/dev/null; return 0; fi f="$(_st_ufile "$id")" if [ -s "$f.lst" ] || [ -s "$f.pfx" ]; then ST_LIST_SRC=user; cat "$f.lst" "$f.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 } _st_user_list() { [ "$1" = exclude ] || { [ "$1" = custom ] && _rb_routable "$1"; }; } ST_DOMCNT="$ST_DIR/domcount" _st_dom_norm() { awk '{ l = tolower($0); sub(/[#;].*$/, "", l); gsub(/^[ \t\r]+|[ \t\r]+$/, "", l); sub(/[ \t].*$/, "", l) sub(/^(domain|full|suffix|domain_suffix):/, "", l); sub(/^=/, "", l); sub(/^\*\./, "", l); sub(/^\./, "", l) if (l ~ /^[a-z0-9]([a-z0-9_-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9_-]*[a-z0-9])?)+$/ && l !~ /^[0-9.]+$/) print l }' } _st_srs_dump() { local j="$2.json.$$" if command -v steer >/dev/null 2>&1; then steer srs-read "$1" --out "$2" --prefixes-out "$3" >/dev/null 2>&1 && return 0 fi command -v sing-box >/dev/null 2>&1 && command -v jsonfilter >/dev/null 2>&1 || return 1 sing-box rule-set decompile -o "$j" "$1" >/dev/null 2>&1 && [ -s "$j" ] || { rm -f "$j"; return 1; } jsonfilter -i "$j" -e '@.rules[*].domain[*]' -e '@.rules[*].domain_suffix[*]' -e '@.rules[*].rules[*].domain[*]' -e '@.rules[*].rules[*].domain_suffix[*]' 2>/dev/null > "$2" jsonfilter -i "$j" -e '@.rules[*].ip_cidr[*]' -e '@.rules[*].rules[*].ip_cidr[*]' 2>/dev/null > "$3" rm -f "$j" return 0 } _st_dom_build() { local id="$1" out="$2" src="$2.src.$$" pfx="$2.pfx.$$" dl=1 sets set f fmt x ok=1 ST_DOM_SRC=""; ST_DOM_MISS="" { _st_running || [ -n "$ST_DOM_NODL" ]; } && dl=0 mkdir -p "${out%/*}" "$ST_DIR/lists" "$ST_RUN/srsdump" : > "$src"; : > "$pfx" if [ "$id" = custom ]; then [ -s "$ST_CUSTOM.lst" ] && echo "$ST_CUSTOM.lst" >> "$src" [ -s "$ST_CUSTOM.pfx" ] && echo "$ST_CUSTOM.pfx" >> "$pfx" ST_DOM_SRC="ваши строки" else sets="$(_rb_svc_field "$id" 8 | tr ',' ' ')" for set in $sets; do fmt="$(awk -F'|' -v k="$set" '$1 == k { print $2; exit }' "$ST_CAT_IDX" 2>/dev/null)" if [ "$fmt" = lst ]; then f="$ST_DIR/lists/$set.dom" [ -s "$f" ] || [ -s "$ST_DIR/lists/$set.pfx" ] || { [ "$dl" = 1 ] && _st_srs_get "$set" >/dev/null 2>&1; } [ -s "$ST_DIR/lists/$set.pfx" ] && echo "$ST_DIR/lists/$set.pfx" >> "$pfx" if [ -s "$f" ]; then echo "$f" >> "$src" elif [ ! -s "$ST_DIR/lists/$set.pfx" ]; then ST_DOM_MISS="$ST_DOM_MISS $set"; ok=0; break; fi else f="$ST_DIR/lists/$set.srs" [ -s "$f" ] || { [ "$dl" = 1 ] && _st_srs_get "$set" >/dev/null 2>&1; } [ -s "$f" ] || ST_DOM_MISS="$ST_DOM_MISS $set" [ -s "$f" ] || { ok=0; break; } x="$ST_RUN/srsdump/$(printf '%s' "$f" | tr -c 'A-Za-z0-9_.-' '_')" if { [ ! -s "$x.dom" ] && [ ! -s "$x.pfx" ]; } || [ "$f" -nt "$x.dom" ]; then rm -f "$x.dom" "$x.pfx" _st_srs_dump "$f" "$x.dom" "$x.pfx" || rm -f "$x.dom" "$x.pfx" fi [ -f "$x.dom" ] || { ok=0; break; } [ -s "$x.dom" ] && echo "$x.dom" >> "$src" [ -s "$x.pfx" ] && echo "$x.pfx" >> "$pfx" fi done if [ -n "$sets" ] && [ "$ok" = 1 ]; then ST_DOM_SRC="каталог списков" else : > "$src"; : > "$pfx" for f in $(_rb_svc_field "$id" 4 | tr ',' ' '); do if [ "$dl" = 1 ]; then _rb_list_get "$f" >/dev/null 2>&1; fi [ -s "$ZM_LISTS_DIR/$f" ] && echo "$ZM_LISTS_DIR/$f" >> "$pfx" done for f in $(_rb_svc_field "$id" 3 | tr ',' ' '); do if [ "$dl" = 1 ]; then _rb_list_get "$f" >/dev/null 2>&1; fi [ -s "$ZM_LISTS_DIR/$f" ] && echo "$ZM_LISTS_DIR/$f" >> "$src" done if [ -s "$src" ]; then ST_DOM_SRC="списки сервиса" else printf '%s,%s\n' "$(_rb_svc_field "$id" 5)" "$(_rb_svc_field "$id" 6)" | tr ',' '\n' > "$out.base.$$" [ -s "$out.base.$$" ] && echo "$out.base.$$" >> "$src" ST_DOM_SRC="основные домены сервиса" fi fi fi while IFS= read -r f; do cat "$f"; echo; done < "$src" | _st_dom_norm | sort -u > "$out.$$" mv -f "$out.$$" "$out" mv -f "$pfx" "$out.pfxsrc" rm -f "$src" "$out.base.$$" printf '%s\n' "$ST_DOM_MISS" > "$out.miss" printf '%s\n' "$ST_DOM_SRC" > "$out.how" if [ -s "$out.pfxsrc" ]; then awk '{ l = tolower($0); sub(/[#;].*$/, "", l); gsub(/[ \t\r]/, "", l) if (l ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+((\/[0-9]+)|(-[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+))?$/ || (l ~ /^[0-9a-f:]+(\/[0-9]+)?$/ && index(l, ":"))) if (!s[l]++) print l }' \ $(cat "$out.pfxsrc") 2>/dev/null > "$out.ips" else : > "$out.ips" fi wc -l < "$out.ips" | tr -d ' ' > "$out.ipn" } steer_svc_domflags() { _rb_svc_src | awk -F'|' -v dcf="$ST_DOMCNT" -v idxf="$ST_CAT_IDX" ' BEGIN { while ((getline l < dcf) > 0) { split(l, a, " "); if (a[1] != "") dc[a[1]] = a[2] + 0 } while ((getline l < idxf) > 0) { split(l, a, "|"); if (a[1] != "") ik[a[1]] = a[4] } printf "{" } $1 == "" || $1 == "custom" || ($1 in seen) || $3 $4 $8 == "" || $1 !~ /^[A-Za-z0-9_.-]+$/ { next } { seen[$1] = 1; d = 0 if ($1 in dc) d = dc[$1] > 0 else if ($3 != "" || $5 != "" || $6 != "") d = 1 else { m = split($8, s, ","); for (i = 1; i <= m; i++) if (s[i] != "" && (!(s[i] in ik) || ik[s[i]] != "s")) d = 1 } printf "%s\"%s\":%s", (n++ ? "," : ""), $1, (d ? "true" : "false") } END { printf "}\n" }' } steer_svc_dom() { local id="${1%%:*}" r o l f n ipn k="" src r="${1#*:}"; o="${r%%:*}"; l="${r#*:}" case "$l" in *:*) k="${l#*:}"; l="${l%%:*}" ;; esac case "$id" in ''|*[!A-Za-z0-9_.-]*) echo '{"error":"нет такого сервиса"}'; return 1 ;; esac case "$o" in ''|*[!0-9]*) o=0 ;; esac case "$l" in ''|*[!0-9]*) l=500 ;; esac [ "$l" -gt 5000 ] && l=5000 [ "$l" -lt 1 ] && l=1 _rb_routable "$id" || { echo '{"error":"у этого пункта нет списка"}'; return 1; } f="$ST_RUN/dom/$id.txt" [ "$id" = custom ] && [ -n "$ST_SEC" ] && f="$ST_RUN/dom/custom.$ST_SEC.txt" if [ ! -f "$f" ] || [ ! -f "$f.how" ] || [ ! -f "$f.ipn" ] || [ ! -f "$f.ips" ] || [ -n "$(find "$f" -mmin +1 2>/dev/null)" ] || { [ "$o" = 0 ] && [ "$id" = custom ] && [ "$k" != ip ]; }; then _st_dom_build "$id" "$f" fi n="$(wc -l < "$f" | tr -d ' ')" if [ "$id" != custom ]; then mkdir -p "$ST_DIR" { grep -v "^$id " "$ST_DOMCNT" 2>/dev/null; echo "$id $n"; } > "$ST_DOMCNT.$$" && mv -f "$ST_DOMCNT.$$" "$ST_DOMCNT" fi ipn="$(cat "$f.ipn" 2>/dev/null)"; case "$ipn" in ''|*[!0-9]*) ipn=0 ;; esac src="$f"; [ "$k" = ip ] && src="$f.ips" printf '{"id":"%s","name":"%s","kind":"%s","count":%s,"ips":%s,"offset":%s,"src":"%s","domains":"%s"}\n' "$id" "$(esc "$(_rb_svc_field "$id" 2)")" "${k:-dom}" "${n:-0}" "$ipn" "$o" \ "$(esc "$(cat "$f.how" 2>/dev/null)")" "$(sed -n "$((o + 1)),$((o + l))p" "$src" 2>/dev/null | tr '\n' ' ')" } _st_find_ip() { local a s for s in 127.0.0.1 77.88.8.8 1.1.1.1; do a="$(_stl_t 4 nslookup "$1" "$s" 2>/dev/null | awk '/^Name:/ { f = 1; next } f && /^Address/ { a = $0; sub(/^Address[ 0-9]*:[ \t]*/, "", a); if (a ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) { print a; exit } }')" case "$a" in ''|198.18.*|198.19.*|0.*|127.*) continue ;; esac printf '%s' "$a" return 0 done return 1 } _st_ip_in() { local ip="$1"; shift [ $# -gt 0 ] || return 0 awk -v ip="$ip" ' function ipn(a, p) { if (split(a, p, ".") != 4) return -1; return ((p[1] * 256 + p[2]) * 256 + p[3]) * 256 + p[4] } BEGIN { iv = ipn(ip); bw = -1 } { l = $0; sub(/[#;].*$/, "", l); gsub(/[ \t\r]/, "", l) if (l ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(\/[0-9]+)?$/) { split(l, c, "/"); b = (c[2] == "") ? 32 : c[2] + 0; if (b < 0 || b > 32) next z = 2 ^ (32 - b); a = ipn(c[1]) if (a >= 0 && int(iv / z) == int(a / z) && b > bw) { best = l; bw = b } } else if (l ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+-[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) { split(l, c, "-"); if (iv >= ipn(c[1]) && iv <= ipn(c[2]) && bw < 0) { best = l; bw = 0 } } } END { if (bw >= 0) print best }' "$@" 2>/dev/null } steer_svc_find() { local q host="" ip="" d="$ST_RUN/dom" suf="$ST_RUN/find.$$.s" res="$ST_RUN/find.$$.r" sel miss nmiss=0 sep="" local id name f3 f4 f5 f6 f7 f8 rest f by line n grp on q="$(_zm_route_target "$1")" || { echo '{"error":"введите сайт или IP, например fast.com или 1.2.3.4"}'; return 1; } if printf '%s' "$q" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then printf '%s' "$q" | awk -F. '$1 < 256 && $2 < 256 && $3 < 256 && $4 < 256 { ok = 1 } END { exit !ok }' || { echo '{"error":"такого IP-адреса не бывает — в каждой части число от 0 до 255"}'; return 1; } ip="$q" elif printf '%s' "$q" | grep -q ':'; then echo '{"error":"IPv6 пока не ищется — введите IPv4-адрес или сайт"}'; return 1 else host="$q"; ip="$(_st_find_ip "$host")"; fi mkdir -p "$d" sel=" $(_st_sel_raw | tr '\n' ' ') " : > "$res"; : > "$suf" [ -n "$host" ] && printf '%s\n' "$host" | awk '{ s = $0; while (1) { print s; i = index(s, "."); if (!i) break; s = substr(s, i + 1) } }' > "$suf" _rb_svc_src | awk -F'|' '$1 != "" && !s[$1]++ && ($1 == "custom" || $3 $4 $8 != "")' > "$res.svc" while IFS='|' read -r id name f3 f4 f5 f6 f7 f8 rest; do case "$id" in *[!A-Za-z0-9_.-]*) continue ;; esac f="$d/$id.txt" [ "$id" = custom ] && [ -n "$ST_SEC" ] && f="$d/custom.$ST_SEC.txt" if [ "$id" = custom ] || [ ! -f "$f" ] || [ ! -f "$f.pfxsrc" ] || [ -s "$f.miss" ] || [ -n "$(find "$f" -mmin +9 2>/dev/null)" ]; then ST_DOM_NODL=1 _st_dom_build "$id" "$f" /dev/null ST_DOM_NODL="" fi by=""; line="" if [ -s "$suf" ] && [ -s "$f" ]; then line="$(grep -xF -f "$suf" "$f" 2>/dev/null | awk '{ print length($0) "\t" $0 }' | sort -rn | head -n1 | cut -f2)" [ -n "$line" ] && by=dom fi if [ -z "$by" ] && [ -n "$ip" ] && [ -s "$f.pfxsrc" ]; then line="$(_st_ip_in "$ip" $(cat "$f.pfxsrc"))" [ -n "$line" ] && by=ip fi [ -n "$by" ] || continue n="$(cat "$f" $(cat "$f.pfxsrc" 2>/dev/null) 2>/dev/null | wc -l | tr -d ' ')" grp="${rest%%|*}" on=false; case "$sel" in *" $id "*) on=true ;; esac printf '%s|%s|%s|%s|%s|%s|%s\n' "$id" "$name" "$grp" "$on" "$by" "$line" "${n:-0}" >> "$res" done < "$res.svc" miss="$(cat "$d"/*.txt.miss 2>/dev/null | tr ' ' '\n' | grep . | sort -u)" if [ -n "$miss" ]; then nmiss="$(printf '%s\n' "$miss" | grep -c .)" [ -d "$ST_RUN/find.lock" ] && [ -n "$(find "$ST_RUN/find.lock" -maxdepth 0 -mmin +10 2>/dev/null)" ] && rmdir "$ST_RUN/find.lock" 2>/dev/null if ! _st_running && mkdir "$ST_RUN/find.lock" 2>/dev/null; then ( for f in $miss; do _st_srs_get "$f"; done; rmdir "$ST_RUN/find.lock" ) >/dev/null 2>&1 "$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_ufile "$id").lst" 2>/dev/null | grep -c .)" "$(cat "$(_st_ufile "$id").pfx" 2>/dev/null | grep -c .)" "$(esc_ml "$body")" } steer_list_set() { local id="${1%%|*}" body="${1#*|}" f tmp n on="" case "$1" in *'|'*) ;; *) echo '{"error":"нет списка"}'; return 1 ;; esac case "$id" in custom+on) id=custom; on=1 ;; esac _st_user_list "$id" || { echo '{"error":"редактируется только свой список или исключения"}'; return 1; } f="$(_st_ufile "$id")"; mkdir -p "${f%/*}" f="$f.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#^[a-z][a-z0-9+.-]*://\([^/?]*\).*#\1#; 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" "${f%.lst}.pfx"; else rm -f "$tmp.pfx" "${f%.lst}.pfx"; fi if [ -n "$on" ] && ! _rb_in custom "$ST_SEL"; then mkdir -p "$(dirname "$ST_SEL")" echo custom >> "$ST_SEL" [ -f "$ST_SKIP" ] && sed -i '/^custom$/d' "$ST_SKIP" fi _st_list_changed "$id" "$n" } steer_list_reset() { local s _st_user_list "$1" || { echo '{"error":"редактируется только свой список или исключения"}'; return 1; } rm -f "$(_st_ufile "$1").lst" "$(_st_ufile "$1").pfx" if [ "$1" = custom ]; then if _rb_in custom "$ST_SEL"; then sed -i '/^custom$/d' "$ST_SEL" 2>/dev/null 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 [ "$1" = exclude ]; then if _st_installed && [ ! -f "$ST_OFF" ] && [ -z "$(_st_blocker)" ] && _st_owns "steer-spec" && _st_work; then job_start steer do_steer_excl else printf '{"ok":true,"saved":true,"count":%s}\n' "$2" fi return fi if _st_installed && [ ! -f "$ST_OFF" ] && [ -z "$(_st_blocker)" ] && _rb_in custom "$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_CUSTOM.lst" ] && [ ! -s "$ST_CUSTOM.pfx" ] && { echo '{"error":"свой список пуст — сначала добавьте в него домены или IP"}'; return 1; } done mkdir -p "$ST_DIR" touch "$ST_SKIP" for id in $(_st_sel_raw); 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_ZONE="zmvpn" ST_VDIR="$ST_DIR" 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 if [ -n "$ST_SEC" ]; then case "$(_st_sec_kind "$ST_SEC")" in vpn) if [ -s "$ST_SUB" ] && _st_vpn_ok; then echo vpn; else echo none; fi ;; iface) if _st_sec_live "$ST_SEC"; then echo iface; else echo none; fi ;; *) if _st_warp_on; then echo warp; else echo none; fi ;; esac return fi 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 dev if dev="$(_st_vpn_dev)"; then trace="$(_st_vpn_trace "$dev")" 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}" ] || { cat >/dev/null; 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_NODES="$ST_DIR/sub.nodes" _st_nodes() { local mods [ -s "$ST_SUB" ] || return 1 if [ ! -f "$ST_SUB.map" ]; then if stl_sub_norm "$ST_SUB" "$ST_SUB.n"; then mv -f "$ST_SUB.n.map" "$ST_SUB.map"; mv -f "$ST_SUB.n" "$ST_SUB"; chmod 600 "$ST_SUB"; fi rm -f "$ST_SUB.n" "$ST_SUB.n.map" fi mods="$(_stl_mods_present)" if [ ! -s "$ST_SUB_NODES" ] || [ "$ST_SUB" -nt "$ST_SUB_NODES" ] || [ "$(sed -n 's/^mods=//p' "$ST_SUB_NODES.meta" 2>/dev/null)" != "$mods" ]; then mkdir -p "$ST_DIR" stl_sub_nodes "$ST_SUB" "$ST_SUB_NODES.tmp" "$ST_SUB_NODES.meta" mv -f "$ST_SUB_NODES.tmp" "$ST_SUB_NODES" echo "mods=$mods" >> "$ST_SUB_NODES.meta" _st_sub_names_fix fi echo "$ST_SUB_NODES" } _st_key_in() { [ -s "$1" ] && ZM_W="$2" awk -F'\t' '$5 == ENVIRON["ZM_W"] { f = 1 } END { exit !f }' "$1"; } _st_nodes_kv() { local t; t="$(_st_nodes)" && cut -f1,5 "$t"; } _st_sub_names_fix() { local t="$ST_SUB_NODES" node w f [ -s "$t" ] || return 0 node="$(cat "$ST_SUB_NODE" 2>/dev/null)" if [ -n "$node" ] && ! _st_key_in "$t" "$node"; then w="$(printf '%s' "$node" | sed 's/[[:space:]]*$//')" f="$(ZM_W="$w" awk -F'\t' 'index($5, ENVIRON["ZM_W"]) == 1 { n++; k = $5 } END { if (n == 1) print k }' "$t")" [ -n "$f" ] && printf '%s\n' "$f" > "$ST_SUB_NODE" fi [ -s "$ST_SUB_HIDE" ] || return 0 while IFS= read -r w || [ -n "$w" ]; do [ -n "$w" ] || continue if _st_key_in "$t" "$w"; then printf '%s\n' "$w" else ZM_W="$(printf '%s' "$w" | sed 's/[[:space:]]*$//')" awk -F'\t' 'index($5, ENVIRON["ZM_W"]) == 1 { print $5 }' "$t"; fi done < "$ST_SUB_HIDE" | awk '!s[$0]++' > "$ST_SUB_HIDE.tmp" if [ -s "$ST_SUB_HIDE.tmp" ]; then mv -f "$ST_SUB_HIDE.tmp" "$ST_SUB_HIDE"; else rm -f "$ST_SUB_HIDE.tmp" "$ST_SUB_HIDE"; fi } _st_sub_filter() { local t want hid all n mk mk="$(head -n1 "$ST_SUB_EXCL" 2>/dev/null | tr '\t' ' ')" [ -n "$(printf '%s' "$mk" | tr -d ' |,')" ] || mk=- t="$(_st_nodes)"; all="$(grep -c . "$t" 2>/dev/null)" if [ -z "$t" ] || [ "${all:-0}" -eq 0 ]; then printf 'tun\tvless\t-\t%s\n' "$mk"; return 0; fi hid=" $(cut -f1,5 "$t" | _zm_excl_hits "$ST_SUB_EXCL" "$ST_SUB_HIDE" | tr '\n' ' ')" if [ "$(echo $hid | wc -w)" -ge "$all" ]; then echo "!! Фильтр узлов скрыл все узлы подписки — пока используем все, поправьте маркеры" >&2 hid=" "; mk=- fi want="$(cat "$ST_SUB_NODE" 2>/dev/null)" if [ -n "$want" ]; then n="$(ZM_W="$want" awk -F'\t' '$5 == ENVIRON["ZM_W"] { print $1 " " $2 " " $3; exit }' "$t")" if [ -n "$n" ]; then set -- $n case "$hid" in *" $1 "*) ;; *) printf 'tun\t%s\t%s\t%s\n' "$2" "$3" "$mk"; return 0 ;; esac fi fi if [ "$(_st_pick)" = ping ] && [ -s "$ST_SUB_ORDER" ]; then ZM_MK="$mk" awk -F'\t' -v h="$hid " -v of="$ST_SUB_ORDER" ' BEGIN { mk = ENVIRON["ZM_MK"]; while ((getline l < of) > 0) if (!(l in rk)) rk[l] = ++r } index(h, " " $1 " ") { next } { c++; key[c] = ($5 in rk) ? rk[$5] : 100000 + NR; pr[c] = $2; ix[c] = $3; o[c] = c } END { for (i = 2; i <= c; i++) { x = o[i]; j = i - 1; while (j > 0 && key[o[j]] > key[x]) { o[j + 1] = o[j]; j-- } o[j + 1] = x } for (i = 1; i <= c; i++) { p = pr[o[i]]; if (!(p in L)) ord[++np] = p; L[p] = L[p] (L[p] == "" ? "" : ",") ix[o[i]] } for (i = 1; i <= np; i++) printf "tun\t%s\t%s\t%s\n", ord[i], L[ord[i]], mk }' "$t" return 0 fi ZM_MK="$mk" awk -F'\t' -v h="$hid " ' BEGIN { mk = ENVIRON["ZM_MK"] } { if (!($2 in tot)) ord[++np] = $2; tot[$2]++ if (!index(h, " " $1 " ")) { ok[$2] = ok[$2] (ok[$2] == "" ? "" : ",") $3; cnt[$2]++ } } END { for (i = 1; i <= np; i++) { p = ord[i]; if (cnt[p]) printf "tun\t%s\t%s\t%s\n", p, (cnt[p] == tot[p] ? "-" : ok[p]), mk } }' "$t" } _st_active_key() { local t t="$(_st_nodes)" || return 0 ZM_P="$STL_PROTO" ZM_I="$STL_PIDX" ZM_N="$STL_NODE" awk -F'\t' '$2 == ENVIRON["ZM_P"] && ((ENVIRON["ZM_I"] != "" && $3 == ENVIRON["ZM_I"]) || (ENVIRON["ZM_I"] == "" && ENVIRON["ZM_N"] != "" && index($5, ENVIRON["ZM_N"]) == 1)) { print $5; exit }' "$t" } _st_vpn_json() { local key="" stl_state vpn || { echo null; return; } [ "$STL_UP" = true ] && key="$(_st_active_key)" printf '{"up":%s,"failed":%s' "$STL_UP" "$STL_FAILED" [ "$STL_UP" = true ] && [ -n "$key$STL_NODE" ] && printf ',"node":"%s"' "$(esc "${key:-$STL_NODE}")" [ -n "$STL_NWHY" ] && printf ',"why":"%s"' "$(esc "$STL_NWHY")" [ -n "$STL_PSTATE" ] && printf ',"probe":{"state":"%s","node":%s,"total":%s}' "$STL_PSTATE" "$STL_PNODE" "$STL_PTOTAL" printf '}' } _st_vpn_dev() { local d stl_state vpn >/dev/null 2>&1 && [ -n "$STL_DEV" ] && [ -d "/sys/class/net/$STL_DEV" ] && { echo "$STL_DEV"; return 0; } for d in $STL_VPN_DEVS; do [ -d "/sys/class/net/$d" ] && { echo "$d"; return 0; }; done return 1 } _st_vpn_trace() { local u t [ -w "/proc/sys/net/ipv4/conf/$1/rp_filter" ] && echo 2 > "/proc/sys/net/ipv4/conf/$1/rp_filter" 2>/dev/null for u in https://1.1.1.1/cdn-cgi/trace https://www.cloudflare.com/cdn-cgi/trace; do t="$(curl -4 -s --interface "$1" --connect-timeout 5 --max-time 10 "$u" 2>/dev/null)" case "$t" in *ip=*) printf '%s\n' "$t"; return 0 ;; esac done return 1 } _st_vpn_zone() { local d have ch=0 if [ "$1" = on ]; then if [ "$(uci -q get "firewall.$ST_VPN_ZONE")" = zone ]; then have=" $(uci -q get "firewall.$ST_VPN_ZONE.device") " for d in $(_st_vpn_devs_all); do case "$have" in *" $d "*) ;; *) uci add_list "firewall.$ST_VPN_ZONE.device=$d"; ch=1 ;; esac done [ "$ch" = 1 ] && { uci commit firewall; /etc/init.d/firewall reload >/dev/null 2>&1; } _zm_fwd_fix "${ST_VPN_ZONE}_fwd" return 0 fi uci set "firewall.$ST_VPN_ZONE=zone" uci set "firewall.$ST_VPN_ZONE.name=$ST_VPN_ZONE" for d in $(_st_vpn_devs_all); do uci add_list "firewall.$ST_VPN_ZONE.device=$d"; done 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 _st_vpn_any && 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 max="${1:-90}" while [ "$w" -lt "$max" ]; do stl_state vpn >/dev/null 2>&1 [ "$STL_UP" = true ] && [ -n "$STL_DEV" ] && [ -d "/sys/class/net/$STL_DEV" ] && break [ "$w" -ge 6 ] && { [ -n "$STL_NWHY" ] || [ "$STL_PSTATE" = failed ] || [ "$STL_PSTATE" = no_such_node ]; } && break sleep 2; w=$((w + 2)) done if [ "$STL_UP" = true ] && [ -n "$STL_DEV" ]; then w=0 while [ "$w" -lt 3 ]; do tr="$(_st_vpn_trace "$STL_DEV")" ip="$(echo "$tr" | sed -n 's/^ip=//p')"; loc="$(echo "$tr" | sed -n 's/^loc=//p')" if [ -n "$ip" ]; then echo "[ OK ] VPN: трафик идёт через узел${STL_NODE:+ «$STL_NODE»} (выход $ip${loc:+, $loc})" return 0 fi w=$((w + 1)); [ "$w" -lt 3 ] && { sleep 5; stl_state vpn >/dev/null 2>&1; } done fi stl_state vpn >/dev/null 2>&1 if [ "$STL_FAILED" = true ] && [ -n "$STL_NWHY" ]; then echo "[FAIL] VPN: узел$( [ -s "$ST_SUB_NODE" ] && printf ' «%s»' "$(head -n1 "$ST_SUB_NODE")") не отвечает — $STL_NWHY; $(_st_fail_text)" elif [ "$STL_PSTATE" = no_such_node ]; then echo "[FAIL] VPN: выбранного узла больше нет в подписке — выберите «Авто» или другой узел" elif [ "$STL_PSTATE" = failed ] || [ "$STL_UP" != true ]; then echo "[FAIL] VPN: ни один выбранный узел не ответил за $max с — проверьте задержку узлов на вкладке «Подписка»" else echo "[FAIL] VPN: туннель поднят${STL_NODE:+ (узел «$STL_NODE»)}, но трафик через него не идёт" fi 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_need_mods() { local f names="" f="$(stl_sub_mods "$1")" _st_feats_ok $f && return 0 case " $f " in *" hy2 "*) names="Hysteria2" ;; esac case " $f " in *" proxy "*) names="${names:+$names, }Trojan, Shadowsocks, SOCKS, HTTP или VMess" ;; esac _rb_say "В подписке есть серверы ${names:-VLESS} — ставим для них модули ядра steer" _st_phase pkgs _st_install_steer $f || return 1 _st_feats_ok $f || { echo "ОШИБКА: модули ядра steer для протоколов подписки не установились"; return 1; } _st_phase sub } _st_sub_take() { rm -f "$ST_SUB.new" "$ST_SUB.new.map" stl_sub_norm "$1" "$ST_SUB.new" || { rm -f "$ST_SUB.new" "$ST_SUB.new.map"; echo "ОШИБКА: в подписке пусто"; return 1; } _st_need_mods "$ST_SUB.new" || { rm -f "$ST_SUB.new" "$ST_SUB.new.map"; return 1; } _st_sub_check "$ST_SUB.new" || { rm -f "$ST_SUB.new" "$ST_SUB.new.map"; return 1; } } _st_sub_commit() { mv -f "$ST_SUB.new.map" "$ST_SUB.map" 2>/dev/null || : > "$ST_SUB.map" mv -f "$ST_SUB.new" "$ST_SUB" chmod 600 "$ST_SUB" rm -f "$ST_SUB_NODES" } _st_proto_names() { local p o="" for p; do case "$p" in vless) p=VLESS ;; hysteria2) p=Hysteria2 ;; trojan) p=Trojan ;; shadowsocks) p=Shadowsocks ;; socks) p=SOCKS ;; http) p=HTTP ;; vmess) p=VMess ;; esac o="$o${o:+, }$p" done printf '%s' "$o" } _st_sub_check() { if stl_sub_check "$1"; then _rb_say "Узлов, с которыми умеет работать Steer: $STL_SUB_USABLE ($(_st_proto_names $STL_SUB_PROTOS))" return 0 fi echo "ОШИБКА: в подписке нет узлов, с которыми умеет работать Steer (VLESS, Hysteria2, Trojan, Shadowsocks, SOCKS, HTTP, VMess)" [ -n "$STL_SUB_WHY" ] && printf '%s\n' "$STL_SUB_WHY" | sed 's/^/ причина: /' return 1 } _st_sub_fetch() { rm -f "$ST_SUB.raw" if ! stl_sub_fetch "$1" "$ST_SUB.raw" "$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_take "$ST_SUB.raw" || { rm -f "$ST_SUB.raw"; return 1; } rm -f "$ST_SUB.raw" _st_sub_commit 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_node_gone } _st_sub_node_gone() { local node t node="$(cat "$ST_SUB_NODE" 2>/dev/null)" [ -n "$node" ] && [ -s "$ST_SUB" ] || return 0 t="$(_st_nodes)" && _st_key_in "$t" "$node" && return 0 rm -f "$ST_SUB_NODE" echo " · выбранного узла «$node» больше нет в подписке — Steer сам выберет рабочий" } _st_sub_apply() { _st_ping_cron if [ -f "$ST_OFF" ]; then _rb_say "Steer выключен — настройки сохранены и применятся при включении" return 0 fi [ "$(_st_pick)" = ping ] && _st_use_vpn && [ ! -s "$ST_SUB_NODE" ] && _st_ping_refresh _st_phase rules _st_apply || return 1 _st_phase check sleep 2 _st_selfcheck _rb_say "Готово" } do_steer_sub_set() { local f="$ST_VDIR/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 -iE "$STL_LINK_RE" | awk '!s[$0]++' > "$ST_SUB.raw" [ -s "$ST_SUB.raw" ] || { rm -f "$ST_SUB.raw"; echo "ОШИБКА: в тексте нет ссылок на серверы (vless://, trojan://, ss://, vmess://, hysteria2://, socks5://)"; return 1; } _st_sub_take "$ST_SUB.raw" || { rm -f "$ST_SUB.raw"; return 1; } rm -f "$ST_SUB.raw" _st_sub_commit rm -f "$ST_SUB_URL" "$ST_SUB_TITLE" "$ST_SUB_INFO" _rb_say "Ссылки сохранены" ;; esac rm -f "$ST_SUB_NODE" "$ST_SUB_HOME" echo vpn > "$ST_EXIT" _rb_say "Выбранные сервисы теперь идут через подписку" _rb_say "Перезапускаем VPN-туннель с новым списком серверов" _st_sub_apply } do_steer_sub_links() { local f="$ST_VDIR/sub.pending" n 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 -iE "$STL_LINK_RE" | awk '!s[$0]++' > "$ST_SUB.raw" rm -f "$f" [ -s "$ST_SUB.raw" ] || { rm -f "$ST_SUB.raw"; echo "ОШИБКА: в тексте нет ссылок на серверы"; return 1; } n="$(grep -c . "$ST_SUB.raw")" _rb_say "Проверяем ссылки: $n" _st_sub_take "$ST_SUB.raw" || { rm -f "$ST_SUB.raw"; echo "Прежний список серверов не тронут"; return 1; } rm -f "$ST_SUB.raw" rm -f "$ST_SUB.bak" _st_sub_commit rm -f "$ST_SUB_URL" "$ST_SUB_TITLE" "$ST_SUB_INFO" _st_sub_node_gone _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_work && ! _st_vpn_probe && _st_vpn_heal return 0 } ST_SUB_FELL="$ST_DIR/sub.node.fell" ST_VPN_WATCH="$JOBS_DIR/steer.vpnwatch" ST_NODE_POL="$ST_DIR/node.pol" ST_SUB_HOME="$ST_DIR/sub.node.home" _st_node_pol() { case "$(cat "$ST_NODE_POL" 2>/dev/null)" in switch) echo switch ;; hold) echo hold ;; *) echo back ;; esac; } _st_fail_text() { [ "$(_st_onfail)" = drop ] && echo "выбранные сервисы остановлены" || echo "выбранные сервисы идут напрямую"; } _st_node_down() { [ "$STL_UP" = true ] && return 1 [ -n "$STL_NWHY" ] || [ "$STL_PSTATE" = failed ] || [ "$STL_PSTATE" = no_such_node ] } _st_diag_node() { case "$1" in "выход "*": узел перестал отвечать"*|"выход "*": соединение с узлом "*" не поднято"*|"выход "*": ни один узел подписки не ответил"*|\ "выход "*": выбран узел "*|"выход "*": все узлы-кандидаты"*|"выход "*": в подписке нет пригодных узлов"*|"выход "*" не отвечает, трафик канала"*) return 0 ;; esac return 1 } _st_node_failover() { local pinned pol pinned="$(cat "$ST_SUB_NODE" 2>/dev/null)" [ -n "$pinned" ] || return 1 pol="$(_st_node_pol)" if [ "$pol" = hold ]; then echo " · Узел «$pinned» не трогаем — так задано в «Если выбранный узел не отвечает»: $(_st_fail_text), пока он не ответит или вы не выберете другой узел" return 1 fi mkdir -p "$ST_VDIR" printf '%s\n' "$pinned" > "$ST_SUB_FELL" if [ "$pol" = back ]; then printf '%s\n' "$pinned" > "$ST_SUB_HOME"; else rm -f "$ST_SUB_HOME"; fi rm -f "$ST_SUB_NODE" if [ "$pol" = back ]; then _rb_say "Узел «$pinned» не отвечает — переходим на рабочий узел и вернёмся на него, когда он ответит" else _rb_say "Узел «$pinned» не отвечает — переходим на рабочий узел"; fi logger -t zapret-manager "Steer: узел «$pinned» не отвечает — переходим на рабочий узел" _st_spec_quick >/dev/null 2>&1 [ $? = 2 ] && _st_apply >/dev/null 2>&1 _st_vpn_check && return 0 echo " · Рабочего узла пока нет — ядро steer продолжит перебирать узлы само" return 1 } _st_home_tick() { local home cur t i st="$JOBS_DIR/steer.hometick${ST_SEC:+.$ST_SEC}" home="$(head -n1 "$ST_SUB_HOME" 2>/dev/null)" [ -n "$home" ] || return 0 cur="$(cat "$ST_SUB_NODE" 2>/dev/null)" if [ "$cur" = "$home" ] || [ "$(_st_node_pol)" != back ]; then rm -f "$ST_SUB_HOME"; return 0; fi t="$(_st_nodes)" && i="$(ZM_W="$home" awk -F'\t' '$5 == ENVIRON["ZM_W"] { print $1; exit }' "$t")" [ -n "$i" ] || { rm -f "$ST_SUB_HOME"; return 0; } [ -n "$(find "$st" -mmin -10 2>/dev/null)" ] && return 0 mkdir -p "$JOBS_DIR"; : > "$st" ( [ "$(steer_sub_probe "$i" 2>/dev/null | jsonfilter -e '@.results[0].ok' 2>/dev/null)" = true ] && ! _zm_busy_job >/dev/null 2>&1 && job_start steer do_steer_home_back >/dev/null 2>&1 ) >/dev/null 2>&1 & return 0 } do_steer_home_back() { local home prev rm -f "$ST_STOP_FLAG" home="$(head -n1 "$ST_SUB_HOME" 2>/dev/null)" [ -n "$home" ] || { _rb_say "Возвращаться не на что — выбор узла уже изменён"; return 0; } _st_key_in "$(_st_nodes)" "$home" || { rm -f "$ST_SUB_HOME"; _rb_say "Узла «$home» больше нет в подписке — остаёмся на текущем"; return 0; } prev="$(cat "$ST_SUB_NODE" 2>/dev/null)"; prev="${prev:-auto}" _rb_say "Узел «$home» снова отвечает — возвращаемся на него" logger -t zapret-manager "Steer: узел «$home» снова отвечает — возвращаемся на него" mkdir -p "$ST_VDIR" printf '%s\n' "$home" > "$ST_SUB_NODE" rm -f "$ST_SUB_HOME" "$ST_SUB_FELL" "$ST_VPN_WATCH" do_steer_switch "$prev" } _st_vpn_heal() { local pinned pinned="$(cat "$ST_SUB_NODE" 2>/dev/null)" if [ -n "$pinned" ] && stl_running && stl_state vpn >/dev/null 2>&1 && _st_node_down; then _rb_warn "VPN не отвечает: не отвечает выбранный узел «$pinned»${STL_NWHY:+ ($STL_NWHY)} — ядро steer исправно, перезапускать его незачем" _st_node_failover && { _st_vpn_probe >/dev/null 2>&1; echo "[ OK ] VPN работает через другой узел"; return 0; } [ "$(_st_node_pol)" = hold ] && return 0 echo "[FAIL] VPN не поднялся — ни один узел подписки не отвечает" return 1 fi _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" ] && [ "$(_st_node_pol)" != hold ]; then _rb_warn "Узел «$pinned» не отвечает — переходим на рабочий" if _st_node_failover >/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_work_main || { rm -f "$f"; return 0; } [ -z "$(_st_blocker)" ] || return 0 _zm_busy_job >/dev/null 2>&1 && return 0 [ -s "$ST_SUB_HOME" ] && _st_home_tick if _st_vpn_probe; then rm -f "$f" "$JOBS_DIR/steer.holdlog${ST_SEC:+.$ST_SEC}"; return 0; fi _zm_inet_ok || return 0 if [ -s "$ST_SUB_NODE" ] && [ "$(_st_node_pol)" = hold ] && stl_running && stl_state vpn >/dev/null 2>&1 && _st_node_down; then if ! cmp -s "$ST_SUB_NODE" "$JOBS_DIR/steer.holdlog${ST_SEC:+.$ST_SEC}" 2>/dev/null; then cp -f "$ST_SUB_NODE" "$JOBS_DIR/steer.holdlog${ST_SEC:+.$ST_SEC}" 2>/dev/null logger -t zapret-manager "Steer: выбранный узел «$(head -n1 "$ST_SUB_NODE")» не отвечает — держим его, как задано в настройке" fi rm -f "$f" return 0 fi { 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 rm -f "$ST_STOP_FLAG" _st_use_vpn && was=1 rm -f "$ST_SUB" "$ST_SUB.bak" "$ST_SUB.map" "$ST_SUB_NODES" "$ST_SUB_NODES.meta" "$ST_SUB_NODES.hp" "$ST_SUB_INFO" "$ST_SUB_URL" "$ST_SUB_TITLE" "$ST_SUB_NODE" "$ST_SUB_FELL" "$ST_SUB_HOME" "$ST_SUB_PING" "$ST_SUB_ORDER" [ -n "$ST_SEC" ] || echo warp > "$ST_EXIT" _st_vpn_zone off [ -n "$(_st_sec_with main eval '[ -s "$ST_SUB_URL" ] && echo 1')$(_st_secs_url)" ] || _st_sub_auto_set off >/dev/null _st_ping_cron if [ -n "$ST_SEC" ]; then _rb_say "Подписка секции «$(_st_sec_name "$ST_SEC")» удалена — её сервисы идут напрямую, пока не добавите подписку или не смените подключение секции" elif _st_warp_on; then _rb_say "Подписка удалена — выбранные сервисы идут через WARP" else _rb_say "Подписка удалена — туннеля больше нет, сервисы идут напрямую"; fi if [ "$was" = 1 ] && _st_installed; then _st_sub_apply || return 1; fi 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://*) ;; *) printf '%s\n' "$mode" | tr ' \t\r' '\n\n\n' | grep -qiE "$STL_LINK_RE" || { echo '{"error":"нужна ссылка на подписку (https://…) или ссылки на серверы: vless://, trojan://, ss://, vmess://, hysteria2://, socks5://"}'; return 1; } ;; esac mkdir -p "$ST_VDIR" printf '%s\n' "$mode" > "$ST_VDIR/sub.pending" chmod 600 "$ST_VDIR/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 -qiE "$STL_LINK_RE" || { echo '{"error":"нет ни одной ссылки на сервер"}'; return 1; } mkdir -p "$ST_VDIR" printf '%s\n' "$mode" > "$ST_VDIR/sub.pending" chmod 600 "$ST_VDIR/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_VDIR" echo "$mode" > "$ST_EXIT" [ "$mode" = warp ] && _st_vpn_zone off if _st_installed && [ ! -f "$ST_OFF" ] && [ -z "$(_st_blocker)" ] && _st_work; then if [ "$mode" = vpn ] && ! _st_feats_ok $(_st_sub_feats); 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_VDIR/sub.exclude.try" "$m" >/dev/null tsv="$(_st_nodes_kv)" total="$(printf '%s\n' "$tsv" | grep -c .)" hid="$(printf '%s\n' "$tsv" | _zm_excl_hits "$ST_VDIR/sub.exclude.try" "$ST_SUB_HIDE" | grep -c .)" rm -f "$ST_VDIR/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" ] && _st_nodes_kv | ZM_W="$(cat "$ST_SUB_NODE")" awk -F'\t' '$2 == ENVIRON["ZM_W"]' | _zm_excl_hits "$ST_SUB_EXCL" "$ST_SUB_HIDE" | grep -c . | grep -qv '^0$'; then rm -f "$ST_SUB_NODE" fi if [ -s "$ST_SUB" ] && _st_use_vpn && _st_installed && [ ! -f "$ST_OFF" ] && [ -z "$(_st_blocker)" ] && _st_work; then job_start steer do_steer_switch - 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_VDIR" 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_VDIR/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="$(_st_nodes_kv)" 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_VDIR/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 -c . | grep -qv '^0$'; then rm -f "$ST_SUB_NODE" fi fi if _st_use_vpn && _st_installed && [ ! -f "$ST_OFF" ] && [ -z "$(_st_blocker)" ] && _st_work; then job_start steer do_steer_switch - else printf '{"ok":true,"saved":true}\n' fi ;; sub_pick) local pm piv ptol case "$mode" in order) pm=order ;; ping:*) pm=ping; piv="$(printf '%s' "$mode" | cut -d: -f2)"; ptol="$(printf '%s' "$mode" | cut -d: -f3)" case "$piv" in 15|30|60|180) ;; *) echo '{"error":"интервал проверки: 15, 30, 60 или 180 минут"}'; return 1 ;; esac case "$ptol" in ''|*[!0-9]*) echo '{"error":"допуск — число миллисекунд от 0 до 10000"}'; return 1 ;; esac [ "$ptol" -le 10000 ] || { echo '{"error":"допуск — число миллисекунд от 0 до 10000"}'; return 1; } ;; *) echo '{"error":"неизвестный режим автовыбора"}'; return 1 ;; esac mkdir -p "$ST_VDIR" if [ "$pm" = ping ]; then echo "ping $piv $ptol" > "$ST_SUB_PICK"; else rm -f "$ST_SUB_PICK" "$ST_SUB_ORDER"; fi if [ -s "$ST_SUB" ] && _st_installed && _st_vpn_ok; then job_start steer do_steer_ping else _st_ping_cron; printf '{"ok":true,"saved":true}\n'; fi ;; sub_ping) [ -s "$ST_SUB" ] || { echo '{"error":"подписки нет"}'; return 1; } [ "$(_st_pick)" = ping ] || { echo '{"error":"сначала выберите автовыбор «По пингу»"}'; return 1; } job_start steer do_steer_ping ;; sub_node) [ -s "$ST_SUB" ] || { echo '{"error":"подписки нет"}'; return 1; } if [ -n "$mode" ] && ! _st_key_in "$(_st_nodes)" "$mode"; then echo '{"error":"такого узла нет в подписке — обновите страницу"}'; return 1 fi if [ -n "$mode" ] && _st_nodes_kv | ZM_W="$mode" awk -F'\t' '$2 == ENVIRON["ZM_W"]' | _zm_excl_hits "$ST_SUB_EXCL" "$ST_SUB_HIDE" | grep -c . | grep -qv '^0$'; then echo '{"error":"этот узел скрыт фильтром — уберите маркер или выберите другой узел"}'; return 1 fi mkdir -p "$ST_VDIR" local prev prev="$(cat "$ST_SUB_NODE" 2>/dev/null)"; prev="${prev:-auto}" if [ -n "$mode" ]; then printf '%s\n' "$mode" > "$ST_SUB_NODE"; else rm -f "$ST_SUB_NODE"; fi rm -f "$ST_SUB_FELL" "$ST_SUB_HOME" "$ST_VPN_WATCH" if _st_use_vpn && _st_installed && [ ! -f "$ST_OFF" ] && [ -z "$(_st_blocker)" ] && _st_work; then job_start steer do_steer_switch "$prev" 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" ] && _st_work; 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 } _st_nodes_json() { awk -F'\t' -v meta="$ST_SUB_NODES.meta" ' function j(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 < " ") o = o " "; else o = o c } return o } BEGIN { while ((getline l < meta) > 0) { if (l ~ /^skipped=/) sk = substr(l, 9); if (l ~ /^reasons=/) rs = substr(l, 9) }; printf "{\"nodes\":[" } { printf "%s{\"index\":%d,\"name\":\"%s\",\"proto\":\"%s\",\"type\":\"%s\",\"security\":\"%s\",\"vision\":%s}", (NR > 1 ? "," : ""), $1, j($5), $2, j($6), j($7), ($8 == 1 ? "true" : "false") } END { printf "],\"usable\":%d,\"skipped\":%d,\"skipped_reasons\":%s}", NR, sk + 0, (rs == "" ? "[]" : rs) }' "$1" } 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="" t _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)" 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 ] && t="$(_st_nodes)"; then list="$(_st_nodes_json "$t")" hidden="$(cut -f1,5 "$t" | _zm_excl_hits "$ST_SUB_EXCL" "$ST_SUB_HIDE" | tr '\n' ',' | sed 's/,$//')" fi node="$(cat "$ST_SUB_NODE" 2>/dev/null)" fi _st_use_vpn && _st_owns "steer-spec" && vpn="$(_st_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","pick":"%s","pick_iv":%s,"pick_tol":%s,"ping_at":%s,"ping":{%s},"order_head":"%s","lat_mode":"%s","ping_blk":[%s],"ping_tcp":[%s],"home":"%s","node_pol":"%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)")" \ "$(_st_pick)" "$(_st_pick_iv)" "$(_st_pick_tol)" "$(date -r "$ST_SUB_PING" +%s 2>/dev/null || echo 0)" \ "$([ -s "$ST_SUB_PING" ] && awk -F'\t' -v q='"' 'function j(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 < " ") o = o " "; else o = o c } return o } { n = j($1); printf "%s%s%s%s:%d", (NR > 1 ? "," : ""), q, n, q, $2 + 0 }' "$ST_SUB_PING")" \ "$(esc "$([ "$(_st_pick)" = ping ] && head -n1 "$ST_SUB_ORDER" 2>/dev/null)")" \ "$(_st_lat_mode)" "$([ -s "$ST_SUB_PING" ] && awk -F'\t' -v q='"' 'function j(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 < " ") o = o " "; else o = o c } return o } $3 == "b" { n = j($1); printf "%s%s%s%s", (c++ ? "," : ""), q, n, q }' "$ST_SUB_PING")" \ "$([ -s "$ST_SUB_PING" ] && awk -F'\t' -v q='"' 'function j(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 < " ") o = o " "; else o = o c } return o } $3 == "t" { n = j($1); printf "%s%s%s%s", (c++ ? "," : ""), q, n, q }' "$ST_SUB_PING")" \ "$(esc "$(head -n1 "$ST_SUB_HOME" 2>/dev/null)")" "$(_st_node_pol)" } _st_hp_build() { local m hp="$ST_SUB_NODES.hp" [ -s "$hp" ] && [ ! "$ST_SUB" -nt "$hp" ] && return 0 mkdir -p "$ST_DIR" { for m in vless proxy; do [ -x "$STL_SBIN/steer-$m" ] || continue _stl_t 30 steer "$m-nodes" "$ST_SUB" 2>/dev/null | tr '\n\t' ' ' | awk -v M="$m" '{ j = $0; p = index(j, "\"nodes\":["); if (!p) next j = substr(j, p + 9); n = split(j, C, /\{"index":/) for (i = 2; i <= n; i++) { ch = C[i]; idx = ch + 0; h = ""; pt = 0 if (match(ch, /"host":"[^"]*"/)) h = substr(ch, RSTART + 8, RLENGTH - 9) if (match(ch, /"port":[0-9]+/)) pt = substr(ch, RSTART + 7, RLENGTH - 7) + 0 if (h != "" && pt > 0) printf "%s\t%d\t%s\t%d\n", M, idx, h, pt } }' done; } > "$hp.$$" 2>/dev/null mv -f "$hp.$$" "$hp" 2>/dev/null } _st_tcp_one() { local h="$1" p="$2" to="$3" out="$4" case "$h" in *:*) h="[$h]" ;; esac ( curl -s -o /dev/null --noproxy '*' --connect-timeout "$to" --max-time "$to" -w '%{time_connect} %{time_namelookup}\n' "http://$h:$p/" 2>/dev/null > "$out.a" ) & ( curl -s -o /dev/null --noproxy '*' --connect-timeout "$to" --max-time "$to" -w '%{time_connect} %{time_namelookup}\n' "http://$h:$p/" 2>/dev/null > "$out.b" ) & wait cat "$out.a" "$out.b" 2>/dev/null | awk '{ c = $1 + 0; n = $2 + 0; if (c > 0) { if (n > 0 && n < c) c -= n; m = int(c * 1000 + 0.5); if (m < 1) m = 1; if (!b || m < b) b = m } } END { if (b) print b }' > "$out" rm -f "$out.a" "$out.b" } steer_sub_tcp() { local t dir list="" i p f hp retry="" out="" sep="" ms command -v curl >/dev/null 2>&1 || { echo '{"error":"на роутере нет curl"}'; return 1; } [ -s "$ST_SUB" ] && _st_vpn_ok && t="$(_st_nodes)" || { echo '{"error":"подписки нет"}'; return 1; } _st_hp_build dir="$ST_RUN/tcp.$$"; mkdir -p "$dir" for i in $(printf '%s' "$1" | tr ',' ' '); do case "$i" in ''|*[!0-9]*) continue ;; esac set -- $(awk -F'\t' -v g="$i" '$1 == g { print $2, $4; exit }' "$t") p="$(_stl_mod_of "$1")"; f="$2" hp="" [ -n "$1" ] && [ "$p" != hysteria2 ] && hp="$(awk -F'\t' -v m="$p" -v n="$f" '$1 == m && $2 == n { print $3, $4; exit }' "$ST_SUB_NODES.hp" 2>/dev/null)" if [ -z "$hp" ]; then echo udp > "$dir/$i.k"; list="$list $i"; continue; fi echo "$hp" > "$dir/$i.k" _st_tcp_one "${hp% *}" "${hp#* }" 3 "$dir/$i" & list="$list $i" done wait for i in $list; do [ "$(cat "$dir/$i.k")" != udp ] && [ ! -s "$dir/$i" ] || continue hp="$(cat "$dir/$i.k")" _st_tcp_one "${hp% *}" "${hp#* }" 6 "$dir/$i" & retry=1 done [ -n "$retry" ] && wait for i in $list; do if [ "$(cat "$dir/$i.k")" = udp ]; then out="$out$sep\"$i\":{\"core\":true}" elif ms="$(cat "$dir/$i" 2>/dev/null)" && [ -n "$ms" ]; then out="$out$sep\"$i\":{\"ok\":true,\"ms\":$ms}" else out="$out$sep\"$i\":{\"ok\":false}"; fi sep="," done rm -rf "$dir" printf '{"results":{%s}}\n' "$out" } ST_SUB_PICK="$ST_DIR/sub.pick" ST_LAT_MODE="$ST_DIR/lat.mode" _st_lat_mode() { [ "$(cat "$ST_LAT_MODE" 2>/dev/null)" = real ] && echo real || echo fast; } ST_SUB_PING="$ST_DIR/sub.ping" ST_SUB_ORDER="$ST_DIR/sub.order" ST_PING_TAG="# zm-subping" _st_pick() { set -- $(head -n1 "$ST_SUB_PICK" 2>/dev/null); [ "$1" = ping ] && echo ping || echo order; } _st_pick_iv() { set -- $(head -n1 "$ST_SUB_PICK" 2>/dev/null); case "$2" in 15|30|60|180) echo "$2" ;; *) echo 15 ;; esac; } _st_pick_tol() { set -- $(head -n1 "$ST_SUB_PICK" 2>/dev/null); case "$3" in ''|*[!0-9]*) echo 50 ;; *) if [ "$3" -le 10000 ]; then echo "$3"; else echo 50; fi ;; esac; } ST_SEC_DIR="$ST_DIR/sec" ST_SEC_LIST="$ST_SEC_DIR/list" ST_SEC_MAX=6 ST_SEC_VPN_MAX=3 _st_bind() { local d n if [ -z "$1" ] || [ "$1" = main ]; then ST_SEC=""; d="$ST_DIR" ST_SEL="$ST_DIR/services"; ST_SKIP="$ST_DIR/skip"; ST_DEVS="$ST_DIR/devices"; ST_EXIT="$ST_DIR/exit" ST_VPN_PROBE="$JOBS_DIR/steer.vpnprobe"; ST_VPN_WATCH="$JOBS_DIR/steer.vpnwatch" STL_VPN_OUT="zm_vpn"; STL_VPN_DEV="zm_vpn"; STL_VPN_PFX="zmv"; ST_CUSTOM="$ST_DIR/user/custom" else ST_SEC="$1"; n="${1#s}"; d="$ST_SEC_DIR/$1" ST_SEL="$d/services"; ST_SKIP="$d/skip"; ST_DEVS="$d/devices"; ST_EXIT="$d/exit" ST_VPN_PROBE="$JOBS_DIR/steer.vpnprobe.$1"; ST_VPN_WATCH="$JOBS_DIR/steer.vpnwatch.$1" STL_VPN_OUT="zm_vpn$n"; STL_VPN_DEV="zm_vpn$n"; STL_VPN_PFX="zmv$n"; ST_CUSTOM="$d/custom" fi ST_VDIR="$d" ST_SUB="$d/sub.txt"; ST_SUB_INFO="$d/sub.userinfo"; ST_SUB_URL="$d/sub.url"; ST_SUB_TITLE="$d/sub.title" ST_SUB_NODE="$d/sub.node"; ST_SUB_EXCL="$d/sub.exclude"; ST_SUB_HIDE="$d/sub.hidden"; ST_SUB_NODES="$d/sub.nodes" ST_SUB_FELL="$d/sub.node.fell"; ST_NODE_POL="$d/node.pol"; ST_SUB_HOME="$d/sub.node.home" ST_SUB_PICK="$d/sub.pick"; ST_LAT_MODE="$d/lat.mode"; ST_SUB_PING="$d/sub.ping"; ST_SUB_ORDER="$d/sub.order" STL_VPN_DEVS="$STL_VPN_DEV ${STL_VPN_PFX}_vless ${STL_VPN_PFX}_hy2 ${STL_VPN_PFX}_trojan ${STL_VPN_PFX}_ss ${STL_VPN_PFX}_socks ${STL_VPN_PFX}_http ${STL_VPN_PFX}_vmess" } _st_sec_with() { local _sw_was="$ST_SEC" _sw_rc _st_bind "$1"; shift "$@"; _sw_rc=$? _st_bind "$_sw_was" return $_sw_rc } _st_secs() { [ -s "$ST_SEC_LIST" ] || return 0; grep -E '^s[2-9]$' "$ST_SEC_LIST" | while read -r _s; do [ -d "$ST_SEC_DIR/$_s" ] && echo "$_s"; done; } _st_sec_ok() { case "$1" in s[2-9]) [ -d "$ST_SEC_DIR/$1" ] ;; *) return 1 ;; esac; } _st_sec_kind() { local k=""; [ -f "$ST_SEC_DIR/$1/kind" ] && read -r k < "$ST_SEC_DIR/$1/kind"; case "$k" in vpn|iface) echo "$k" ;; *) echo warp ;; esac; } _st_sec_iface() { local k=""; [ -f "$ST_SEC_DIR/$1/iface" ] && read -r k < "$ST_SEC_DIR/$1/iface"; printf '%s' "$k"; } _st_sec_name() { local f="$ST_SEC_DIR/$1/name" v="" case "$1" in ''|main) f="$ST_SEC_DIR/main.name" ;; esac [ -f "$f" ] && read -r v < "$f" [ -n "$v" ] || case "$1" in ''|main) v="Основная" ;; *) v="Секция ${1#s}" ;; esac printf '%s' "$v" } _st_sec_out() { case "$(_st_sec_kind "$1")" in vpn) echo "zm_vpn${1#s}" ;; iface) echo "zm_if${1#s}" ;; *) echo "$STL_WARP_OUT" ;; esac } _st_sec_live() { _st_sec_off "$1" && return 1 case "$(_st_sec_kind "$1")" in vpn) [ -s "$ST_SEC_DIR/$1/sub.txt" ] && _st_vpn_ok ;; iface) local i; i="$(_st_sec_iface "$1")"; [ -n "$i" ] && [ "$(uci -q get "network.$i.proto")" = amneziawg ] ;; *) _st_warp_on ;; esac } _st_sec_work() { _st_sec_with "$1" _st_work; } _st_secs_work() { local s; for s in $(_st_secs); do _st_sec_live "$s" && _st_sec_work "$s" && return 0; done; return 1; } _st_secs_vpn() { local s; for s in $(_st_secs); do [ "$(_st_sec_kind "$s")" = vpn ] && _st_sec_live "$s" && _st_sec_work "$s" && echo "$s"; done; } _st_secs_warp() { local s; for s in $(_st_secs); do [ "$(_st_sec_kind "$s")" = warp ] && _st_sec_work "$s" && return 0; done; return 1; } _st_secs_ifaces() { local s; for s in $(_st_secs); do [ "$(_st_sec_kind "$s")" = iface ] && _st_sec_live "$s" && _st_sec_work "$s" && _st_sec_iface "$s" && echo; done | grep . | awk '!x[$0]++'; } _st_vpn_any() { _st_sec_with main _st_use_vpn && return 0; [ -n "$(_st_secs_vpn)" ]; } _st_sec_vpn_json() { local has=false key="" node="" [ -s "$ST_SUB" ] && has=true node="$(cat "$ST_SUB_NODE" 2>/dev/null)" printf '{"has":%s,"url":%s,"label":"%s","pinned":"%s","state":%s}' "$has" "$([ -s "$ST_SUB_URL" ] && echo true || echo false)" "$(esc "$(_st_sub_label)")" "$(esc "$node")" "$(_st_vpn_json)" } _st_secs_json() { local s sep="" k i lv sel vpn ifs age fl for s in $(_st_secs); do k="$(_st_sec_kind "$s")"; i="$(_st_sec_iface "$s")" lv=false; _st_sec_live "$s" && lv=true sel="$(_st_sec_with "$s" _st_sel_raw | awk '{ printf "%s\"%s\"", (n++ ? "," : ""), $0 }')" vpn=null; ifs=null [ "$k" = vpn ] && vpn="$(_st_sec_with "$s" _st_sec_vpn_json)" if [ "$k" = iface ] && [ -n "$i" ]; then age="$(_awg_hs_age "$i" 2>/dev/null)" fl=false; [ "$lv" = true ] && _st_owns "steer-spec" && _st_sec_with "$s" stl_state iface && [ "$STL_FAILED" = true ] && fl=true ifs="{\"up\":$([ -d "/sys/class/net/$i" ] && echo true || echo false),\"hs_age\":\"$age\",\"warp\":$(_awf_warp "$i" && echo true || echo false),\"failed\":$fl}" fi printf '%s{"id":"%s","name":"%s","kind":"%s","iface":"%s","live":%s,"off":%s,"services":[%s],"devices":[%s],"vpn":%s,"ifs":%s}' "$sep" "$s" "$(esc "$(_st_sec_name "$s")")" "$k" "$(esc "$i")" "$lv" "$(_st_sec_off "$s" && echo true || echo false)" "$sel" "$(_st_sec_with "$s" _st_devs_json)" "$vpn" "$ifs" sep="," done } _st_secs_url() { local s; for s in $(_st_secs); do [ "$(_st_sec_kind "$s")" = vpn ] && [ -s "$ST_SEC_DIR/$s/sub.url" ] && echo "$s"; done; } do_steer_sub_update_all() { local s rc=0 if [ -s "$ST_SUB_URL" ]; then do_steer_sub_update auto || rc=1; fi for s in $(_st_secs_url); do _rb_say "Секция «$(_st_sec_name "$s")»" _st_sec_with "$s" do_steer_sub_update auto || rc=1 done return $rc } _st_secs_ping_tick() { local s iv for s in $(_st_secs_vpn); do iv="$(_st_sec_with "$s" _st_pick_iv)" [ -f "$ST_SEC_DIR/$s/sub.ping" ] && [ -z "$(find "$ST_SEC_DIR/$s/sub.ping" -mmin +$((iv - 1)) 2>/dev/null)" ] && continue _st_sec_with "$s" steer_ping_tick done } _st_secs_vpn_watch() { local s; for s in $(_st_secs_vpn); do _st_sec_with "$s" _st_vpn_watch; done; } _st_vpn_devs_all() { local s; _st_sec_with main eval 'echo $STL_VPN_DEVS'; for s in $(_st_secs); do [ "$(_st_sec_kind "$s")" = vpn ] && _st_sec_with "$s" eval 'echo $STL_VPN_DEVS'; done; } _st_feats_all() { local s; { _st_sec_with main _st_use_vpn && _st_sec_with main _st_sub_feats; for s in $(_st_secs_vpn); do _st_sec_with "$s" _st_sub_feats; done; } | tr ' ' '\n' | grep . | awk '!x[$0]++' | tr '\n' ' '; } _st_sec_new_id() { local n=2; while [ "$n" -le 9 ]; do [ -d "$ST_SEC_DIR/s$n" ] || { echo "s$n"; return 0; }; n=$((n + 1)); done; return 1; } _st_sec_count() { _st_secs | grep -c .; } _st_sec_vpn_count() { local n=0 s; _st_sec_with main _st_use_vpn && n=1; for s in $(_st_secs); do [ "$(_st_sec_kind "$s")" = vpn ] && n=$((n + 1)); done; echo "$n"; } _st_ping_iv1() { [ "$(_st_pick)" = ping ] && [ -s "$ST_SUB" ] && _st_pick_iv; } _st_ping_iv_any() { local s { _st_sec_with main _st_ping_iv1; for s in $(_st_secs); do [ "$(_st_sec_kind "$s")" = vpn ] && _st_sec_with "$s" _st_ping_iv1; done; } | sort -n | head -n1 } _st_ping_cron() { local iv spec want="" had="" grep -qF "$ST_PING_TAG" "$CRON_FILE" 2>/dev/null && had=1 iv="$(_st_ping_iv_any)" if [ -n "$iv" ] && _st_installed; then case "$iv" in 60) spec="7 * * * *" ;; 180) spec="7 */3 * * *" ;; *) spec="*/$iv * * * *" ;; esac want="$spec /opt/zapret-manager-luci/backend.sh steer_action sub_ping_tick x >/dev/null 2>&1 $ST_PING_TAG" grep -qxF "$want" "$CRON_FILE" 2>/dev/null && return 0 fi [ -z "$want" ] && [ -z "$had" ] && return 0 mkdir -p "$(dirname "$CRON_FILE")"; touch "$CRON_FILE" sed -i "\\|$ST_PING_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_ping_measure() { local t dir hid i proto pidx fidx name rest p hp k n=0 hy=0 f ms j tab kd [ -s "$ST_SUB" ] && _st_vpn_ok && t="$(_st_nodes)" || return 1 command -v curl >/dev/null 2>&1 || return 1 tab="$(printf '\t')" _st_hp_build hid=" $(cut -f1,5 "$t" | _zm_excl_hits "$ST_SUB_EXCL" "$ST_SUB_HIDE" | tr '\n' ' ') " dir="$ST_RUN/ping.$$"; rm -rf "$dir"; mkdir -p "$dir" while IFS="$tab" read -r i proto pidx fidx name rest; do case "$i" in ''|*[!0-9]*) continue ;; esac case "$hid" in *" $i "*) continue ;; esac printf '%s\n' "$name" > "$dir/$i.n" if [ "$proto" = hysteria2 ]; then echo "hy $fidx" > "$dir/$i.k"; continue; fi p="$(_stl_mod_of "$proto")" hp="$(awk -F'\t' -v m="$p" -v n="$fidx" '$1 == m && $2 == n { print $3, $4; exit }' "$ST_SUB_NODES.hp" 2>/dev/null)" [ -n "$hp" ] || { echo none > "$dir/$i.k"; continue; } echo "$hp" > "$dir/$i.k" echo "$p $fidx" > "$dir/$i.m" _st_tcp_one "${hp% *}" "${hp#* }" 3 "$dir/$i" & n=$((n + 1)); [ $((n % 16)) = 0 ] && wait done < "$t" wait n=0 for f in "$dir"/*.k; do [ -f "$f" ] || continue i="${f##*/}"; i="${i%.k}"; read -r k < "$f" case "$k" in hy*|none) continue ;; esac [ -s "$dir/$i" ] && continue _st_tcp_one "${k% *}" "${k#* }" 6 "$dir/$i" & n=$((n + 1)); [ $((n % 16)) = 0 ] && wait done wait if stl_has hy2; then for f in "$dir"/*.k; do [ -f "$f" ] || continue read -r k < "$f" case "$k" in hy*) ;; *) continue ;; esac [ "$hy" -ge 6 ] && break hy=$((hy + 1)) i="${f##*/}"; i="${i%.k}" ( j="$(stl_probe "$ST_SUB" hysteria2 "${k#hy }")" if [ "$(printf '%s' "$j" | jsonfilter -e '@.results[0].ok' 2>/dev/null)" = true ]; then ms="$(printf '%s' "$j" | jsonfilter -e '@.results[0].handshake_ms' 2>/dev/null)" case "$ms" in ''|*[!0-9]*|0) ms="$(printf '%s' "$j" | jsonfilter -e '@.results[0].ttfb_ms' 2>/dev/null)" ;; esac case "$ms" in ''|*[!0-9]*|0) ms=1 ;; esac echo "$ms" > "$dir/$i" else : > "$dir/$i.dead"; fi ) & [ $((hy % 2)) = 0 ] && wait done wait fi if [ "$(_st_lat_mode)" = real ]; then for f in "$dir"/*.m; do [ -f "$f" ] || continue i="${f##*/}"; i="${i%.m}" [ -s "$dir/$i" ] && printf '%s %s\n' "$(cat "$dir/$i")" "$i" done | sort -n | head -n 8 | while read -r ms i; do echo "$i"; done > "$dir/real.list" if [ -n "$1" ]; then for f in "$dir"/*.m; do [ -f "$f" ] || continue i="${f##*/}"; i="${i%.m}" [ -s "$dir/$i" ] && [ "$(cat "$dir/$i.n")" = "$1" ] || continue grep -qx "$i" "$dir/real.list" || echo "$i" >> "$dir/real.list" break done fi n=0 while read -r i; do [ -n "$i" ] || continue read -r p k < "$dir/$i.m" stl_has "$p" || continue ( j="$(stl_probe "$ST_SUB" "$p" "$k")" okv="$(printf '%s' "$j" | jsonfilter -e '@.results[0].ok' 2>/dev/null)" if [ "$okv" = true ]; then ms="$(printf '%s' "$j" | jsonfilter -e '@.results[0].ttfb_ms' 2>/dev/null)" case "$ms" in ''|*[!0-9]*|0) ms="$(printf '%s' "$j" | jsonfilter -e '@.results[0].handshake_ms' 2>/dev/null)" ;; esac case "$ms" in ''|*[!0-9]*|0) ms=1 ;; esac echo "$ms" > "$dir/$i"; echo r > "$dir/$i.kind" elif [ "$okv" = false ]; then echo b > "$dir/$i.kind"; fi ) & n=$((n + 1)); [ $((n % 2)) = 0 ] && wait done < "$dir/real.list" wait for f in "$dir"/*.m; do [ -f "$f" ] || continue i="${f##*/}"; i="${i%.m}" [ -s "$dir/$i" ] && [ ! -f "$dir/$i.kind" ] && echo t > "$dir/$i.kind" done fi for f in $(ls "$dir"/*.n 2>/dev/null | sed 's#.*/##; s#\.n$##' | sort -n); do read -r k < "$dir/$f.k" kd=""; [ -f "$dir/$f.kind" ] && read -r kd < "$dir/$f.kind" if [ "$kd" = b ]; then ms=0 elif [ -s "$dir/$f" ]; then ms="$(cat "$dir/$f")" elif [ "$k" = none ] || { [ "${k%% *}" = hy ] && [ ! -f "$dir/$f.dead" ]; }; then ms=-1 else ms=0; fi printf '%s\t%s\t%s\n' "$(cat "$dir/$f.n")" "$ms" "$kd" done > "$ST_SUB_PING.tmp" rm -rf "$dir" mkdir -p "$ST_DIR" mv -f "$ST_SUB_PING.tmp" "$ST_SUB_PING" [ -s "$ST_SUB_PING" ] } _st_ping_order() { local old new [ -s "$ST_SUB_PING" ] || return 1 old="$(head -n1 "$ST_SUB_ORDER" 2>/dev/null)" ZM_CUR="${1:-$old}" awk -F'\t' -v tol="$(_st_pick_tol)" ' { n[NR] = $1; m[NR] = $2 + 0; if ($3 == "t" && m[NR] > 0) m[NR] += 100000 } END { c = 0 for (i = 1; i <= NR; i++) if (m[i] > 0) a[++c] = i for (i = 2; i <= c; i++) { x = a[i]; j = i - 1; while (j > 0 && m[a[j]] > m[x]) { a[j + 1] = a[j]; j-- } a[j + 1] = x } cur = ENVIRON["ZM_CUR"]; h = 0 if (c && cur != "") for (i = 1; i <= c; i++) if (n[a[i]] == cur && m[a[i]] <= m[a[1]] + tol) { h = a[i]; break } if (h) print n[h] for (i = 1; i <= c; i++) if (a[i] != h) print n[a[i]] for (i = 1; i <= NR; i++) if (m[i] < 0) print n[i] for (i = 1; i <= NR; i++) if (m[i] == 0) print n[i] }' "$ST_SUB_PING" > "$ST_SUB_ORDER.tmp" mv -f "$ST_SUB_ORDER.tmp" "$ST_SUB_ORDER" new="$(head -n1 "$ST_SUB_ORDER")" [ "$new" != "$old" ] } _st_ping_line() { awk -F'\t' -v w="$1" '$1 == w { print $2; exit }' "$ST_SUB_PING" 2>/dev/null; } _st_ping_refresh() { local cur="" total ok best ms blk _rb_say "Замеряем пинг узлов подписки" _st_owns "steer-spec" && stl_running && stl_state vpn >/dev/null 2>&1 && [ "$STL_UP" = true ] && cur="$(_st_active_key)" if ! _st_ping_measure "$cur"; then _rb_warn "Пинг замерить не удалось — узлы идут по порядку подписки"; rm -f "$ST_SUB_ORDER"; return 1; fi total="$(grep -c . "$ST_SUB_PING")"; ok="$(awk -F'\t' '$2 > 0' "$ST_SUB_PING" | grep -c .)" blk="$(awk -F'\t' '$3 == "b"' "$ST_SUB_PING" | grep -c .)" _st_ping_order "$cur" best="$(head -n1 "$ST_SUB_ORDER")"; ms="$(_st_ping_line "$best")" [ "$blk" -gt 0 ] && echo " • Порт открыт, но трафик не идёт (похоже, блокирует провайдер): $blk — эти узлы в конце" if [ "$ok" -gt 0 ]; then echo " ✓ Отвечают $ok из $total, первым — «$best» (${ms} мс)" else _rb_warn "Ни один узел не ответил на пинг — ядро steer переберёт их само по порядку"; fi return 0 } _st_ping_ready() { [ "$(_st_pick)" = ping ] && [ -s "$ST_SUB" ] && [ ! -s "$ST_SUB_NODE" ] && _st_use_vpn && _st_installed && [ ! -f "$ST_OFF" ] && [ -z "$(_st_blocker)" ] && _st_owns "steer-spec" && _st_work_main } steer_ping_tick() { local lk="$ST_RUN/ping.lock" cur="" best _st_ping_ready || return 0 _st_running && return 0 mkdir -p "$ST_RUN" mkdir "$lk" 2>/dev/null || { [ -n "$(find "$lk" -maxdepth 0 -mmin +20 2>/dev/null)" ] && rmdir "$lk" 2>/dev/null; return 0; } stl_running && stl_state vpn >/dev/null 2>&1 && [ "$STL_UP" = true ] && cur="$(_st_active_key)" if _st_ping_measure "$cur" && _st_ping_order "$cur" && ! _st_running && _st_ping_ready; then best="$(head -n1 "$ST_SUB_ORDER")" if _st_spec_quick >/dev/null 2>&1; then logger -t zapret-manager "Steer: по пингу первым стал узел «$best» ($(_st_ping_line "$best") мс)" else logger -t zapret-manager "Steer: узел по пингу «$best» применить не удалось"; fi fi rmdir "$lk" 2>/dev/null return 0 } do_steer_ping() { _st_phase sub rm -f "$ST_STOP_FLAG" _st_ping_cron if [ "$(_st_pick)" != ping ]; then rm -f "$ST_SUB_ORDER" _rb_say "Автовыбор: первый рабочий узел по порядку подписки" else _rb_say "Автовыбор по пингу: замер раз в $(_st_pick_iv) мин, допуск $(_st_pick_tol) мс" _st_ping_refresh fi if [ -s "$ST_SUB_NODE" ]; then _rb_say "Готово. Сейчас закреплён узел $(_st_node_label) — автовыбор заработает, когда выберете «Авто»" return 0 fi if _st_use_vpn && _st_installed && [ ! -f "$ST_OFF" ] && [ -z "$(_st_blocker)" ] && _st_work; then do_steer_switch -; return $? fi _rb_say "Готово: сохранено — применится, когда сервисы пойдут через VPN" } steer_sub_probe() { local out t p f case "$1" in ''|*[!0-9]*) echo '{"ok":false,"error":"неверный номер узла"}'; return 1 ;; esac [ -s "$ST_SUB" ] && _st_vpn_ok && t="$(_st_nodes)" || { echo '{"ok":false,"error":"подписки нет"}'; return 1; } set -- $(awk -F'\t' -v g="$1" '$1 == g { print $2, $4; exit }' "$t") [ -n "$1" ] || { echo '{"ok":false,"error":"узла нет в подписке"}'; return 1; } p="$(_stl_mod_of "$1")"; f="$2" stl_has "$p" || { echo '{"ok":false,"error":"модуль ядра steer для этого протокола не установлен"}'; return 1; } out="$(stl_probe "$ST_SUB" "$p" "$f")" case "$out" in '{'*) printf '%s\n' "$out" ;; *) echo '{"ok":false,"error":"проверка не удалась"}' ;; esac } _st_sec_label_ok() { printf '%s' "$1" | tr -d '"\\\000-\037' | cut -c1-120 | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'; } steer_sec_action() { local action="$1" mode="$2" id k i n d case "$action" in sec_add) [ -z "$(_st_blocker)" ] || { echo '{"error":"ядро steer сейчас настраивает не Zapret Manager"}'; return 1; } k="${mode%%|*}"; mode="${mode#*|}"; i="${mode%%|*}"; n="${mode#*|}" [ "$(_st_sec_count)" -lt $((ST_SEC_MAX - 1)) ] || { printf '{"error":"%s"}\n' "$(esc "секций может быть не больше $ST_SEC_MAX")"; return 1; } _st_sec_kind_check "$k" "$i" "" || return 1 id="$(_st_sec_new_id)" || { echo '{"error":"нет свободного номера секции"}'; return 1; } d="$ST_SEC_DIR/$id" mkdir -p "$d" echo "$k" > "$d/kind" [ "$k" = iface ] && echo "$i" > "$d/iface" n="$(_st_sec_label_ok "$n")"; [ -n "$n" ] && printf '%s\n' "$n" > "$d/name" { _st_secs; echo "$id"; } | awk '!x[$0]++' > "$ST_SEC_LIST.tmp" && mv -f "$ST_SEC_LIST.tmp" "$ST_SEC_LIST" printf '{"ok":true,"id":"%s"}\n' "$id" ;; sec_name) id="${mode%%|*}"; n="$(_st_sec_label_ok "${mode#*|}")" if [ "$id" = main ]; then d="$ST_SEC_DIR/main.name"; mkdir -p "$ST_SEC_DIR" else _st_sec_ok "$id" || { echo '{"error":"нет такой секции — обновите страницу"}'; return 1; }; d="$ST_SEC_DIR/$id/name"; fi if [ -n "$n" ]; then printf '%s\n' "$n" > "$d"; else rm -f "$d"; fi printf '{"ok":true}\n' ;; sec_kind) _st_running && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } [ -z "$(_st_blocker)" ] || { echo '{"error":"ядро steer сейчас настраивает не Zapret Manager"}'; return 1; } id="${mode%%|*}"; mode="${mode#*|}"; k="${mode%%|*}"; i="${mode#*|}"; [ "$i" = "$mode" ] && i="" _st_sec_ok "$id" || { echo '{"error":"нет такой секции — обновите страницу"}'; return 1; } _st_sec_kind_check "$k" "$i" "$id" || return 1 d="$ST_SEC_DIR/$id" echo "$k" > "$d/kind" if [ "$k" = iface ]; then echo "$i" > "$d/iface"; else rm -f "$d/iface"; fi _st_ping_cron if _st_installed && [ ! -f "$ST_OFF" ] && _st_owns "steer-spec" && _st_sec_work "$id"; then job_start steer do_steer_apply else printf '{"ok":true,"saved":true}\n'; fi ;; sec_off) _st_running && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } [ -z "$(_st_blocker)" ] || { echo '{"error":"ядро steer сейчас настраивает не Zapret Manager"}'; return 1; } id="${mode%%|*}"; k="${mode#*|}" case "$k" in 0|1) ;; *) echo '{"error":"неизвестное значение"}'; return 1 ;; esac [ -n "$(_st_secs)" ] || { echo '{"error":"секций нет — выключите Steer целиком"}'; return 1; } [ "$id" = main ] || _st_sec_ok "$id" || { echo '{"error":"нет такой секции — обновите страницу"}'; return 1; } if [ "$id" = main ]; then d="$ST_SEC_DIR/main.off"; else d="$ST_SEC_DIR/$id/off"; fi if [ "$k" = 1 ]; then n=0 for i in main $(_st_secs); do [ "$i" = "$id" ] || _st_sec_off "$i" || n=$((n + 1)); done [ "$n" -gt 0 ] || { echo '{"error":"это последняя включённая секция — чтобы выключить всё, нажмите «Выключить Steer целиком»"}'; return 1; } [ -f "$d" ] && { printf '{"ok":true,"saved":true}\n'; return 0; } mkdir -p "$ST_SEC_DIR"; : > "$d" logger -t zapret-manager "Steer: секция «$(_st_sec_name "$id")» выключена" else [ -f "$d" ] || { printf '{"ok":true,"saved":true}\n'; return 0; } rm -f "$d" logger -t zapret-manager "Steer: секция «$(_st_sec_name "$id")» включена" fi _st_ping_cron if _st_installed && [ ! -f "$ST_OFF" ] && _st_owns "steer-spec"; then job_start steer do_steer_apply elif _st_installed && [ ! -f "$ST_OFF" ] && [ "$k" = 0 ]; then job_start steer do_steer_apply else printf '{"ok":true,"saved":true}\n'; fi ;; sec_del) _st_running && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } [ -z "$(_st_blocker)" ] || { echo '{"error":"ядро steer сейчас настраивает не Zapret Manager"}'; return 1; } id="$mode" _st_sec_ok "$id" || { echo '{"error":"нет такой секции — обновите страницу"}'; return 1; } k=""; _st_sec_work "$id" && k=1 _st_secs | grep -vx "$id" > "$ST_SEC_LIST.tmp"; mv -f "$ST_SEC_LIST.tmp" "$ST_SEC_LIST" [ -s "$ST_SEC_LIST" ] || { rm -f "$ST_SEC_LIST"; [ -f "$ST_SEC_DIR/main.off" ] && { rm -f "$ST_SEC_DIR/main.off"; k=1; }; } rm -rf "${ST_SEC_DIR:?}/$id" rm -f "$JOBS_DIR/steer.vpnprobe.$id" "$JOBS_DIR/steer.vpnwatch.$id" "$JOBS_DIR/steer.hometick.$id" "$JOBS_DIR/steer.holdlog.$id" _st_ping_cron if [ -n "$k" ] && _st_installed && [ ! -f "$ST_OFF" ] && _st_owns "steer-spec"; then job_start steer do_steer_apply else _st_owns "steer-spec" && _st_secs_fw >/dev/null 2>&1 printf '{"ok":true,"saved":true}\n' fi ;; *) echo '{"error":"неизвестное действие"}'; return 1 ;; esac } _st_sec_kind_check() { local k="$1" i="$2" id="$3" n case "$k" in warp) ;; vpn) n="$(_st_sec_vpn_count)" [ -n "$id" ] && [ "$(_st_sec_kind "$id")" = vpn ] && n=$((n - 1)) [ "$n" -lt "$ST_SEC_VPN_MAX" ] || { printf '{"error":"%s"}\n' "$(esc "VPN-секций может быть не больше $ST_SEC_VPN_MAX — каждая держит свой туннель и память роутера")"; return 1; } ;; iface) printf '%s' "$i" | grep -qE '^[A-Za-z0-9_]{1,15}$' || { echo '{"error":"выберите интерфейс AmneziaWG"}'; return 1; } _awg_is_steer "$i" && { echo '{"error":"это туннель самого Steer — выберите интерфейс с вкладки AmneziaWG"}'; return 1; } [ "$(uci -q get "network.$i.proto")" = amneziawg ] || { echo '{"error":"такого интерфейса AmneziaWG нет"}'; return 1; } ;; *) echo '{"error":"неизвестный способ подключения"}'; return 1 ;; esac return 0 } steer_action() { local action="$1" mode="$2" w case "$action" in sec_add|sec_name|sec_kind|sec_del|sec_off) steer_sec_action "$action" "$mode"; return ;; esac case "$mode" in "~s"[2-9]"~"*) w="${mode#?}"; w="${w%%\~*}"; mode="${mode#?$w?}" _st_sec_ok "$w" || { echo '{"error":"нет такой секции — обновите страницу"}'; return 1; } case "$action" in lists|devs_set|list_set|list_get|list_reset|svc_find|svc_dom|sub_status|sub_probe|sub_tcp|lat_mode|node_pol|sub_home|sub_links_get|sub_set|sub_links|sub_update|sub_remove|sub_node|sub_filter|sub_hide|sub_pick|sub_ping) ;; *) echo '{"error":"это действие общее для всех секций"}'; return 1 ;; esac ( _st_bind "$w"; steer_action "$action" "$mode" ); return $? ;; esac 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|warp_iface|ext_pick|warp_pick|onfail|dnsmode|realip) _st_running && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } case "$action" in warp_iface|ext_pick|warp_restart|warp_mode|warp_own) _job_alive awg && { echo '{"error":"на вкладке AmneziaWG идёт операция — дождитесь её окончания"}'; return 1; } ;; esac [ "$action" = remove ] || [ -z "$(_st_blocker)" ] || { echo '{"error":"ядро steer сейчас настраивает не Zapret Manager — панель его не трогает"}'; return 1; } case "$action" in warp_endpoint|warp_recreate|warp_fix|warp_fixkeys|warp_pick) _st_warp_ext && { echo '{"error":"в режиме «Интерфейс AmneziaWG» туннель чинится на вкладке AmneziaWG или кнопкой «Подобрать точку входа»"}'; return 1; } _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" ( umask 077; printf '%s\n' "$mode" | tr -d '\r' > "$ST_DIR/warp.pending" ) chmod 600 "$ST_DIR/warp.pending" if ! _st_own_conf_ok "$ST_DIR/warp.pending"; then rm -f "$ST_DIR/warp.pending" echo '{"error":"в конфиге нет PrivateKey, Address, PublicKey или Endpoint (адрес:порт) — прежний туннель не тронут"}' return 1 fi 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 ;; warp_iface) printf '%s' "$mode" | grep -qE '^[A-Za-z0-9_]{1,15}$' || { echo '{"error":"неверное имя интерфейса"}'; return 1; } _awg_is_steer "$mode" && { echo '{"error":"это туннель самого Steer — выберите интерфейс, созданный на вкладке AmneziaWG"}'; return 1; } [ "$(uci -q get "network.$mode.proto")" = amneziawg ] || { echo '{"error":"такого интерфейса AmneziaWG нет"}'; return 1; } job_start steer do_steer_warp_iface "$mode" ;; ext_pick) _st_warp_ext || { echo '{"error":"Steer сейчас работает не через интерфейс AmneziaWG"}'; return 1; } [ "$(uci -q get "network.$(_awg_peer_sec "$(_st_ext_if)").public_key")" = "$AWG_WARP_PEER" ] || { echo '{"error":"подбор точки входа есть только для туннелей WARP"}'; return 1; } job_start steer do_steer_ext_pick ;; dnsmode|realip) if [ "$action" = dnsmode ]; then case "$mode" in tun|router) ;; *) echo '{"error":"неизвестный режим"}'; return 1 ;; esac else case "$mode" in on|off) ;; *) echo '{"error":"неизвестный режим"}'; return 1 ;; esac fi if _st_installed && [ ! -f "$ST_OFF" ] && _st_owns "steer-spec"; then job_start steer do_steer_dnsopt "$action" "$mode" else _st_dnsopt_save "$action" "$mode"; printf '{"ok":true,"saved":true}\n' fi ;; onfail) case "$mode" in drop|direct) ;; *) echo '{"error":"неизвестный режим"}'; return 1 ;; esac if _st_installed && [ ! -f "$ST_OFF" ] && _st_owns "steer-spec"; then job_start steer do_steer_onfail "$mode" else mkdir -p "$ST_DIR" if [ "$mode" = drop ]; then echo drop > "$ST_DIR/onfail"; else rm -f "$ST_DIR/onfail"; fi printf '{"ok":true,"saved":true}\n' fi ;; warp_pick) _st_warp_on || { echo '{"error":"WARP ещё не подключён"}'; return 1; } case "$mode" in latency) job_start steer do_steer_warp_pick latency ;; order:*) mode="$(printf '%s' "${mode#order:}" | tr ',' ' ')" for w in $mode; do printf '%s' "$w" | grep -qE '^zmwarp[0-9]?$' || { echo '{"error":"неверное имя туннеля"}'; return 1; }; done [ -n "$(echo $mode)" ] || { echo '{"error":"пустой порядок туннелей"}'; return 1; } job_start steer do_steer_warp_pick order "$mode" ;; *) echo '{"error":"неизвестный режим"}'; return 1 ;; esac ;; 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) _st_running && return 0 [ -s "$ST_SUB_URL" ] || [ -n "$(_st_secs_url)" ] || return 0 job_start steer do_steer_sub_update_all >/dev/null ;; sub_probe) steer_sub_probe "$mode" ;; sub_tcp) steer_sub_tcp "$mode" ;; lat_mode) case "$mode" in real) mkdir -p "$ST_DIR"; echo real > "$ST_LAT_MODE" ;; fast) rm -f "$ST_LAT_MODE" ;; '') ;; *) echo '{"error":"неизвестный способ проверки"}'; return 1 ;; esac printf '{"ok":true,"mode":"%s"}\n' "$(_st_lat_mode)" ;; node_pol) case "$mode" in back) rm -f "$ST_NODE_POL" ;; switch|hold) mkdir -p "$ST_DIR"; echo "$mode" > "$ST_NODE_POL"; rm -f "$ST_SUB_HOME" ;; '') ;; *) echo '{"error":"неизвестная настройка"}'; return 1 ;; esac printf '{"ok":true,"pol":"%s"}\n' "$(_st_node_pol)" ;; sub_home) rm -f "$ST_SUB_HOME" "$ST_SUB_FELL" printf '{"ok":true}\n' ;; 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 -iE "$STL_LINK_RE" "$ST_SUB")")" ;; sub_ping_tick) steer_ping_tick; _st_secs_ping_tick; printf '{"ok":true}\n' ;; sub_set|sub_links|sub_update|sub_remove|sub_exit|sub_node|sub_filter|sub_hide|sub_pick|sub_ping) _st_running && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } [ -z "$(_st_blocker)" ] || { echo '{"error":"ядро steer сейчас настраивает не Zapret Manager — панель его не трогает"}'; return 1; } steer_sub_action "$action" "$mode" ;; list_get) steer_list_get "$mode" ;; svc_dom) steer_svc_dom "$mode" ;; svc_find) steer_svc_find "$mode" ;; svc_domflags) steer_svc_domflags ;; devices) steer_devices ;; devs_set) _st_running && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } [ -z "$(_st_blocker)" ] || { echo '{"error":"ядро steer сейчас настраивает не Zapret Manager — панель его не трогает"}'; return 1; } _st_devs_save "$mode" if _st_installed && [ ! -f "$ST_OFF" ] && [ "$(_st_exit)" != none ] && { _st_work || _st_owns "steer-spec"; }; then job_start steer do_steer_devs else printf '{"ok":true,"saved":true}\n'; fi ;; 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; } [ -z "$(_st_blocker)" ] || { echo '{"error":"ядро steer сейчас настраивает не Zapret Manager — панель его не трогает"}'; 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' ;; conns) stl_present && stl_running || { echo '{"error":"Steer не запущен"}'; return 1; } w="$(_stl_t 15 steer conns 2>/dev/null | tr -d '\n\r')" case "$w" in '{'*'"conns"'*) ;; *) echo '{"error":"ядро Steer не отдало список соединений — нужна версия 2.0 или новее"}'; return 1 ;; esac if [ "$(printf '%s' "$w" | sed 's/}, *{/}\n{/g' | wc -l)" -gt 400 ]; then printf '%s' "$w" | sed 's/}, *{/}\n{/g' | head -n 400 | tr '\n' ',' | sed 's/,$//' w="$(printf '%s' "$w" | sed -n 's/.*"total": *\([0-9][0-9]*\).*/\1/p')" printf '],"shown":400,"total":%s,"truncated":true}\n' "${w:-400}" else printf '%s\n' "$w"; fi ;; dnslog) stl_present && stl_running || { echo '{"error":"Steer не запущен"}'; return 1; } w="$(_stl_t 15 steer dns-log 2>/dev/null | tr -d '\n\r')" case "$w" in '{'*'"names"'*) ;; *) echo '{"error":"ядро Steer не отдало журнал имён — нужна версия 2.0 или новее"}'; return 1 ;; esac mkdir -p "$ST_RUN" printf '%s\n' "$w" > "$ST_RUN/dnslog.$$" _st_dnslog_fix "$ST_RUN/dnslog.$$" 2>/dev/null | grep . || printf '%s\n' "$w" rm -f "$ST_RUN/dnslog.$$" ;; 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 && _st_work && [ ! -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 && _st_work && [ ! -f "$ST_OFF" ]; then warp=off wn=1 while [ "$wn" -le "$ST_WARP_N" ]; do wi="$(_st_wif "$wn")" if _st_wnet "$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_ping_cron _st_owns "steer-spec" && [ ! -f "$ST_OFF" ] && [ -z "$(_st_blocker)" ] && 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_ver "$1" else _opkg_ver "$1" fi } _awg_installed() { command -v awg >/dev/null 2>&1 && { _awg_kmod_have || _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_owned() { grep -qxF "$1" "$AWG_DIR/owned" 2>/dev/null; } _awg_fk_secs() { local s [ -s /etc/config/netshift ] || return 0 for s in $(uci -q show netshift 2>/dev/null | sed -n "s/^netshift\.\([^.=]*\)\.interface='$1'\$/\1/p"); do [ "$(uci -q get "netshift.$s.connection_type")" = vpn ] && echo "$s" done return 0 } _awg_fk_live() { _fk_installed && _fk_enabled && [ -n "$(_awg_fk_secs "$1")" ]; } _awg_down() { local i="$1" w=0 ifdown "$i" >/dev/null 2>&1 while [ -d "/sys/class/net/$i" ] && [ "$w" -lt 5 ]; do sleep 1; w=$((w + 1)); done [ -d "/sys/class/net/$i" ] && ip link del "$i" >/dev/null 2>&1 return 0 } _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 if [ -f /lib/netifd/proto/amneziawg.sh ]; then st=1; else st=3; fi 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="$(_zm_zone_of_net "$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","owned":%s,"forkozz":%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" "$(_awg_owned "$i" && echo true || echo false)" "$(_awg_fk_live "$i" && echo true || echo false)" "$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 x list="" sep="" running=false conf=false ep="" mask="" 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)" mask="$(sed -n 's/^[[:space:]]*I1[[:space:]]*=[[:space:]]*//p' "$MIXOMO_WARP_CONF" | head -n1)" [ -n "$mask" ] && mask="$(_awg_mask_name "$mask")" 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","warp_mask":"%s","mihomo":%s,"endpoints":"%s","steer_own":%s,"steer_ext":"%s","steer_ifs":[%s],"steer_active":"%s","fk_name":"%s","fix":%s,"ifaces":[%s]}\n' \ "$running" "$(cat "$AWG_RUN/phase" 2>/dev/null)" "$(_awg_installed && echo true || echo false)" \ "$(esc "$(_awg_kmod_ver)")" "$(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")" "$(esc "$mask")" "$mih" "$AWG_ENDPOINTS" "$(_st_warp_own && echo true || echo false)" "$(_st_installed && _st_warp_ext && esc "$(_st_ext_if)")" "$(_st_installed && { _st_warp_ext && _st_ext_if; for x in $(_st_secs); do [ "$(_st_sec_kind "$x")" = iface ] && _st_sec_iface "$x" && echo; done; } | grep . | awk '!s[$0]++ { printf "%s\"%s\"", (n++ ? "," : ""), $0 }')" "$(_st_warp_on && _st_active_if)" "$(_fk_name)" "$(_awf_json)" "$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 all="$1" used="" left="" rc=0 echo remove > "$AWG_RUN/phase" for i in $(_awg_ifaces); do _awg_is_steer "$i" && [ -s "$ST_OWNED" ] && continue _awg_fk_live "$i" && used="$used $i" done if [ -n "$used" ] && _fk_foreign; then echo "ОШИБКА: через туннель${used} сейчас работает NetShift. Панель его не трогает — выберите в нём другое подключение или выключите его, потом удалите AmneziaWG" return 1 fi if [ -n "$used" ]; then _awg_say "Forkozz работает через туннель${used} — сначала выключаем Forkozz, чтобы сервисы не зависли" /etc/init.d/netshift disable >/dev/null 2>&1 _zm_run 90 $FK_REL /etc/init.d/netshift stop >/dev/null 2>&1 nft delete table inet "$FK_NFT" >/dev/null 2>&1 nft delete table inet "$FK_NFT_GUARD" >/dev/null 2>&1 _fk_dnsmasq_clean /etc/init.d/dnsmasq restart >/dev/null 2>&1 echo " ✓ Forkozz выключен, трафик идёт напрямую. Его настройки сохранены — выберите в нём другое подключение и включите снова" fi for i in $(_awg_ifaces); do _awg_is_steer "$i" && [ -s "$ST_OWNED" ] && continue if ! _awg_is_steer "$i" && ! _awg_owned "$i" && [ "$all" != all ]; then left="$left $i"; continue; fi _awg_say "Удаляем туннель $i с его пиром и зоной файрвола" _awg_delete "$i" echo " ✓ Туннель $i удалён" done if [ -n "$left" ]; then echo "ОШИБКА: туннели${left} созданы не панелью. Удалите их сами (Сеть → Интерфейсы) или отметьте их в окне удаления" return 1 fi rm -f "$AWF_MODE" "$AWF_STATE" "$AWF_SKIP"; _awf_cron _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 _zm_pkg_purge "$p" || rc=1 done _awg_say "Выгружаем модуль ядра и чистим файлы" rmmod amneziawg >/dev/null 2>&1 _awg_kmod_manual_rm [ -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 _zm_wipe "$AWG_DIR" /usr/bin/awg /usr/bin/awg-quick /lib/netifd/proto/amneziawg.sh || rc=1 _zm_rm_listed awg || rc=1 if [ -s "$MIXOMO_WARP_CONF" ]; then if _mx_installed; then echo " · $MIXOMO_WARP_CONF оставляем — он нужен Mixomo"; else rm -f "$MIXOMO_WARP_CONF"; fi fi rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* 2>/dev/null _rb_rpcd_ensure if [ "$ZM_NO_NETCHECK" = 1 ]; then if [ "$rc" = 0 ]; then echo " ✓ AmneziaWG удалён"; else echo "!! AmneziaWG удалён не полностью — что осталось, написано выше"; fi return "$rc" fi if _zm_inet_wait 9; then echo " ✓ Интернет на роутере работает" else echo "!! Интернет на роутере пока не отвечает — подождите минуту или перезагрузите роутер"; _zm_net_why; fi _zm_rm_done "$rc" "AmneziaWG" } _awg_valid_ep() { _zm_re "$1" '^(\[[0-9A-Fa-f:]+\]|[A-Za-z0-9.-]+):[0-9]{1,5}$'; } _awg_new_ok() { grep -q awg_random_trailers /lib/netifd/proto/amneziawg.sh 2>/dev/null; } AWF_MODE="$ZM_STATE_DIR/awgfix" AWF_SKIP="$ZM_STATE_DIR/awgfix.skip" AWF_STATE="$ZM_STATE_DIR/awgfix.state" AWF_LOG="$ZM_STATE_DIR/awgfix.log" AWF_TAG="# zm-awgfix" AWF_TRIES=4 _awf_min() { case "$(cat "$AWF_MODE" 2>/dev/null)" in 30) echo 30 ;; 60) echo 60 ;; 180) echo 180 ;; esac; } _awf_warp() { [ "$(uci -q get "network.$(_awg_peer_sec "$1").public_key")" = "$AWG_WARP_PEER" ]; } _awg_steer_sync() { local i="$1" col w=0 _st_mode_vars _st_uses_if "$i" || return 0 [ -f "$ST_OFF" ] && return 0 _st_running && [ "$2" != force ] && return 0 while [ "$w" -lt 15 ] && ! _st_warp_alive_if "$i"; do sleep 2; w=$((w + 1)); done _st_warp_alive_if "$i" || return 0 if ! { _st_warp_ext && [ "$(_st_ext_if)" = "$i" ]; }; then _st_kick >/dev/null 2>&1 echo " ✓ Секция Steer продолжает работать через $i" return 0 fi mkdir -p "$ST_DIR" 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_warp_geo "$i" _st_kick >/dev/null 2>&1 echo " ✓ Steer продолжает работать через $i${col:+ (колония $col)}" return 0 } _awf_ifaces() { local i; for i in $(_awg_ifaces); do _awg_is_steer "$i" && continue; _awf_warp "$i" && echo "$i"; done; } _awf_watched() { ! grep -qxF "$1" "$AWF_SKIP" 2>/dev/null; } _awf_get() { awk -v i="$1" -v f="$2" '$1 == i { print $f; exit }' "$AWF_STATE" 2>/dev/null; } _awf_put() { mkdir -p "$ZM_STATE_DIR" { grep -v "^$1 " "$AWF_STATE" 2>/dev/null; [ "$2" = - ] || echo "$1 $2 ${3:-0} ${4:-0} ${5:-dead}"; } > "$AWF_STATE.$$" mv -f "$AWF_STATE.$$" "$AWF_STATE" [ -s "$AWF_STATE" ] || rm -f "$AWF_STATE" } _awf_say() { mkdir -p "$ZM_STATE_DIR" { tail -n 19 "$AWF_LOG" 2>/dev/null; echo "$(date +%s)|$1"; } > "$AWF_LOG.$$" && mv -f "$AWF_LOG.$$" "$AWF_LOG" logger -t zapret-manager "Автопочинка WARP: $1" } _awf_alive() { local age [ -d "/sys/class/net/$1" ] || return 1 age="$(_awg_hs_age "$1")" [ -n "$age" ] && [ "$age" -lt 300 ] 2>/dev/null || 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 } _awf_check() { _awf_alive "$1" || { echo dead; return 0; } set -- $(_cf_meta "$1") if [ -n "$1" ] && _st_warp_is_ru "$1" "$3"; then echo "ru $1"; else echo ok; fi } _awf_ep() { local p; p="$(_awg_peer_sec "$1")"; printf '%s:%s' "$(uci -q get "network.$p.endpoint_host")" "$(uci -q get "network.$p.endpoint_port")"; } _awf_cron() { local want="" [ -n "$(_awf_min)" ] && want="*/10 * * * * /opt/zapret-manager-luci/backend.sh awg_action fix_tick x >/dev/null 2>&1 $AWF_TAG" if [ -n "$want" ]; then [ "$(grep -cF "$AWF_TAG" "$CRON_FILE" 2>/dev/null)" = 1 ] && grep -qxF "$want" "$CRON_FILE" && return 0 else grep -qF "$AWF_TAG" "$CRON_FILE" 2>/dev/null || return 0 fi mkdir -p "$(dirname "$CRON_FILE")"; touch "$CRON_FILE" sed -i "\\|$AWF_TAG|d" "$CRON_FILE" [ -n "$want" ] && echo "$want" >> "$CRON_FILE" /etc/init.d/cron restart >/dev/null 2>&1 return 0 } _awf_set() { case "$1" in off) rm -f "$AWF_MODE" "$AWF_STATE" "$AWG_RUN/fix.last" "$AWG_RUN/fix.nonet" ;; 30|60|180) mkdir -p "$ZM_STATE_DIR"; echo "$1" > "$AWF_MODE" ;; *) echo '{"error":"неверный интервал"}'; return 1 ;; esac _awf_cron [ "$1" = off ] || ( _awf_tick >/dev/null 2>&1 & ) printf '{"ok":true}\n' } _awf_skip() { local i="${1%%|*}" v="${1#*|}" _awf_ifaces | grep -qxF "$i" || { echo '{"error":"нет такого туннеля WARP"}'; return 1; } mkdir -p "$ZM_STATE_DIR" { grep -vxF "$i" "$AWF_SKIP" 2>/dev/null; [ "$v" = off ] && echo "$i"; } > "$AWF_SKIP.$$" mv -f "$AWF_SKIP.$$" "$AWF_SKIP" [ -s "$AWF_SKIP" ] || rm -f "$AWF_SKIP" [ "$v" = off ] && _awf_put "$i" - printf '{"ok":true}\n' } _awf_tick() { local lim now i since tries last all=1 any=0 pick="" age why lim="$(_awf_min)" [ -n "$lim" ] || return 0 _awg_installed || return 0 _job_alive awg && return 0 _job_alive steer && return 0 now="$(date +%s)" mkdir -p "$AWG_RUN"; echo "$now" > "$AWG_RUN/fix.last" for i in $(_awf_ifaces); do _awf_watched "$i" || continue [ "$(uci -q get "network.$i.auto")" = 0 ] && { _awf_put "$i" -; continue; } any=1 set -- $(_awf_check "$i") if [ "$1" = ok ]; then all=0 [ -n "$(_awf_get "$i" 2)" ] && { _awf_put "$i" -; _awf_say "$i снова работает"; } continue fi why="$1${2:+:$2}" [ "$1" = ru ] && all=0 since="$(_awf_get "$i" 2)"; tries="$(_awf_get "$i" 3)"; last="$(_awf_get "$i" 4)" if [ -z "$since" ]; then age="$(_awg_hs_age "$i")" if [ "$why" = dead ] && [ -n "$age" ] && [ "$age" -ge 300 ] 2>/dev/null && [ "$age" -lt 86400 ] 2>/dev/null; then since=$((now - age)); else since="$now"; fi tries=0; last=0 fi _awf_put "$i" "$since" "${tries:-0}" "${last:-0}" "$why" [ "${tries:-0}" -ge "$AWF_TRIES" ] && continue [ $((now - since)) -ge $((lim * 60)) ] || continue [ $((now - ${last:-0})) -ge $((lim * 60)) ] || continue [ -z "$pick" ] && pick="$i" done [ "$any" = 1 ] && [ -n "$pick" ] || return 0 if [ "$all" = 1 ] && ! _st_wfix_net_ok; then [ -f "$AWG_RUN/fix.nonet" ] || { touch "$AWG_RUN/fix.nonet"; _awf_say "нет интернета у роутера — починка отложена"; } return 0 fi rm -f "$AWG_RUN/fix.nonet" _zm_busy_job >/dev/null 2>&1 && return 0 tries="$(_awf_get "$pick" 3)"; since="$(_awf_get "$pick" 2)"; why="$(_awf_get "$pick" 5)" _awf_put "$pick" "$since" $(( ${tries:-0} + 1 )) "$now" "$why" job_start awg do_awg_fix "$pick" "$since" >/dev/null } do_awg_fix() { local i="$1" since="$2" tries mins keys="" bak="$AWG_RUN/fix.network" w=0 ok=0 old new why res="" mkdir -p "$AWG_RUN" echo autofix > "$AWG_RUN/phase" echo "$i" > "$AWG_RUN/fix.busy" tries="$(_awf_get "$i" 3)"; [ "${tries:-1}" -ge 3 ] && keys=1 why="$(_awf_get "$i" 5)" mins=$(( ($(date +%s) - ${since:-$(date +%s)}) / 60 )) old="$(_awf_ep "$i")" _awg_say "Автопочинка: $i $(case "$why" in ru:*) echo "работает через российский узел ${why#ru:} — блокировки через него не снимаются" ;; *) echo "не работает $mins мин" ;; esac) — $([ -n "$keys" ] && echo "получаем новые ключи и подбираем зарубежную точку входа" || echo "подбираем зарубежную точку входа") (попытка ${tries:-1} из $AWF_TRIES)" cp -f /etc/config/network "$bak" echo " · текущая настройка сохранена — если новая не заработает, вернём её" if [ -n "$keys" ]; then ZM_NOSYNC=1 do_awg_regen "$i" >/dev/null 2>&1 && echo " ✓ новые ключи получены" || echo "!! новые ключи получить не удалось" fi do_awg_pick "$i" 2>&1 | grep -E '^==> Готово|ОШИБКА' | sed 's/^==> Готово: / ✓ точка входа: /; s/^ОШИБКА: /!! /' echo autofix > "$AWG_RUN/phase" _awg_say "Проверяем, работает ли $i" ubus call "network.interface.$i" up >/dev/null 2>&1 while [ "$w" -lt 12 ]; do _awf_alive "$i" && { ok=1; break; } sleep 5; w=$((w + 1)) done [ "$ok" = 1 ] && res="$(_awf_check "$i")" rm -f "$AWG_RUN/fix.busy" new="$(_awf_ep "$i")" if [ "$res" = ok ]; then rm -f "$bak" _awf_put "$i" - echo " ✓ $i работает через зарубежный узел: точка входа $new" _awg_steer_sync "$i" _awf_say "$i $(case "$why" in ru:*) echo "шёл через российский узел ${why#ru:}" ;; *) echo "не работал $mins мин" ;; esac) — подобрана точка $new${keys:+, новые ключи}" _awg_say "Готово" return 0 fi case "$res" in ru*) if [ "${why%%:*}" != ru ]; then rm -f "$bak" _awf_put "$i" "$since" "${tries:-1}" "$(date +%s)" "ru:${res#ru }" echo "!! $i снова работает, но только через российский узел ${res#ru } — оставляем, это лучше, чем ничего; зарубежный поищем при следующей проверке" _awg_steer_sync "$i" _awf_say "$i: зарубежной точки не нашлось — работает через российский узел ${res#ru }, поищем снова через $(_awf_min) мин" return 1 fi ;; esac echo "!! с новой настройкой $i не стал лучше — возвращаем прежнюю" cat "$bak" > /etc/config/network rm -f "$bak" ubus call network reload >/dev/null 2>&1 sleep 2 ubus call "network.interface.$i" up >/dev/null 2>&1 _awg_steer_sync "$i" if [ "${tries:-1}" -ge "$AWF_TRIES" ]; then _awf_say "$i: $AWF_TRIES попытки не помогли — автопочинка для него остановлена до «Попробовать снова»" else _awf_say "$i: $(case "$res" in ru*) echo "нашлась только российская точка ${res#ru }" ;; *) echo "новая настройка не заработала" ;; esac) — вернули прежнюю, повторим через $(_awf_min) мин" fi return 1 } _awf_json() { local m out="" sep="" i since tries last why fixing="" sk="" t txt m="$(_awf_min)" if [ -s "$AWF_STATE" ]; then while read -r i since tries last why; do [ -n "$i" ] || continue out="$out$sep{\"name\":\"$(esc "$i")\",\"since\":${since:-0},\"tries\":${tries:-0},\"last\":${last:-0},\"why\":\"$(esc "${why:-dead}")\"}"; sep="," done < "$AWF_STATE" fi sep="" for i in $(cat "$AWF_SKIP" 2>/dev/null); do sk="$sk$sep\"$(esc "$i")\""; sep=","; done _job_alive awg && [ -s "$AWG_RUN/fix.busy" ] && fixing="$(cat "$AWG_RUN/fix.busy")" printf '{"mode":"%s","max":%s,"now":%s,"last_check":%s,"fixing":"%s","skip":[%s],"dead":[%s],"log":[' "${m:-off}" "$AWF_TRIES" "$(date +%s)" \ "$(awk '{ print $1 + 0; exit }' "$AWG_RUN/fix.last" 2>/dev/null | grep . || echo 0)" "$(esc "$fixing")" "$sk" "$out" sep="" [ -s "$AWF_LOG" ] && tail -n 5 "$AWF_LOG" | while IFS='|' read -r t txt; do printf '%s{"t":%s,"text":"%s"}' "$sep" "${t:-0}" "$(esc "$txt")"; sep="," done printf ']}' } 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_write_sip_conf() { mkdir -p "$(dirname "$MIXOMO_WARP_CONF")" printf '%s\n' \ "[Interface]" "PrivateKey = $1" "Address = ${3%/*}" "DNS = 1.1.1.1, 1.0.0.1" "MTU = 1280" \ "S1 = 0" "S2 = 0" "S3 = 0" "S4 = 0" "Jc = 4" "Jmin = 40" "Jmax = 70" \ "H1 = 1" "H2 = 2" "H3 = 3" "H4 = 4" "I1 = $AWG_SIP_I1" "I2 = $AWG_SIP_I2" \ "RandomTrailers = on" "DisableCookies = on" "" \ "[Peer]" "PublicKey = ${2:-$AWG_WARP_PEER}" "AllowedIPs = 0.0.0.0/0" "Endpoint = $4" "PersistentKeepalive = 25" \ > "$MIXOMO_WARP_CONF" chmod 600 "$MIXOMO_WARP_CONF" } _awg_keys_main() { local reg="$AWG_RUN/reg.$$.${ZM_REG_N:-0}.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.$$.${ZM_REG_N:-0}.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" sip="" path="" i 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" [ "$ep" = auto ] || sip=1 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" if [ -n "$sip" ]; then _awg_write_sip_conf "$G_PRIV" "$G_PEER" "$G_V4" "$ep" _awg_say "Маскировка: SIP-звонок" _awg_installed && ! _awg_new_ok && echo " AmneziaWG на роутере старше 3.1 — при создании интерфейса роутер возьмёт только то, что понимает. Обновите AmneziaWG, чтобы работали все параметры" else _awg_write_conf "$G_PRIV" "$G_PEER" "$G_V4" "$G_V6" "$ep" fi _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 if (n ~ /^(random_trailers|disable_cookies)$/) { lv = tolower(v); v = (lv == "on" || lv == "true" || lv == "yes" || lv == "1") ? 1 : 0 } 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 case "$o" in awg_random_trailers|awg_disable_cookies) [ "$v" = 1 ] && v=on || v=off ;; esac 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 v p zone w=0 age foreign="" 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" [ -n "$(uci -q get "network.$name")" ] && ! _awg_owned "$name" && foreign=1 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" if ! _awg_new_ok && [ -n "$(_awg_cv "$kv" interface randomtrailers)$(_awg_cv "$kv" interface disablecookies)" ]; then echo " AmneziaWG на роутере старше 3.1 — RandomTrailers и DisableCookies он не знает и пропустит. Туннель будет работать и без них" fi 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 [ -n "$foreign" ] || 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 [ -n "$ZM_NOSYNC" ] || _awg_steer_sync "$name" _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_pick_sync() { do_awg_pick "$1" || return 1; _awg_steer_sync "$1"; } do_awg_regen() { local i="$1" p host port ep warp=0 route k v have=0 f="$AWG_DIR/pending.conf" rc mkdir -p "$AWG_RUN" "$AWG_DIR" echo gen > "$AWG_RUN/phase" 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" >&2 rc=$? _awg_try_down "$AWG_TEST_IF" >&2 if [ "$rc" = 0 ]; then _awg_say "Проверка: точка $ep отвечает" >&2; else _rb_warn "Точка $ep не ответила — после создания подберите точку входа" >&2; fi 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_ownconf; 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_warp_ext && echo "через интерфейс $(_st_ext_if)" || echo "на своём конфиге ($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 _awg_down "$i" 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() { _zm_re "$1" '^[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 _awg_owned "$i" || ep="$ep $i" fi done [ -n "$ep" ] && [ "$mode" != all ] && { printf '{"error":"%s"}\n' "$(esc "туннели${ep} созданы не панелью — отметьте их в окне удаления или удалите сами (Сеть → Интерфейсы)")"; return 1; } _job_running forkop && { echo '{"error":"на вкладке Forkozz идёт операция — дождитесь её окончания"}'; return 1; } job_start awg do_awg_remove "$mode" ;; 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 ;; fix_set) _awf_set "$mode" ;; fix_skip) _awf_skip "$mode" ;; fix_reset) [ -s "$AWF_STATE" ] && awk '{ print $1, $2, 0, 0, ($5 == "" ? "dead" : $5) }' "$AWF_STATE" > "$AWF_STATE.$$" && mv -f "$AWF_STATE.$$" "$AWF_STATE" _awf_say "попытки сброшены вручную" ( _awf_tick >/dev/null 2>&1 & ) printf '{"ok":true}\n' ;; fix_tick) _awf_tick ;; 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_sync "$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 _st_uses_if "$i" && ( sleep 3; _awg_steer_sync "$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|up) [ "$action" = up ] && { uci -q delete "network.$mode.auto"; uci commit network; } ifup "$mode" >/dev/null 2>&1 _st_uses_if "$mode" && ( sleep 3; _awg_steer_sync "$mode" ) >/dev/null 2>&1 & ;; down) _awg_is_steer "$mode" && { echo '{"error":"туннели Steer выключаются на странице Steer"}'; return 1; } _st_uses_if "$mode" && { printf '{"error":"%s"}\n' "$(esc "через $mode работают сервисы Steer. Сначала выберите в Steer другой режим WARP или другое подключение секции")"; return 1; } _awg_fk_live "$mode" && { printf '{"error":"%s"}\n' "$(esc "через туннель $mode сейчас работает $(_fk_name). Сначала выключите $(_fk_name) или выберите в нём другое подключение")"; return 1; } uci set "network.$mode.auto=0"; uci commit network; _awg_down "$mode" ;; 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 4 --max-time 6 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null)" [ -n "$tr" ] || tr="$(curl -s --interface "$mode" --connect-timeout 4 --max-time 6 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 3 --max-time 5 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 3 --max-time 5 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; } _st_uses_if "$mode" && { printf '{"error":"%s"}\n' "$(esc "через $mode работают сервисы Steer. Сначала выберите в Steer другой режим WARP или другое подключение секции")"; return 1; } _awg_fk_live "$mode" && { printf '{"error":"%s"}\n' "$(esc "через туннель $mode сейчас работает $(_fk_name). Сначала выключите $(_fk_name) или выберите в нём другое подключение")"; 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 } _term_say() { echo "==> $*"; } _term_installed() { [ -x /usr/bin/ttyd ]; } _term_running() { pidof ttyd >/dev/null 2>&1; } _term_enabled() { ls /etc/rc.d/S*ttyd >/dev/null 2>&1; } _term_version() { ttyd --version 2>/dev/null | head -n1 | awk '{ print $NF }'; } _term_port() { local p p="$(uci -q get 'ttyd.@ttyd[0].port' 2>/dev/null)" case "$p" in ''|*[!0-9]*) p=7681 ;; esac echo "$p" } # Где слушает терминал. ttyd с «-i br-lan» (интерфейс @lan) через libwebsockets привязывает # сокет к интерфейсу (SO_BINDTODEVICE): подключения, пришедшие через tailscale0, он отбрасывает, # поэтому из Tailscale терминал не открывается. Выбрать tailscale0, как советуют, — значит # закрыть его дома. Правильно — слушать все интерфейсы (без -i), а от интернета закрывает # firewall: вход из зоны интернета закрыт, порт 7681 там не открыт. Это режим «all». TERM_ALL="$ZM_STATE_DIR/ttyd.all" _term_iface() { uci -q get 'ttyd.@ttyd[0].interface'; } # home — только домашняя сеть; all — дом и Tailscale; ts — только Tailscale; other — своя настройка _term_mode() { local i i="$(_term_iface)" case "$i" in '') [ -f "$TERM_ALL" ] && echo all || echo home ;; @lan|br-lan) echo home ;; @tailscale|tailscale0) echo ts ;; *) echo other ;; esac } # Режим «all» допустим, только пока вход из интернета закрыт firewall'ом. Открыли — возвращаем # терминал в домашнюю сеть (вызывается при каждом запуске терминала и раз в минуту сторожем Tailscale). _term_all_guard() { [ -f "$TERM_ALL" ] || return 1 [ -z "$(_term_iface)" ] || { rm -f "$TERM_ALL"; return 1; } _tsc_wan_closed && return 1 uci -q set 'ttyd.@ttyd[0].interface=@lan'; uci -q commit ttyd rm -f "$TERM_ALL" logger -t zapret-manager "Терминал: вход из интернета в firewall открыт — терминал снова только для домашней сети" return 0 } # $1: all — дом и Tailscale; home — только дом _term_set_mode() { _term_config case "$1" in all) _tsc_wan_closed || { echo "вход из интернета в firewall не закрыт — открыть терминал на всех интерфейсах небезопасно"; return 1; } uci -q delete 'ttyd.@ttyd[0].interface' mkdir -p "$ZM_STATE_DIR"; : > "$TERM_ALL" ;; home) uci -q set 'ttyd.@ttyd[0].interface=@lan' rm -f "$TERM_ALL" ;; *) echo "неизвестный режим"; return 1 ;; esac uci -q commit ttyd _term_running && /etc/init.d/ttyd restart >/dev/null 2>&1 return 0 } _term_config() { [ -f /etc/config/ttyd ] || : > /etc/config/ttyd uci -q get 'ttyd.@ttyd[0]' >/dev/null 2>&1 || uci -q add ttyd ttyd >/dev/null 2>&1 _term_all_guard [ -n "$(_term_iface)" ] || [ -f "$TERM_ALL" ] || uci -q set 'ttyd.@ttyd[0].interface=@lan' [ -n "$(uci -q get 'ttyd.@ttyd[0].port')" ] || uci -q set 'ttyd.@ttyd[0].port=7681' [ -n "$(uci -q get 'ttyd.@ttyd[0].command')" ] || uci -q set 'ttyd.@ttyd[0].command=/bin/login' uci -q commit ttyd } _term_start() { local t=0 _term_installed || { echo "ОШИБКА: терминал не установлен"; return 1; } _term_config rm -f "$ZM_STATE_DIR/ttyd.htop" /etc/init.d/ttyd enable >/dev/null 2>&1 /etc/init.d/ttyd restart >/dev/null 2>&1 while [ "$t" -lt 10 ]; do _term_running && break sleep 1; t=$((t + 1)) done _term_running || { echo "ОШИБКА: ttyd не запустился"; return 1; } return 0 } _term_stop() { [ -x /etc/init.d/ttyd ] && { /etc/init.d/ttyd stop >/dev/null 2>&1; /etc/init.d/ttyd disable >/dev/null 2>&1; } _term_running && { killall ttyd >/dev/null 2>&1; sleep 1; killall -9 ttyd >/dev/null 2>&1; } return 0 } term_status() { local inst=false run=false en=false _term_installed && inst=true _term_running && run=true _term_enabled && en=true printf '{"installed":%s,"running":%s,"enabled":%s,"version":"%s","port":%s,"lan_ip":"%s","mode":"%s","iface":"%s","ts":%s,"ts_ip":"%s","wan_closed":%s,"job":%s}\n' \ "$inst" "$run" "$en" "$(esc "$(_term_version)")" "$(_term_port)" "$(esc "$(_zm_lan_ip)")" \ "$(_term_mode)" "$(esc "$(_term_iface)")" "$(_tsc_installed && echo true || echo false)" \ "$(esc "$(_tsc_running && _zm_tmo 5 tailscale ip -4 2>/dev/null | head -n1)")" \ "$(_tsc_wan_closed && echo true || echo false)" \ "$(_job_alive term && echo true || echo false)" } do_term_install() { _ensure_deps _term_say "Устанавливаем терминал (ttyd)" $UPDATE || echo "!! Список пакетов не обновился — пробуем с тем, что есть" $INSTALL ttyd || { echo "ОШИБКА: ttyd не установился — проверьте интернет и доступность пакета для вашей прошивки"; return 1; } _term_installed || { echo "ОШИБКА: файла /usr/bin/ttyd нет — пакет встал не полностью"; return 1; } _term_say "Запускаем терминал" _term_start || return 1 echo " ✓ Терминал работает: порт $(_term_port), только домашняя сеть" _term_say "Готово: терминал ttyd $(_term_version)" } do_term_remove() { _term_say "Останавливаем терминал" _term_stop echo " ✓ Терминал остановлен и убран из автозапуска" _term_say "Удаляем ttyd" local rc=0 _zm_pkg_wipe luci-i18n-ttyd-ru luci-app-ttyd ttyd || rc=1 _zm_wipe /etc/config/ttyd /etc/config/ttyd-opkg /etc/config/ttyd.apk-new /etc/init.d/ttyd /etc/rc.d/*ttyd || rc=1 rm -f "$TERM_ALL" _zm_rm_listed terminal || rc=1 rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* 2>/dev/null _term_installed && rc=1 _zm_rm_done "$rc" "Терминал" } term_action() { local a="$1" j case "$a" in install|remove) _job_alive term && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } if j="$(_zm_busy_job)"; then printf '{"error":"%s"}\n' "$(esc "Сейчас идёт операция: $j — дождитесь её окончания")"; return 1; fi case "$a" in install) job_start term do_term_install ;; remove) job_start term do_term_remove ;; esac ;; start|restart) _term_installed || { echo '{"error":"терминал не установлен"}'; return 1; } _job_alive term && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } _term_start >/dev/null 2>&1 || { echo '{"error":"ttyd не запустился — смотрите журнал роутера"}'; return 1; } printf '{"ok":true}\n' ;; ts_on|ts_off) _term_installed || { echo '{"error":"терминал не установлен"}'; return 1; } _job_alive term && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } if j="$(_term_set_mode "$([ "$a" = ts_on ] && echo all || echo home)")"; then printf '{"ok":true,"mode":"%s"}\n' "$(_term_mode)" else printf '{"error":"%s"}\n' "$(esc "$j")"; return 1; fi ;; *) echo '{"error":"неизвестное действие"}'; return 1 ;; esac } TSC_DIR="$ZM_STATE_DIR/tailscale" TSC_ON="$TSC_DIR/on" TSC_NOEXIT="$TSC_DIR/noexit" TSC_LAN="$TSC_DIR/lan" TSC_LS_PREV="$TSC_DIR/localservice" TSC_ZR_OWN="$TSC_DIR/zapret.own" TSC_STATE="$TSC_DIR/state" TSC_SRV="$TSC_DIR/server" TSC_KEY="$TSC_DIR/authkey" TSC_DEV="tailscale0" TSC_NET="tailscale" TSC_FW_RULE="zm_tailscale" TSC_NFT="/usr/share/nftables.d/chain-pre/forward/30-zm-tailscale.nft" TSC_HOOK="/usr/libexec/zm-tailscale-tgws" TSC_SYSCTL="/etc/sysctl.d/90-zm-tailscale.conf" TSC_UPLOG="$JOBS_DIR/tsc.up.log" TSC_NEED_KB=10240 _tsc_say() { echo "==> $*"; } _tsc_installed() { [ -x /usr/sbin/tailscaled ] && [ -e /usr/sbin/tailscale ]; } _tsc_running() { pidof tailscaled >/dev/null 2>&1; } _tsc_enabled() { ls /etc/rc.d/S*tailscale >/dev/null 2>&1; } # Состояние и настройки — через LocalAPI на сокете tailscaled: ответ за миллисекунды. # Команда tailscale в сборке для OpenWrt сжата UPX и при каждом запуске распаковывается # в память целиком — на слабом роутере это секунды. Она остаётся запасным путём. TSC_SOCK="/var/run/tailscale/tailscaled.sock" _tsc_api() { local o [ -S "$TSC_SOCK" ] && command -v curl >/dev/null 2>&1 || return 1 o="$(curl -sf --max-time 6 --unix-socket "$TSC_SOCK" "http://local-tailscaled.sock/localapi/v0/$1" 2>/dev/null)" || return 1 case "$o" in '{'*) printf '%s\n' "$o" ;; *) return 1 ;; esac } _tsc_ver() { local f="$TSC_DIR/version" k v k="$(ls -ilL /usr/sbin/tailscaled 2>/dev/null | awk 'NR == 1 && $1 ~ /^[0-9]+$/ { print $1 ":" $6 }')" [ -n "$k" ] && k="$k:$(date -r /usr/sbin/tailscaled +%s 2>/dev/null)" if [ -n "$k" ] && [ -s "$f" ]; then read -r v 2>/dev/null < "$f" [ "${v%% *}" = "$k" ] && { printf '%s\n' "${v#* }"; return 0; } fi v="$(tailscale version 2>/dev/null | head -n1)" [ -n "$v" ] && [ -n "$k" ] && { mkdir -p "$TSC_DIR"; printf '%s %s\n' "$k" "$v" > "$f" 2>/dev/null; } printf '%s\n' "$v" } _tsc_json() { _tsc_api status || _zm_tmo 8 tailscale status --json 2>/dev/null; } _tsc_free_kb() { df -k /overlay 2>/dev/null | awk 'NR == 2 { print $4; exit }'; } _tsc_port() { local p; p="$(uci -q get tailscale.settings.port)"; case "$p" in ''|*[!0-9]*) echo 41641 ;; *) echo "$p" ;; esac; } _tsc_lan_cidr() { local d a="" for d in $(_zm_lan_devs); do [ "$d" = "$TSC_DEV" ] && continue a="$(ip -4 -o addr show dev "$d" 2>/dev/null | awk '{ print $4; exit }')" [ -n "$a" ] && break done [ -n "$a" ] || return 1 awk -v a="$a" 'BEGIN { split(a, p, "/"); split(p[1], o, "."); m = (p[2] == "" ? 32 : p[2] + 0) ip = ((o[1] * 256 + o[2]) * 256 + o[3]) * 256 + o[4]; s = 2 ^ (32 - m); n = int(ip / s) * s printf "%d.%d.%d.%d/%d\n", int(n / 16777216) % 256, int(n / 65536) % 256, int(n / 256) % 256, n % 256, m }' } # Имя роутера для Tailscale: «_» и точка ломают вход на Routerich (ссылка входа неактивна), # поэтому, если в имени системы они есть, передаём исправленное (только буквы, цифры, «-»). _tsc_hostname() { local h c h="$(uci -q get system.@system[0].hostname)" [ -n "$h" ] || h="$(cat /proc/sys/kernel/hostname 2>/dev/null)" [ -n "$h" ] || return 1 c="$(printf '%s' "$h" | tr -c 'A-Za-z0-9-' '-' | sed 's/--*/-/g; s/^-//; s/-$//' | cut -c1-63)" [ -n "$c" ] || c=openwrt [ "$c" != "$h" ] && echo "$c" } _tsc_flags() { local e=true r="" h [ -f "$TSC_NOEXIT" ] && e=false [ -f "$TSC_LAN" ] && r="$(_tsc_lan_cidr)" printf -- '--accept-dns=false --advertise-exit-node=%s --advertise-routes=%s' "$e" "$r" h="$(_tsc_hostname)" && printf -- ' --hostname=%s' "$h" return 0 } _tsc_srv() { cat "$TSC_SRV" 2>/dev/null; } _tsc_kind() { case "$(_tsc_srv)" in '') echo official ;; *://rc.routerich.ru|*://rc.routerich.ru/*) echo routerich ;; *) echo custom ;; esac } _tsc_admin() { case "$(_tsc_kind)" in official) echo "https://login.tailscale.com/admin" ;; routerich) echo "https://remote.routerich.ru" ;; esac } _tsc_upflags() { local s s="$(_tsc_srv)" printf '%s --netfilter-mode=off' "$(_tsc_flags)" [ -n "$s" ] && printf ' --login-server=%s' "$s" return 0 } TSC_GH_BASES="https://raw.githubusercontent.com/gunanovo/openwrt-tailscale/refs/heads/feed https://cdn.jsdelivr.net/gh/gunanovo/openwrt-tailscale@feed" TSC_LUCI_BASES="https://raw.githubusercontent.com/Tokisaki-Galaxy/luci-app-tailscale-community/gh-pages/all https://tokisaki-galaxy.github.io/luci-app-tailscale-community/all https://cdn.jsdelivr.net/gh/Tokisaki-Galaxy/luci-app-tailscale-community@gh-pages/all" TSC_SRC="$TSC_DIR/source" _tsc_arch() { local a a="$(awk -F\' '/DISTRIB_ARCH/ { print $2 }' /etc/openwrt_release 2>/dev/null)" [ -n "$a" ] || a="$(cat /etc/apk/arch 2>/dev/null)" [ -n "$a" ] || a="$(opkg print-architecture 2>/dev/null | awk '$2 != "all" && $2 != "noarch" { a = $2 } END { print a }')" printf '%s' "$a" | tr -d '\r\n\t ' } # $1 — путь от корня зеркала, $2 — куда сохранить, $3 — список зеркал, $4 — сек. _tsc_fetch() { local b for b in $3; do rm -f "$2" _zm_gh_get "$b/$1" "$2" "" "${4:-120}" && _zm_dl_sane "$2" && return 0 done rm -f "$2" return 1 } _tsc_gh_latest() { local f="$JOBS_DIR/tsc.ver.$$" v mkdir -p "$JOBS_DIR" _tsc_fetch "$(_tsc_arch)/version" "$f" "$TSC_GH_BASES" 15 || return 1 v="$(head -c 64 "$f" | tr -d ' \r\n')" rm -f "$f" case "$v" in ''|*[!0-9A-Za-z.-]*) return 1 ;; esac echo "$v" } _tsc_vnum() { printf '%s' "$1" | sed 's/-r[0-9]*$//; s/[^0-9.].*$//'; } # Уменьшенная сборка Tailscale для OpenWrt (GuNanOvO/openwrt-tailscale) с GitHub _tsc_install_gh() { local arch ver f sum want got arch="$(_tsc_arch)" [ -n "$arch" ] || { echo " · Не удалось узнать архитектуру роутера"; return 1; } echo " → Архитектура: $arch, ищем сборку на GitHub" ver="$(_tsc_gh_latest)" || { echo " · GitHub не ответил или сборки для $arch нет"; return 1; } f="$JOBS_DIR/tailscale-$ver.$RAZ" sum="$JOBS_DIR/tailscale.$RAZ.sha256" echo " → Скачиваем Tailscale $ver ($RAZ, около 7 МБ)" _tsc_fetch "$arch/tailscale-$ver.$RAZ" "$f" "$TSC_GH_BASES" 300 || { echo " · Пакет не скачался ни с GitHub, ни с зеркала"; return 1; } _tsc_fetch "$arch/$RAZ.sha256" "$sum" "$TSC_GH_BASES" 20 || { rm -f "$f"; echo " · Не скачалась контрольная сумма"; return 1; } want="$(head -c 200 "$sum" | tr -d ' \r\n' | cut -c1-64)" got="$(sha256sum "$f" 2>/dev/null | cut -d' ' -f1)" rm -f "$sum" if [ -z "$want" ] || [ "$want" != "$got" ]; then rm -f "$f"; echo " · Контрольная сумма не сошлась — файл повреждён, ставить не будем"; return 1 fi echo " ✓ Контрольная сумма сошлась" $INSTALL "$f" || { rm -f "$f"; return 1; } rm -f "$f" _tsc_installed || return 1 mkdir -p "$TSC_DIR"; echo github > "$TSC_SRC" return 0 } # Страница Tailscale для LuCI (Tokisaki-Galaxy/luci-app-tailscale-community) _tsc_install_luci() { local idx="$JOBS_DIR/tsc.luci.idx" ver f if _tsc_fetch Packages "$idx" "$TSC_LUCI_BASES" 20; then ver="$(awk '/^Package: luci-app-tailscale-community$/ { p = 1; next } /^Package:/ { p = 0 } p && /^Version:/ { print $2; exit }' "$idx")" rm -f "$idx" case "$ver" in ''|*[!0-9A-Za-z.~+-]*) ver="" ;; esac if [ -n "$ver" ]; then if [ "$RAZ" = apk ]; then f="luci-app-tailscale-community-$ver.apk"; else f="luci-app-tailscale-community_${ver}_all.ipk"; fi if _tsc_fetch "$f" "$JOBS_DIR/$f" "$TSC_LUCI_BASES" 120 && $INSTALL "$JOBS_DIR/$f" >/dev/null 2>&1; then rm -f "$JOBS_DIR/$f" return 0 fi rm -f "$JOBS_DIR/$f" fi fi $INSTALL luci-app-tailscale-community >/dev/null 2>&1 } _tsc_luci_path() { grep -ho '"admin/[A-Za-z0-9_-]*/tailscale"' /usr/share/luci/menu.d/*.json 2>/dev/null | head -n1 | tr -d '"'; } _tsc_luci_app() { [ -n "$(_tsc_luci_path)" ]; } # Настройки Tailscale в /etc/config/tailscale: их читает LuCI-приложение luci-app-tailscale-community # и его служба tailscale-settings применяет их при каждой загрузке. Без этой записи она # выключила бы выход в интернет (advertise_exit_node по умолчанию 0). _tsc_uci_sync() { local r e l uci -q show tailscale >/dev/null 2>&1 || return 0 uci -q get tailscale.settings >/dev/null || uci set tailscale.settings=settings e=1; [ -f "$TSC_NOEXIT" ] && e=0 uci set tailscale.settings.advertise_exit_node="$e" uci set tailscale.settings.dns_mode=disabled uci set tailscale.settings.service_enabled=1 uci set tailscale.settings.disable_fw_config=1 r="$(_tsc_lan_cidr)" if [ -n "$r" ]; then l="$(uci -q get tailscale.settings.advertise_routes | tr ' ,' '\n\n' | grep . | grep -vxF "$r")" [ -f "$TSC_LAN" ] && l="$(printf '%s\n%s' "$l" "$r" | grep .)" uci -q delete tailscale.settings.advertise_routes for e in $l; do uci add_list tailscale.settings.advertise_routes="$e"; done fi uci commit tailscale } _tsc_start() { local t=0 /etc/init.d/tailscale enable >/dev/null 2>&1 /etc/init.d/tailscale restart >/dev/null 2>&1 while [ "$t" -lt 20 ]; do [ -n "$(_tsc_json | jsonfilter -e '@.BackendState' 2>/dev/null)" ] && { _tsc_prefs >/dev/null 2>&1; return 0; } sleep 1; t=$((t + 1)) done echo "ОШИБКА: служба Tailscale не ответила за 20 секунд — смотрите журнал роутера (logread | grep tailscale)" return 1 } _tsc_wan_closed() { local s i s="$(_zm_zone_sec "$(_zm_wan_zone)")" || return 1 i="$(uci -q get "firewall.$s.input")" [ "$i" = REJECT ] || [ "$i" = DROP ] } _tsc_nft_text() { printf '%s\n' \ '# Zapret Manager: Tailscale. Файл создаёт и убирает панель.' \ 'iifname "tailscale0" meta nfproto ipv6 counter reject with icmpv6 type admin-prohibited comment "zm-tailscale: no IPv6"' \ 'iifname "tailscale0" tcp flags syn tcp option maxseg size set 1240 comment "zm-tailscale: MSS"' \ 'oifname "tailscale0" tcp flags syn tcp option maxseg size set 1240 comment "zm-tailscale: MSS"' } _tsc_hook_text() { cat <<'EOF' #!/bin/sh # Zapret Manager: Telegram через sTGWS для телефонов из Tailscale. # Файл создаёт и убирает панель; запускается из /etc/init.d/tgws. ( # адрес роутера в домашней сети — так же, как его находит панель (любое имя сети, мост или нет) ip="$(/opt/zapret-manager-luci/backend.sh tsc_lan_ip 2>/dev/null | head -n1)" [ -n "$ip" ] || ip="$(ifstatus lan 2>/dev/null | jsonfilter -e "@['ipv4-address'][0].address" 2>/dev/null)" [ -n "$ip" ] || ip="$(uci -q get network.lan.ipaddr | awk '{ print $1 }' | cut -d/ -f1)" [ -n "$ip" ] || ip="$(ip -4 -o addr show dev br-lan 2>/dev/null | awk '{ print $4; exit }' | cut -d/ -f1)" case "$ip" in *.*.*.*) ;; *) exit 0 ;; esac i=0 while ! nft list chain inet stgws tgws_redirect >/dev/null 2>&1 || ! nft list chain inet stgws prerouting_mark >/dev/null 2>&1; do i=$((i + 1)); [ "$i" -gt 40 ] && exit 0; sleep 1 done for c in prerouting_mark tgws_redirect; do nft -a list chain inet stgws "$c" 2>/dev/null | sed -n '/comment "zm-tailscale"/s/.*# handle \([0-9]*\)$/\1/p' | while read -r h; do nft delete rule inet stgws "$c" handle "$h"; done done nft add rule inet stgws prerouting_mark iifname "tailscale0" ip daddr @tg_ip meta mark set meta mark and 0xefffffff or 0x50000000 counter return comment '"zm-tailscale"' nft insert rule inet stgws tgws_redirect iifname "tailscale0" meta mark and 0x10000000 == 0x10000000 tcp dport '{ 80, 443, 5222 }' counter dnat ip to "$ip:8490" comment '"zm-tailscale"' ) /dev/null 2>&1 & EOF } _tsc_tg_rules() { nft list table inet stgws 2>/dev/null | grep -c 'comment "zm-tailscale"'; } # правило перенаправления Telegram ведёт не на текущий адрес роутера — пересоздаём _tsc_tg_ip_fix() { local ip r [ -x "$TSC_HOOK" ] || return 0 r="$(nft list chain inet stgws tgws_redirect 2>/dev/null | grep 'zm-tailscale')" || return 0 ip="$(_zm_lan_ip)" || return 0 printf '%s' "$r" | grep -qF "to $ip:8490" && return 0 "$TSC_HOOK" return 0 } _tsc_tg_ensure() { [ -f "$TSC_ON" ] && [ -f /etc/init.d/tgws ] || return 0 local t="$TSC_HOOK.zmtmp" mkdir -p "${TSC_HOOK%/*}" _tsc_hook_text > "$t" if cmp -s "$t" "$TSC_HOOK"; then rm -f "$t"; else mv -f "$t" "$TSC_HOOK"; fi chmod 0755 "$TSC_HOOK" if ! grep -qF "$TSC_HOOK" /etc/init.d/tgws; then grep -q 'procd_close_instance' /etc/init.d/tgws || return 1 awk -v l=" [ -x $TSC_HOOK ] && $TSC_HOOK" '{ print } !d && /procd_close_instance/ { print l; d = 1 }' /etc/init.d/tgws > /etc/init.d/tgws.zmtmp && cat /etc/init.d/tgws.zmtmp > /etc/init.d/tgws rm -f /etc/init.d/tgws.zmtmp fi [ "$(_tsc_tg_rules)" -ge 2 ] 2>/dev/null || "$TSC_HOOK" return 0 } _tsc_tg_drop() { local c h [ -f /etc/init.d/tgws ] && grep -qF "$TSC_HOOK" /etc/init.d/tgws && sed -i "\#$TSC_HOOK#d" /etc/init.d/tgws rm -f "$TSC_HOOK" for c in prerouting_mark tgws_redirect; do nft -a list chain inet stgws "$c" 2>/dev/null | sed -n '/comment "zm-tailscale"/s/.*# handle \([0-9]*\)$/\1/p' | while read -r h; do nft delete rule inet stgws "$c" handle "$h" 2>/dev/null; done done return 0 } _tsc_zapret() { local f for f in /opt/zapret/config /opt/zapret2/config; do [ -f "$f" ] || continue case "$1" in add) grep -qE '^IFACE_LAN="?[^" ]' "$f" || continue grep '^IFACE_LAN=' "$f" | tr '"' ' ' | grep -qw "$TSC_DEV" && continue sed -i "s/^IFACE_LAN=\"\{0,1\}\([^\"]*\)\"\{0,1\}\$/IFACE_LAN=\"\1 $TSC_DEV\"/" "$f" mkdir -p "$TSC_DIR"; grep -qxF "$f" "$TSC_ZR_OWN" 2>/dev/null || echo "$f" >> "$TSC_ZR_OWN" echo " ✓ ${f%/config}: интерфейс $TSC_DEV добавлен в IFACE_LAN" ;; del) grep -qxF "$f" "$TSC_ZR_OWN" 2>/dev/null || continue sed -i "/^IFACE_LAN=/s/ $TSC_DEV//" "$f" ;; esac case "$f" in /opt/zapret/*) [ -x /etc/init.d/zapret ] && /etc/init.d/zapret restart >/dev/null 2>&1 ;; *) [ -x /etc/init.d/zapret2 ] && /etc/init.d/zapret2 restart >/dev/null 2>&1 ;; esac done [ "$1" = del ] && rm -f "$TSC_ZR_OWN" return 0 } # Forkozz (netshift): трафик берётся только с интерфейсов из source_network_interfaces _tsc_fk_has() { uci -q get netshift.settings.source_network_interfaces | tr ' ' '\n' | grep -qx "$TSC_DEV"; } _tsc_fk() { local cur ch=0 d own="$TSC_DIR/forkozz.own" orig want uci -q get netshift.settings >/dev/null || return 0 cur="$(uci -q get netshift.settings.source_network_interfaces)" case "$1" in add) _tsc_fk_has && return 0 mkdir -p "$TSC_DIR" if uci -q get netshift.settings.source_network_interfaces >/dev/null; then orig="list $cur"; else orig="unset"; fi [ -n "$cur" ] || uci add_list netshift.settings.source_network_interfaces="br-lan" uci add_list netshift.settings.source_network_interfaces="$TSC_DEV" printf 'orig=%s\nset=%s\n' "$orig" "$(uci -q get netshift.settings.source_network_interfaces)" > "$own"; ch=1 echo " ✓ Forkozz: устройства Tailscale добавлены к домашней сети" ;; del) [ -f "$own" ] || return 0 orig="$(sed -n 's/^orig=//p' "$own")"; want="$(sed -n 's/^set=//p' "$own")" if [ -n "$want" ] && [ "$cur" = "$want" ] && [ -n "$orig" ]; then uci -q delete netshift.settings.source_network_interfaces case "$orig" in list\ *) for d in ${orig#list }; do uci add_list netshift.settings.source_network_interfaces="$d"; done ;; esac ch=1 echo " ✓ Forkozz: источники трафика вернулись к прежним" elif _tsc_fk_has; then uci -q del_list netshift.settings.source_network_interfaces="$TSC_DEV"; ch=1 echo " ✓ Forkozz: устройства Tailscale убраны из источников трафика" fi rm -f "$own" ;; esac [ "$ch" = 1 ] || return 0 uci commit netshift return 0 } # Forkozz и Steer держат своё состояние (правила, метки соединений, DNS). После перезагрузки # сети и firewall, перезапуска dnsmasq и особенно после того, как Tailscale побывал в режиме # со своими правилами firewall, их нужно перезапустить — иначе они пишут «работает», а сервисы # через них не открываются (проверено на роутере: помог перезапуск Forkozz). _tsc_kick_bypass() { local l="$TSC_DIR/kick.lock" w=0 mkdir -p "$TSC_DIR" if [ "$1" = wait ]; then while [ -d "$l" ] && [ "$w" -lt 120 ]; do sleep 2; w=$((w + 2)); done fi # перезапуск уже идёт (сторож и страница могли сработать одновременно) — второй не нужен; # зависшую больше 10 минут блокировку снимаем find "$TSC_DIR" -maxdepth 1 -name kick.lock -mmin +10 -exec rmdir {} \; 2>/dev/null mkdir "$l" 2>/dev/null || { echo " · Forkozz и Steer уже перезапускаются"; return 0; } pidof tailscaled > "$TSC_DIR/kick.pid" 2>/dev/null if [ -x /etc/init.d/netshift ] && [ -f /etc/config/netshift ] && /etc/init.d/netshift enabled 2>/dev/null; then echo " → Перезапускаем Forkozz, чтобы он подхватил изменения сети" _zm_run 180 /etc/init.d/netshift restart >/dev/null 2>&1 fi _tsc_steer_kick rmdir "$l" 2>/dev/null return 0 } _tsc_kick_bg() { ( _tsc_kick_bypass ) /dev/null 2>&1 & } _tsc_steer_on() { _st_installed && [ ! -f "$ST_OFF" ] && _st_owns "steer-spec" && [ -z "$(_st_blocker)" ]; } _tsc_steer_kick() { _tsc_steer_on || return 0 _job_alive steer && { echo " · Steer сейчас занят — устройства Tailscale попадут в его правила при следующем применении"; return 0; } echo "==> Steer: обновляем правила для устройств Tailscale" _st_respec return 0 } _st_lan_devs() { local out out="$(_zm_lan_devs)" if [ -f "$TSC_ON" ]; then case " $out " in *" $TSC_DEV "*) ;; *) out="$out $TSC_DEV" ;; esac; fi echo $out } # Файл входа Tailscale должен лежать на флеш-памяти: в /tmp он пропадает при перезагрузке, # и роутер становится новым устройством — с новым адресом 100.x, новым входом и одобрением. _tsc_state_keep() { local f n="/etc/tailscale/tailscaled.state" f="$(uci -q get tailscale.settings.state_file)" case "$f" in /tmp/*|/var/*|/dev/shm/*) ;; *) return 1 ;; esac mkdir -p "${n%/*}" [ -s "$f" ] && cp -f "$f" "$n" uci set tailscale.settings.state_file="$n" uci commit tailscale echo " ✓ Вход Tailscale теперь хранится на флеш-памяти ($n) — после перезагрузки не придётся входить заново" return 0 } _tsc_setup() { local z lz ch=0 p t mkdir -p "$TSC_DIR"; : > "$TSC_ON" _tsc_state_keep && _tsc_running && _tsc_start >/dev/null 2>&1 _tsc_say "Настраиваем сеть и firewall" if [ "$(uci -q get "network.$TSC_NET")" != interface ]; then uci set "network.$TSC_NET=interface" uci set "network.$TSC_NET.proto=none" uci set "network.$TSC_NET.device=$TSC_DEV" uci commit network ubus call network reload >/dev/null 2>&1 echo " ✓ Интерфейс «$TSC_NET» ($TSC_DEV) создан" fi lz="$(_zm_lan_zone)" if z="$(_zm_zone_sec "$lz")"; then case " $(uci -q get "firewall.$z.network") " in *" $TSC_NET "*) ;; *) uci add_list "firewall.$z.network=$TSC_NET"; ch=1; echo " ✓ Tailscale добавлен в зону «$lz» — телефон для роутера как домашнее устройство" ;; esac else echo "!! Не нашли зону домашней сети в firewall — добавьте интерфейс «$TSC_NET» в неё вручную" fi p="$(_tsc_port)" if [ "$(uci -q get "firewall.$TSC_FW_RULE")" != rule ] || [ "$(uci -q get "firewall.$TSC_FW_RULE.dest_port")" != "$p" ]; then uci set "firewall.$TSC_FW_RULE=rule" uci set "firewall.$TSC_FW_RULE.name=Allow-Tailscale" uci set "firewall.$TSC_FW_RULE.src=$(_zm_wan_zone)" uci set "firewall.$TSC_FW_RULE.proto=udp" uci set "firewall.$TSC_FW_RULE.dest_port=$p" uci set "firewall.$TSC_FW_RULE.target=ACCEPT" ch=1 echo " ✓ Открыт порт UDP $p — для прямого соединения с телефоном" fi [ "$ch" = 1 ] && uci commit firewall t="$TSC_NFT.zmtmp" mkdir -p "${TSC_NFT%/*}" _tsc_nft_text > "$t" if cmp -s "$t" "$TSC_NFT"; then rm -f "$t"; else mv -f "$t" "$TSC_NFT"; ch=1; echo " ✓ IPv6 из Tailscale отклоняется сразу, размер пакетов подогнан под туннель"; fi if [ "$ch" = 1 ]; then if fw4 check >/dev/null 2>&1 || ! command -v fw4 >/dev/null 2>&1; then /etc/init.d/firewall reload >/dev/null 2>&1 else rm -f "$TSC_NFT" echo "!! firewall не принял правила IPv6 и MSS — они отключены, остальное работает" /etc/init.d/firewall reload >/dev/null 2>&1 fi fi if [ "$(cat /proc/sys/net/ipv4/ip_forward 2>/dev/null)" != 1 ]; then mkdir -p "${TSC_SYSCTL%/*}"; echo 'net.ipv4.ip_forward=1' > "$TSC_SYSCTL" sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 echo " ✓ Включена пересылка пакетов" fi if [ ! -f "$TSC_LS_PREV" ] && [ "$(uci -q get dhcp.@dnsmasq[0].localservice)" != 0 ]; then if _tsc_wan_closed; then uci -q get dhcp.@dnsmasq[0].localservice > "$TSC_LS_PREV" || : > "$TSC_LS_PREV" uci set dhcp.@dnsmasq[0].localservice=0 uci commit dhcp /etc/init.d/dnsmasq restart >/dev/null 2>&1 echo " ✓ DNS роутера отвечает и устройствам Tailscale (из интернета он по-прежнему закрыт)" else echo "!! Вход из интернета в firewall не закрыт — DNS роутера для Tailscale не открываем" fi fi _tsc_uci_sync _tsc_cron on _tsc_fk add _tsc_zapret add if [ -f /etc/init.d/tgws ]; then _tsc_tg_ensure && echo " ✓ sTGWS: Telegram с телефона пойдёт через мост" fi _tsc_kick_bypass return 0 } _tsc_unsetup() { local z lz ch=0 v _tsc_say "Убираем настройки сети и firewall" _tsc_tg_drop _tsc_nft_clean lz="$(_zm_lan_zone)" if z="$(_zm_zone_sec "$lz")"; then case " $(uci -q get "firewall.$z.network") " in *" $TSC_NET "*) uci del_list "firewall.$z.network=$TSC_NET"; ch=1 ;; esac fi uci -q get "firewall.$TSC_FW_RULE" >/dev/null && { uci delete "firewall.$TSC_FW_RULE"; ch=1; } [ "$ch" = 1 ] && uci commit firewall [ -f "$TSC_NFT" ] && { rm -f "$TSC_NFT"; ch=1; } [ "$ch" = 1 ] && /etc/init.d/firewall reload >/dev/null 2>&1 if uci -q get "network.$TSC_NET" >/dev/null; then uci delete "network.$TSC_NET"; uci commit network ubus call network reload >/dev/null 2>&1 fi rm -f "$TSC_SYSCTL" if [ -f "$TSC_LS_PREV" ]; then v="$(cat "$TSC_LS_PREV")" if [ -n "$v" ]; then uci set "dhcp.@dnsmasq[0].localservice=$v"; else uci -q delete dhcp.@dnsmasq[0].localservice; fi uci commit dhcp /etc/init.d/dnsmasq restart >/dev/null 2>&1 rm -f "$TSC_LS_PREV" fi _tsc_zapret del _tsc_fk del _tsc_cron off rm -f "$TSC_ON" if [ -f "$TERM_ALL" ] && _term_installed; then _term_set_mode home >/dev/null 2>&1 echo " ✓ Терминал снова только для домашней сети" fi _tsc_kick_bypass wait echo " ✓ Сеть и firewall вернулись к прежнему виду" return 0 } _tsc_dbg() { _tsc_api prefs || _zm_tmo 8 tailscale debug prefs 2>/dev/null; } # $1 — уже полученный вывод `tailscale debug prefs` (необязательно) _tsc_nf_on() { local m m="$(printf '%s' "${1:-$(_tsc_dbg)}" | jsonfilter -e '@.NetfilterMode' 2>/dev/null)" [ -n "$m" ] && [ "$m" != 0 ] } # После вызова: TSC_KICK=1, если Tailscale был в режиме со своими правилами firewall или # остались его правила — тогда Forkozz и Steer надо перезапустить (_tsc_kick_bypass). _tsc_prefs() { local st TSC_KICK="" _tsc_nf_on "$1" && TSC_KICK=1 _tsc_uci_sync st="$(_tsc_json | jsonfilter -e '@.BackendState' 2>/dev/null)" case "$st" in Running|NeedsMachineAuth|Starting) ;; NeedsLogin|Stopped) _zm_tmo 20 tailscale set --netfilter-mode=off --accept-dns=false >/dev/null 2>&1; _tsc_nft_clean && TSC_KICK=1; return 0 ;; *) return 0 ;; esac # shellcheck disable=SC2046 if _zm_tmo 20 tailscale set $(_tsc_flags) --netfilter-mode=off >/dev/null 2>&1 || _zm_tmo 25 tailscale up --reset $(_tsc_upflags) >/dev/null 2>&1 || _zm_tmo 20 tailscale set $(_tsc_flags) >/dev/null 2>&1; then _tsc_nft_clean && TSC_KICK=1 return 0 fi return 1 } # Применились ли наши настройки в самом tailscaled. Вход через LuCI (Account Settings) # делает `tailscale login` без флагов: роутер в сети, но выходом в интернет себя не # предлагает, пока не выполнить `tailscale set --advertise-exit-node ...`. _tsc_prefs_ok() { local p="$1" r c [ -n "$p" ] || p="$(_tsc_dbg)" [ -n "$p" ] || return 0 r="$(printf '%s' "$p" | jsonfilter -e '@.AdvertiseRoutes[*]' 2>/dev/null)" if [ -f "$TSC_NOEXIT" ]; then printf '%s\n' "$r" | grep -qx '0.0.0.0/0' && return 1 else printf '%s\n' "$r" | grep -qx '0.0.0.0/0' || return 1; fi c="$(_tsc_lan_cidr)" if [ -n "$c" ]; then if [ -f "$TSC_LAN" ]; then printf '%s\n' "$r" | grep -qxF "$c" || return 1 else printf '%s\n' "$r" | grep -qxF "$c" && return 1; fi fi [ "$(printf '%s' "$p" | jsonfilter -e '@.CorpDNS' 2>/dev/null)" = true ] && return 1 _tsc_nf_on "$p" && return 1 _tsc_nft_left && return 1 return 0 } # Остатки правил Tailscale (режим netfilter on): таблицы ip/ip6 filter, nat, mangle. # Удаляем только своё Tailscale: переходы в ts-*, цепочки ts-* и перенос метки соединения # маской 0x00ff0000; таблицу целиком — только если в ней больше нет ни одного правила. _tsc_nft_clean() { local fam t out ch h n=0 for fam in ip ip6; do for t in filter nat mangle; do out="$(nft -a list table "$fam" "$t" 2>/dev/null)" || continue printf '%s\n' "$out" | grep -qE 'jump ts-|chain ts-|0x00ff0000' || continue printf '%s\n' "$out" | awk '/^\tchain /{ c = $2 } /^\t\t/ && /# handle / && (/jump ts-/ || /0x00ff0000/) { h = $NF; print c, h }' | while read -r ch h; do nft delete rule "$fam" "$t" "$ch" handle "$h" 2>/dev/null; done for ch in $(printf '%s\n' "$out" | awk '/^\tchain ts-/ { print $2 }'); do nft flush chain "$fam" "$t" "$ch" 2>/dev/null; nft delete chain "$fam" "$t" "$ch" 2>/dev/null done if ! nft -a list table "$fam" "$t" 2>/dev/null | grep -qE '^ .*# handle [0-9]+$'; then nft delete table "$fam" "$t" 2>/dev/null fi n=$((n + 1)) done done [ "$n" -gt 0 ] || return 1 # Tailscale включал это для своих правил и сам не выключает; в OpenWrt по умолчанию 0 sysctl -w net.ipv4.conf.all.src_valid_mark=0 >/dev/null 2>&1 return 0 } _tsc_nft_left() { local t for t in $(nft list tables 2>/dev/null | awk '$1 == "table" && ($2 == "ip" || $2 == "ip6") && ($3 == "filter" || $3 == "nat" || $3 == "mangle") { print $2 "/" $3 }'); do nft list table "${t%/*}" "${t#*/}" 2>/dev/null | grep -qE 'jump ts-|chain ts-|0x00ff0000' && return 0 done return 1 } TSC_CRON_TAG="# zm-tailscale" _tsc_cron() { local l="* * * * * /opt/zapret-manager-luci/backend.sh tsc_watch >/dev/null 2>&1 $TSC_CRON_TAG" if [ "$1" = on ]; then [ "$(grep -cF "$TSC_CRON_TAG" "$CRON_FILE" 2>/dev/null)" = 1 ] && grep -qxF "$l" "$CRON_FILE" && return 0 else grep -qF "$TSC_CRON_TAG" "$CRON_FILE" 2>/dev/null || return 0 fi sed -i "\\|$TSC_CRON_TAG|d" "$CRON_FILE" 2>/dev/null [ "$1" = on ] && echo "$l" >> "$CRON_FILE" /etc/init.d/cron restart >/dev/null 2>&1 return 0 } # Раз в минуту из cron: после входа через LuCI, перезагрузки или перезапуска службы # возвращает наши настройки (выход в интернет, подсеть, без своих правил firewall и без # MagicDNS на роутере). Дешёво: tailscale запускается, только если сменились состояние # службы или файл входа. # Что из нашей настройки пропало: сеть и firewall сбросили, Forkozz или Zapret обновились # и переписали свои настройки, sTGWS переустановили. Только дешёвые проверки uci и файлов. _tsc_heal_need() { local z f [ "$(uci -q get "network.$TSC_NET")" = interface ] || { echo "интерфейс сети"; return 0; } [ "$(uci -q get "firewall.$TSC_FW_RULE")" = rule ] || { echo "порт в firewall"; return 0; } z="$(_zm_zone_sec "$(_zm_lan_zone)")" && case " $(uci -q get "firewall.$z.network") " in *" $TSC_NET "*) ;; *) echo "зона домашней сети"; return 0 ;; esac if uci -q get netshift.settings >/dev/null && ! _tsc_fk_has; then echo "Forkozz"; return 0; fi for f in $(cat "$TSC_ZR_OWN" 2>/dev/null); do [ -f "$f" ] && grep -qE '^IFACE_LAN="?[^" ]' "$f" && ! grep '^IFACE_LAN=' "$f" | tr '"' ' ' | grep -qw "$TSC_DEV" && { echo "Zapret"; return 0; } done [ -f /etc/init.d/tgws ] && grep -q procd_close_instance /etc/init.d/tgws && ! grep -qF "$TSC_HOOK" /etc/init.d/tgws && { echo "sTGWS"; return 0; } return 1 } # Раз в минуту из cron: после входа через LuCI, перезагрузки или перезапуска службы # возвращает наши настройки (выход в интернет, подсеть, без своих правил firewall и без # MagicDNS на роутере), после перезапуска Tailscale один раз перезапускает Forkozz и Steer, # а если обновление другого компонента затёрло нашу настройку — восстанавливает её. # Дешёво: tailscale запускается, только если сменились служба, файл входа, подсеть или режимы. tsc_watch() { local pid l="$TSC_DIR/watch.lock" [ -f "$TSC_ON" ] && _tsc_installed || return 0 pid="$(pidof tailscaled 2>/dev/null)" [ -n "$pid" ] || return 0 _job_alive tsc && return 0 # прошлый запуск ещё идёт (перезапуск Forkozz бывает дольше минуты) find "$TSC_DIR" -maxdepth 1 -name watch.lock -mmin +10 -exec rmdir {} \; 2>/dev/null mkdir "$l" 2>/dev/null || return 0 _tsc_watch_run "$pid" rmdir "$l" 2>/dev/null return 0 } _tsc_watch_run() { local pid="$1" key st dp why _term_all_guard && _term_running && /etc/init.d/ttyd restart >/dev/null 2>&1 if why="$(_tsc_heal_need)"; then # не чаще раза в полчаса: если починить не выходит, не дёргаем сеть каждую минуту if ! find "$TSC_DIR" -maxdepth 1 -name heal.ts -mmin -30 2>/dev/null | grep -q .; then : > "$TSC_DIR/heal.ts" logger -t zapret-manager "Tailscale: восстанавливаем настройку ($why)" _tsc_setup >/dev/null 2>&1 rm -f "$TSC_DIR/watch.key" fi fi if _tsc_nft_left; then _tsc_nft_clean && { logger -t zapret-manager "Tailscale: убраны его правила firewall, мешавшие Steer, Forkozz и Zapret"; _tsc_kick_bypass >/dev/null 2>&1; } rm -f "$TSC_DIR/watch.key" fi # вход начат в панели и ещё не завершён — дожимаем его, даже если страница закрыта [ -s "$TSC_DIR/login.req" ] && _tsc_login_nudge "$(_tsc_json | jsonfilter -e '@.BackendState' 2>/dev/null)" key="$pid $(date -r "$(uci -q get tailscale.settings.state_file || echo /etc/tailscale/tailscaled.state)" +%s 2>/dev/null) $(_tsc_lan_cidr) $(_zm_lan_ip) $([ -f "$TSC_NOEXIT" ] && echo e)$([ -f "$TSC_LAN" ] && echo l)" [ "$key" = "$(cat "$TSC_DIR/watch.key" 2>/dev/null)" ] && return 0 _tsc_tg_ip_fix st="$(_tsc_json | jsonfilter -e '@.BackendState' 2>/dev/null)" printf '%s\n' "$st" > "$TSC_STATE" 2>/dev/null _tsc_login_nudge "$st" case "$st" in Running) dp="$(_tsc_dbg)" _tsc_prefs_ok "$dp" || { _tsc_prefs "$dp" >/dev/null 2>&1 logger -t zapret-manager "Tailscale: применены настройки выхода в интернет и подсети" [ -n "$TSC_KICK" ] && { logger -t zapret-manager "Tailscale: перезапускаем Forkozz и Steer"; _tsc_kick_bypass >/dev/null 2>&1; } } _tsc_prefs_ok && printf '%s\n' "$key" > "$TSC_DIR/watch.key" # служба Tailscale перезапустилась (кнопка, LuCI, перезагрузка роутера) — один раз # перезапускаем Forkozz и Steer под новое состояние сети if [ "$pid" != "$(cat "$TSC_DIR/kick.pid" 2>/dev/null)" ]; then logger -t zapret-manager "Tailscale перезапущен: перезапускаем Forkozz и Steer" _tsc_kick_bypass >/dev/null 2>&1 fi ;; NeedsLogin|Stopped) # не вошли: режим без своих правил firewall ставим один раз, а не каждую минуту; # вход перепишет файл состояния — ключ сменится, и сторож всё проверит снова _tsc_prefs >/dev/null 2>&1 [ -n "$TSC_KICK" ] && _tsc_kick_bypass >/dev/null 2>&1 printf '%s\n' "$key" > "$TSC_DIR/watch.key" ;; *) # Starting, NeedsMachineAuth и т. п. — ждём следующей минуты _tsc_prefs >/dev/null 2>&1 [ -n "$TSC_KICK" ] && _tsc_kick_bypass >/dev/null 2>&1 ;; esac return 0 } # Подсказка к ошибке входа: сбитые часы роутера ломают TLS — сервер «не отвечает» _tsc_clock_hint() { [ "$(date +%Y)" -lt 2025 ] 2>/dev/null && printf ' Часы роутера показывают %s — включите синхронизацию времени (LuCI → Система → Система → NTP), без неё вход не пройдёт.' "$(date +%d.%m.%Y)" return 0 } # «tailscale login» в фоне, как кнопка Login в LuCI. plain — только адрес сервера, без имени устройства _tsc_login_bg() { local s h a="" s="$(_tsc_srv)" [ "$1" != plain ] && h="$(_tsc_hostname)" && a="--hostname=$h" [ -n "$s" ] && a="$a --login-server=$s" pkill -f 'tailscale (up|login)' >/dev/null 2>&1 : > "$TSC_UPLOG" # shellcheck disable=SC2086 ( _zm_tmo 900 tailscale login $a >"$TSC_UPLOG" 2>&1 & ) >/dev/null 2>&1 } # Сервер уже подтвердил вход, а роутер всё ждёт — повторяем «tailscale login» (то же, что кнопка в LuCI): # не чаще раза в 30 с и только 15 минут после того, как вход начали в панели _tsc_login_nudge() { local r [ "$1" = NeedsLogin ] || { [ "$1" = Running ] && rm -f "$TSC_DIR/login.req" "$TSC_DIR/login.nudge"; return 0; } [ -s "$TSC_DIR/login.req" ] || return 0 r="$(cat "$TSC_DIR/login.req" 2>/dev/null)" [ $(( $(date +%s) - ${r:-0} )) -lt 900 ] 2>/dev/null || { rm -f "$TSC_DIR/login.req" "$TSC_DIR/login.nudge"; return 0; } [ -f "$TSC_DIR/login.nudge" ] && [ $(( $(date +%s) - $(cat "$TSC_DIR/login.nudge" 2>/dev/null || echo 0) )) -lt 30 ] 2>/dev/null && return 0 date +%s > "$TSC_DIR/login.nudge" _tsc_login_bg } _tsc_login() { local i=0 st url key _tsc_running || _tsc_start >/dev/null 2>&1 || { echo '{"error":"служба Tailscale не запускается — нажмите «Перезапустить» или смотрите журнал роутера"}'; return 1; } st="$(_tsc_json | jsonfilter -e '@.BackendState' 2>/dev/null)" if [ "$st" = Running ]; then printf '{"ok":true,"state":"Running"}\n'; return 0; fi pkill -f 'tailscale (up|login)' >/dev/null 2>&1 _tsc_uci_sync : > "$TSC_UPLOG" key="$(cat "$TSC_KEY" 2>/dev/null)" if [ -n "$key" ]; then # shellcheck disable=SC2046 _zm_tmo 45 tailscale up --reset $(_tsc_upflags) --auth-key="$key" >"$TSC_UPLOG" 2>&1 st="$(_tsc_json | jsonfilter -e '@.BackendState' 2>/dev/null)" case "$st" in Running|NeedsMachineAuth) printf '{"ok":true,"state":"%s"}\n' "$st"; return 0 ;; esac printf '{"error":"%s"}\n' "$(esc "Сервер не принял ключ. Проверьте, что это Device Auth Key, а не Management Key, и адрес сервера. Ключ Routerich действует год с создания сети.$(_tsc_clock_hint) $(tail -n 2 "$TSC_UPLOG" 2>/dev/null | sed "s#$key#***#g" | tr '\n' ' ')")" return 1 fi # Вход через аккаунт — как кнопка Login в LuCI: «tailscale login» в фоне и ждём ссылку до 30 с. # Остальные настройки (выход в интернет, firewall) панель применит сама сразу после входа. mkdir -p "$TSC_DIR"; date +%s > "$TSC_DIR/login.req"; rm -f "$TSC_DIR/login.nudge" _tsc_login_bg while [ "$i" -lt 30 ]; do sleep 1; i=$((i + 1)) # имя устройства сервер не принял — повторяем как LuCI, без своих флагов if [ "$i" = 4 ] && grep -qiE 'requires mentioning|invalid|error' "$TSC_UPLOG" 2>/dev/null && ! grep -q 'https://' "$TSC_UPLOG" 2>/dev/null; then _tsc_login_bg plain fi url="$(_tsc_json | jsonfilter -e '@.AuthURL' 2>/dev/null)" [ -n "$url" ] || url="$(grep -o 'https\{0,1\}://[^[:space:]]*' "$TSC_UPLOG" 2>/dev/null | head -n1)" [ -n "$url" ] && { printf '{"ok":true,"url":"%s"}\n' "$(esc "$url")"; return 0; } st="$(_tsc_json | jsonfilter -e '@.BackendState' 2>/dev/null)" [ "$st" = Running ] && { rm -f "$TSC_DIR/login.req"; printf '{"ok":true,"state":"Running"}\n'; return 0; } done printf '{"error":"%s"}\n' "$(esc "Tailscale не выдал ссылку для входа. Проверьте интернет на роутере и попробуйте ещё раз.$(_tsc_clock_hint) $(tail -n 2 "$TSC_UPLOG" 2>/dev/null | tr '\n' ' ')")" return 1 } _tsc_route_ok() { { printf '%s' "$1" | jsonfilter -e '@.Self.PrimaryRoutes[*]' 2>/dev/null; printf '%s' "$1" | jsonfilter -e '@.Self.AllowedIPs[*]' 2>/dev/null; } | awk -v want="$2" ' function ipn(a, o) { if (split(a, o, ".") != 4) return -1; return ((o[1] * 256 + o[2]) * 256 + o[3]) * 256 + o[4] } BEGIN { split(want, w, "/"); wa = ipn(w[1]); wm = (w[2] == "" ? 32 : w[2] + 0) } $0 !~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\/[0-9]+$/ { next } { split($0, r, "/"); m = r[2] + 0; if (m < 1 || m > wm) next; z = 1; for (i = m; i < 32; i++) z *= 2; if (int(ipn(r[1]) / z) == int(wa / z)) { ok = 1; exit } } END { exit !ok }' } TSC_MANUAL="$TSC_DIR/approved.manual" tsc_status() { local inst=false run=false en=false on=false j="" st="" url="" ip="" host="" tn="" exo=false lano=false man=false local pok=true cidr="" ver="" kexp="" lip lprev="" net=false zone=false nft=false tg=false tgr=0 zr=false st_on=false z lz free _tsc_installed && { inst=true; ver="$(_tsc_ver)"; } [ -f "$TSC_ON" ] && on=true _tsc_enabled && en=true if $inst && _tsc_running; then run=true j="$(_tsc_json)" fi if [ -n "$j" ]; then st="$(printf '%s' "$j" | jsonfilter -e '@.BackendState' 2>/dev/null)" _tsc_login_nudge "$st" url="$(printf '%s' "$j" | jsonfilter -e '@.AuthURL' 2>/dev/null)" ip="$(printf '%s' "$j" | jsonfilter -e '@.Self.TailscaleIPs[*]' 2>/dev/null | grep -m1 '\.')" host="$(printf '%s' "$j" | jsonfilter -e '@.Self.HostName' 2>/dev/null)" tn="$(printf '%s' "$j" | jsonfilter -e '@.CurrentTailnet.Name' 2>/dev/null)" kexp="$(printf '%s' "$j" | jsonfilter -e '@.Self.KeyExpiry' 2>/dev/null)" [ "$(printf '%s' "$j" | jsonfilter -e '@.Self.ExitNodeOption' 2>/dev/null)" = true ] && exo=true printf '%s' "$j" | jsonfilter -e '@.Self.AllowedIPs[*]' 2>/dev/null | grep -qx '0.0.0.0/0' && exo=true cidr="$(_tsc_lan_cidr)" [ -n "$cidr" ] && _tsc_route_ok "$j" "$cidr" && lano=true [ "$st" = Running ] && [ -s "$TSC_MANUAL" ] && { ! $exo && grep -qx 'exit' "$TSC_MANUAL" && exo=true && man=true ! $lano && [ -n "$cidr" ] && grep -qxF "lan $cidr" "$TSC_MANUAL" && lano=true && man=true } if [ "$st" = Running ] && [ -f "$TSC_ON" ] && ! _job_alive tsc; then local dp dp="$(_tsc_dbg)" _tsc_prefs_ok "$dp" || { _tsc_prefs "$dp" >/dev/null 2>&1; [ -n "$TSC_KICK" ] && _tsc_kick_bg; _tsc_prefs_ok || pok=false; } fi fi [ -z "$cidr" ] && cidr="$(_tsc_lan_cidr)" # адрес роутера в домашней сети, под который настраивали телефон (DNS): запоминаем при # первом подключении, при смене — подсказываем поправить DNS в панели сервера lip="$(_zm_lan_ip)" if [ "$st" = Running ] && [ -n "$lip" ]; then [ -s "$TSC_DIR/lan.ip" ] || printf '%s\n' "$lip" > "$TSC_DIR/lan.ip" 2>/dev/null lprev="$(cat "$TSC_DIR/lan.ip" 2>/dev/null)" [ "$lprev" = "$lip" ] && lprev="" $on && $run && _tsc_tg_ip_fix >/dev/null 2>&1 fi [ -f "$TSC_ON" ] && $inst && ! grep -qF "$TSC_CRON_TAG" "$CRON_FILE" 2>/dev/null && _tsc_cron on mkdir -p "$TSC_DIR" 2>/dev/null; printf '%s\n' "${st:-}" > "$TSC_STATE" 2>/dev/null [ "$(uci -q get "network.$TSC_NET")" = interface ] && net=true lz="$(_zm_lan_zone)" z="$(_zm_zone_sec "$lz")" && case " $(uci -q get "firewall.$z.network") " in *" $TSC_NET "*) zone=true ;; esac [ -f "$TSC_NFT" ] && nft=true if [ -f /etc/init.d/tgws ]; then tg=true $on && $run && [ "$(_tsc_tg_rules)" -lt 2 ] 2>/dev/null && [ -n "$(tgws status 2>/dev/null)" ] && _tsc_tg_ensure >/dev/null 2>&1 tgr="$(_tsc_tg_rules)" fi [ -f /etc/init.d/zapret ] && pgrep -f "/opt/zapre[t]/" >/dev/null 2>&1 && zr=true _tsc_steer_on && st_on=true free="$(_tsc_free_kb)" printf '{"installed":%s,"version":"%s","running":%s,"enabled":%s,"on":%s,"state":"%s","auth_url":"%s","ip":"%s","host":"%s","tailnet":"%s",' \ "$inst" "$(esc "$ver")" "$run" "$en" "$on" "$(esc "$st")" "$(esc "$url")" "$(esc "$ip")" "$(esc "$host")" "$(esc "$tn")" printf '"exit_want":%s,"exit_ok":%s,"lan_want":%s,"lan_ok":%s,"lan_cidr":"%s","lan_ip":"%s","net":%s,"zone":%s,"nft":%s,' \ "$([ -f "$TSC_NOEXIT" ] && echo false || echo true)" "$exo" "$([ -f "$TSC_LAN" ] && echo true || echo false)" "$lano" \ "$(esc "$cidr")" "$(esc "$lip")" "$net" "$zone" "$nft" local lat="" vcur $inst && lat="$(_zm_cached tsc _tsc_gh_latest)" vcur="$(_tsc_vnum "$ver")" printf '"manual_ok":%s,' "$man" printf '"prefs_ok":%s,"luci_path":"%s","key_expiry":"%s","lan_prev":"%s","term_mode":"%s",' "$pok" "$(esc "$(_tsc_luci_path)")" "$(esc "$kexp")" "$(esc "$lprev")" "$(_term_installed && _term_mode)" printf '"fk":%s,"fk_ok":%s,"mx":%s,"adblock":%s,' "$(_fk_installed && echo true || echo false)" "$(_tsc_fk_has && echo true || echo false)" \ "$([ -x /etc/init.d/magitrickle ] && echo true || echo false)" "$(_ab_on && echo true || echo false)" printf '"luci_app":%s,"latest":"%s","newer":%s,"source":"%s",' "$(_tsc_luci_app && echo true || echo false)" "$(esc "$(_tsc_vnum "$lat")")" \ "$([ -n "$lat" ] && [ -n "$vcur" ] && _st_ver_lt "$vcur" "$(_tsc_vnum "$lat")" && echo true || echo false)" "$(esc "$(cat "$TSC_SRC" 2>/dev/null)")" printf '"server":"%s","server_kind":"%s","admin":"%s","has_key":%s,' "$(esc "$(_tsc_srv)")" "$(_tsc_kind)" "$(esc "$(_tsc_admin)")" "$([ -s "$TSC_KEY" ] && echo true || echo false)" printf '"stgws":%s,"tg_rules":%s,"zapret":%s,"zapret_inst":%s,"steer":%s,"steer_inst":%s,"steer_dns":"%s","free_kb":%s,"need_kb":%s,"job":%s}\n' \ "$tg" "${tgr:-0}" "$zr" "$([ -f /etc/init.d/zapret ] && echo true || echo false)" "$st_on" "$(_st_installed && echo true || echo false)" "$(esc "$(_st_dnsmode 2>/dev/null)")" "${free:-0}" "$TSC_NEED_KB" \ "$(_job_alive tsc && echo true || echo false)" } do_tsc_install() { local free _ensure_deps _tsc_say "Проверяем место на флеш-памяти" free="$(_tsc_free_kb)" if [ -n "$free" ] && [ "$free" -lt "$TSC_NEED_KB" ]; then echo "ОШИБКА: свободно $((free / 1024)) МБ, а Tailscale нужно около $((TSC_NEED_KB / 1024)) МБ. Освободите место — например, удалите ненужные компоненты в разделе «Очистка и удаление»" return 1 fi [ -n "$free" ] && echo " ✓ Свободно $((free / 1024)) МБ" _tsc_say "Обновляем список пакетов OpenWrt" $UPDATE || echo "!! Список пакетов не обновился — пробуем с тем, что есть" if _tsc_installed; then echo " ✓ Tailscale уже установлен: $(_tsc_ver)" else _tsc_say "Устанавливаем Tailscale с GitHub — уменьшенная сборка для OpenWrt" if ! _tsc_install_gh; then _tsc_say "Пробуем репозиторий OpenWrt (пакет больше, около 25 МБ)" $INSTALL tailscale || { echo "ОШИБКА: Tailscale не установился ни с GitHub, ни из репозитория OpenWrt — проверьте интернет и место на флеш-памяти"; return 1; } mkdir -p "$TSC_DIR"; echo openwrt > "$TSC_SRC" fi _tsc_installed || { echo "ОШИБКА: файла /usr/sbin/tailscaled нет — пакет встал не полностью"; return 1; } echo " ✓ Tailscale $(_tsc_ver)" fi _tsc_say "Ставим страницу Tailscale для LuCI (luci-app-tailscale-community)" if _tsc_luci_app || _tsc_install_luci; then rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* 2>/dev/null case "$(_tsc_luci_path)" in admin/vpn/*) echo " ✓ Готово: LuCI → VPN → Tailscale" ;; *) echo " ✓ Готово: LuCI → Службы → Tailscale" ;; esac else echo " · Не установилась — не страшно, всё настраивается на этой странице" fi mkdir -p "$TSC_DIR" [ -f "$TSC_DIR/lan.init" ] || { : > "$TSC_LAN"; : > "$TSC_DIR/lan.init"; } _tsc_say "Запускаем службу Tailscale" _tsc_start || return 1 echo " ✓ Служба работает" _tsc_setup _tsc_say "Готово. Осталось войти в аккаунт Tailscale — кнопка «Войти» на этой странице" } do_tsc_update() { local old new lat _tsc_installed || { echo "ОШИБКА: Tailscale не установлен"; return 1; } old="$(_tsc_ver)" _tsc_say "Обновляем Tailscale (сейчас $old)" $UPDATE >/dev/null 2>&1 _tsc_install_gh || { echo "ОШИБКА: свежая версия не скачалась — Tailscale $old продолжает работать"; return 1; } rm -f "$TSC_DIR/version" _tsc_say "Перезапускаем службу" _tsc_start || return 1 _tsc_prefs _tsc_kick_bypass ZM_VER_FORCE=1 _zm_cached tsc _tsc_gh_latest >/dev/null 2>&1 new="$(_tsc_ver)" lat="$(_tsc_vnum "$(cat "$ZM_STATE_DIR/latest.tsc" 2>/dev/null)")" if [ -n "$new" ] && [ -n "$lat" ] && _st_ver_lt "$(_tsc_vnum "$new")" "$lat"; then echo "!! Пакет установился, но Tailscale по-прежнему $new — возможно, на флеш-памяти не хватило места или файл программы занят. Перезагрузите роутер и проверьте версию ещё раз" return 1 fi if [ "$new" = "$old" ]; then _tsc_say "Готово: Tailscale $new"; else _tsc_say "Готово: Tailscale $old → $new"; fi } do_tsc_repair() { _tsc_installed || { echo "ОШИБКА: Tailscale не установлен"; return 1; } _tsc_running || { _tsc_say "Запускаем службу Tailscale"; _tsc_start || return 1; } _tsc_setup _tsc_prefs _tsc_say "Готово: настройки для телефона на месте" } do_tsc_remove() { local rc=0 _tsc_say "Выходим из аккаунта и останавливаем Tailscale" _tsc_running && _zm_tmo 10 tailscale logout >/dev/null 2>&1 pkill -f 'tailscale (up|login)' >/dev/null 2>&1 if [ -x /etc/init.d/tailscale ]; then /etc/init.d/tailscale stop >/dev/null 2>&1; /etc/init.d/tailscale disable >/dev/null 2>&1; fi killall tailscaled >/dev/null 2>&1 echo " ✓ Служба остановлена" _tsc_unsetup _tsc_say "Удаляем пакет tailscale" _zm_pkg_wipe luci-i18n-tailscale-community-ru luci-app-tailscale-community >/dev/null 2>&1 _zm_pkg_wipe tailscale || rc=1 _zm_rm_listed tailscale || rc=1 rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* 2>/dev/null _tsc_installed && rc=1 _zm_rm_done "$rc" "Tailscale" } _tsc_server_set() { local srv key old st srv="$(printf '%s' "$1" | head -n1 | cut -f1 | tr -d '\r ')" key="$(printf '%s' "$1" | head -n1 | cut -s -f2 | tr -d '\r ')" srv="${srv%/}" case "$srv" in '') ;; http://*|https://*) printf '%s' "$srv" | grep -qE '^https?://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._~/-]*)?$' || { echo '{"error":"адрес сервера записан с ошибкой — пример: https://rc.routerich.ru"}'; return 1; } ;; *) echo '{"error":"адрес сервера должен начинаться с https://"}'; return 1 ;; esac if [ -n "$key" ]; then [ "${#key}" -le 300 ] && printf '%s' "$key" | grep -qE '^[A-Za-z0-9_:.+/=-]+$' || { echo '{"error":"в ключе есть лишние символы — скопируйте его целиком, без пробелов"}'; return 1; } printf '%s' "$key" | grep -qE '^[0-9a-fA-F]{64}$' && { echo '{"error":"это похоже на Management Key (ключ входа в панель). Для роутера и телефона нужен Device Auth Key"}'; return 1; } fi [ -z "$srv" ] && key="" old="$(_tsc_srv)" mkdir -p "$TSC_DIR" if [ -n "$srv" ]; then printf '%s\n' "$srv" > "$TSC_SRV"; else rm -f "$TSC_SRV"; fi if [ -n "$key" ]; then ( umask 077; printf '%s\n' "$key" > "$TSC_KEY" ); else rm -f "$TSC_KEY"; fi # другой сервер — другая сеть: DNS телефона там настраивается заново под текущий адрес [ "$old" != "$srv" ] && rm -f "$TSC_DIR/lan.ip" if [ "$old" != "$srv" ] && _tsc_running; then st="$(_tsc_json | jsonfilter -e '@.BackendState' 2>/dev/null)" case "$st" in Running|NeedsMachineAuth|Starting|Stopped) pkill -f 'tailscale (up|login)' >/dev/null 2>&1 _zm_tmo 15 tailscale logout >/dev/null 2>&1 ;; esac fi printf '{"ok":true,"changed":%s}\n' "$([ "$old" != "$srv" ] && echo true || echo false)" } tsc_action() { local a="$1" m="$2" j case "$a" in install|remove|repair|update) _job_alive tsc && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } if j="$(_zm_busy_job)"; then printf '{"error":"%s"}\n' "$(esc "Сейчас идёт операция: $j — дождитесь её окончания")"; return 1; fi case "$a" in install) job_start tsc do_tsc_install ;; remove) job_start tsc do_tsc_remove ;; repair) job_start tsc do_tsc_repair ;; update) job_start tsc do_tsc_update ;; esac ;; login|logout|restart|exit|lan|server|apply|kick|lanack|approved) _tsc_installed || { echo '{"error":"Tailscale не установлен"}'; return 1; } _job_alive tsc && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } case "$a" in login) _tsc_login ;; server) rm -f "$TSC_MANUAL"; _tsc_server_set "$m" ;; kick) _tsc_kick_bg; printf '{"ok":true}\n' ;; lanack) mkdir -p "$TSC_DIR"; _zm_lan_ip > "$TSC_DIR/lan.ip"; printf '{"ok":true}\n' ;; approved) mkdir -p "$TSC_DIR" if [ "$m" = off ]; then rm -f "$TSC_MANUAL"; printf '{"ok":true}\n'; return 0; fi { [ -f "$TSC_NOEXIT" ] || echo exit; [ -f "$TSC_LAN" ] && _tsc_lan_cidr | sed 's/^/lan /'; } > "$TSC_MANUAL" printf '{"ok":true}\n' ;; apply) _tsc_prefs [ -n "$TSC_KICK" ] && _tsc_kick_bg if _tsc_prefs_ok; then printf '{"ok":true}\n' else echo '{"error":"Tailscale не принял настройки — нажмите «Перезапустить» и попробуйте ещё раз"}'; return 1; fi ;; logout) rm -f "$TSC_MANUAL" pkill -f 'tailscale (up|login)' >/dev/null 2>&1 _zm_tmo 15 tailscale logout >/dev/null 2>&1 || { echo '{"error":"не получилось выйти из аккаунта — смотрите журнал роутера"}'; return 1; } printf '{"ok":true}\n' ;; restart) _tsc_start >/dev/null 2>&1 || { echo '{"error":"служба Tailscale не запустилась — смотрите журнал роутера"}'; return 1; } _tsc_kick_bg printf '{"ok":true}\n' ;; exit|lan) case "$m" in on|off) ;; *) echo '{"error":"неизвестный режим"}'; return 1 ;; esac mkdir -p "$TSC_DIR" if [ "$a" = exit ]; then if [ "$m" = on ]; then rm -f "$TSC_NOEXIT"; else : > "$TSC_NOEXIT"; fi else if [ "$m" = on ]; then [ -n "$(_tsc_lan_cidr)" ] || { echo '{"error":"не удалось узнать подсеть домашней сети"}'; return 1; } : > "$TSC_LAN" else rm -f "$TSC_LAN"; fi fi [ "$m" = off ] && rm -f "$TSC_MANUAL" _tsc_prefs || { echo '{"error":"Tailscale не принял настройку — смотрите журнал роутера"}'; return 1; } printf '{"ok":true}\n' ;; esac ;; *) echo '{"error":"неизвестное действие"}'; return 1 ;; esac } _tsc_dot() { _tsc_installed || { echo 0; return; } if _tsc_running; then [ "$(cat "$TSC_STATE" 2>/dev/null)" = Running ] && echo 1 || echo 4 elif _tsc_enabled; then echo 2; else echo 4; fi } MON_HTOP_PID="$JOBS_DIR/htop.pid" MON_TTYD_OWN="$ZM_STATE_DIR/ttyd.htop" _mon_alive() { [ -d "/proc/$1" ] && ! grep -q '^State:[[:space:]]*Z' "/proc/$1/status" 2>/dev/null; } _mon_kill() { local pid="${1%%:*}" sig=TERM name ppid t=0 case "$1" in *:KILL) sig=KILL ;; esac case "$pid" in ''|*[!0-9]*) echo '{"error":"неверный номер процесса"}'; return 1 ;; esac [ "$pid" -gt 2 ] || { echo '{"error":"этот процесс завершать нельзя"}'; return 1; } _mon_alive "$pid" || { echo '{"error":"процесс уже завершился"}'; return 1; } name="$(cat "/proc/$pid/comm" 2>/dev/null)" ppid="$(sed -n 's/^PPid:[[:space:]]*//p' "/proc/$pid/status" 2>/dev/null)" [ "$ppid" = 2 ] && { echo '{"error":"это поток ядра Linux — его не завершают"}'; return 1; } case "$name" in procd|ubusd|rpcd|uhttpd|netifd|logd|ubox|init) printf '{"error":"%s"}\n' "$(esc "«$name» — служба, без которой роутер или эта панель перестанут отвечать. Её не завершаем: перезапустите её в LuCI → Система → Автозагрузка")" return 1 ;; esac kill -s "$sig" "$pid" 2>/dev/null || { echo '{"error":"не удалось отправить сигнал процессу"}'; return 1; } while [ "$t" -lt 3 ] && _mon_alive "$pid"; do /bin/sleep 1; t=$((t + 1)); done printf '{"ok":true,"name":"%s","alive":%s}\n' "$(esc "$name")" "$(_mon_alive "$pid" && echo true || echo false)" } _mon_kids() { grep -l "^PPid:[[:space:]]*$1\$" /proc/[0-9]*/status 2>/dev/null | sed 's#^/proc/\([0-9]*\)/status$#\1#'; } _mon_htop_close() { local p c t=0 p="$(cat "$MON_HTOP_PID" 2>/dev/null)" rm -f "$MON_HTOP_PID" case "$p" in ''|*[!0-9]*) return 0 ;; esac grep -q ttyd "/proc/$p/comm" 2>/dev/null || return 0 for c in $(_mon_kids "$p"); do kill "$c" 2>/dev/null; done kill "$p" 2>/dev/null while [ "$t" -lt 3 ] && _mon_alive "$p"; do /bin/sleep 1; t=$((t + 1)); done _mon_alive "$p" && kill -9 "$p" 2>/dev/null return 0 } _mon_htop_open() { local tok port=7682 dev w="" p [ -x /usr/bin/htop ] || { echo '{"error":"htop не установлен"}'; return 1; } _term_installed || { echo '{"error":"нет ttyd — он нужен, чтобы показать htop в браузере. Нажмите «Установить htop»"}'; return 1; } _mon_htop_close tok="$(head -c 16 /dev/urandom 2>/dev/null | hexdump -v -e '/1 "%02x"' 2>/dev/null)" [ "${#tok}" = 32 ] || tok="$(head -c 16 /dev/urandom 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n')" [ "${#tok}" = 32 ] || { echo '{"error":"не удалось создать одноразовый адрес"}'; return 1; } while netstat -ltn 2>/dev/null | grep -q "[:.]${port}[[:space:]]"; do port=$((port + 1)) [ "$port" -gt 7699 ] && { echo '{"error":"нет свободного порта 7682–7699"}'; return 1; } done dev="$(_mon_dev lan)" # с телефона через Tailscale подключение приходит не через br-lan, а ttyd с -i принимает # только свой интерфейс (SO_BINDTODEVICE). Одноразовый ttyd с токеном в пути при закрытом # из интернета firewall можно слушать на всех интерфейсах. [ -f "$TSC_ON" ] && _tsc_wan_closed && dev="" ttyd --help 2>&1 | grep -q -- '--writable' && w="-W" ( TERM=xterm-256color ttyd -p "$port" ${dev:+-i "$dev"} -o -m 1 $w -b "/$tok" htop /dev/null 2>&1 & echo $! > "$MON_HTOP_PID" ) sleep 1 p="$(cat "$MON_HTOP_PID" 2>/dev/null)" kill -0 "$p" 2>/dev/null || { rm -f "$MON_HTOP_PID"; echo '{"error":"ttyd с htop не запустился — смотрите журнал роутера"}'; return 1; } ( sleep 120; [ "$(cat "$MON_HTOP_PID" 2>/dev/null)" = "$p" ] && kill -0 "$p" 2>/dev/null && [ -z "$(_mon_kids "$p")" ] && _mon_htop_close ) >/dev/null 2>&1 Устанавливаем htop" mkdir -p "$ZM_STATE_DIR"; rm -f "$ZM_STATE_DIR/htop.off" $UPDATE || echo "!! Список пакетов не обновился — пробуем с тем, что есть" if [ ! -x /usr/bin/htop ]; then $INSTALL htop || { echo "ОШИБКА: htop не установился — проверьте интернет и доступность пакета для вашей прошивки"; return 1; } : > "$ZM_STATE_DIR/htop.own" fi [ -x /usr/bin/htop ] || { echo "ОШИБКА: файла /usr/bin/htop нет — пакет встал не полностью"; return 1; } echo " ✓ $(htop --version 2>/dev/null | head -n1)" if ! _term_installed; then echo "==> Устанавливаем ttyd — через него htop показывается в браузере" $INSTALL ttyd || { echo "ОШИБКА: ttyd не установился — проверьте интернет и доступность пакета для вашей прошивки"; return 1; } _term_installed || { echo "ОШИБКА: файла /usr/bin/ttyd нет — пакет встал не полностью"; return 1; } [ -x /etc/init.d/ttyd ] && { /etc/init.d/ttyd stop >/dev/null 2>&1; /etc/init.d/ttyd disable >/dev/null 2>&1; } mkdir -p "$ZM_STATE_DIR"; : > "$MON_TTYD_OWN" echo " ✓ ttyd $(_term_version) — только для htop, вкладка «Терминал» не включена" fi echo "==> Готово: htop установлен" } do_htop_remove() { local rc=0 _mon_htop_close echo "==> Удаляем htop" _zm_pkg_wipe htop || rc=1 rm -rf /etc/htoprc /root/.config/htop 2>/dev/null if [ -f "$MON_TTYD_OWN" ] && _term_installed && ! _term_enabled; then echo "==> ttyd ставился только для htop — удаляем и его" do_term_remove || rc=1 fi rm -f "$MON_TTYD_OWN" "$ZM_STATE_DIR/htop.own" mkdir -p "$ZM_STATE_DIR"; : > "$ZM_STATE_DIR/htop.off" [ -x /usr/bin/htop ] && rc=1 _zm_rm_done "$rc" "htop" } htop_gone() { _mon_htop_close if [ -f "$ZM_STATE_DIR/htop.own" ] && [ -x /usr/bin/htop ]; then _zm_pkg_wipe htop >/dev/null 2>&1 rm -rf /etc/htoprc /root/.config/htop 2>/dev/null fi if [ -f "$MON_TTYD_OWN" ] && _term_installed && ! _term_enabled; then ZM_RM_INNER=1 do_term_remove >/dev/null 2>&1 fi rm -f "$ZM_STATE_DIR/htop.own" "$MON_TTYD_OWN" return 0 } monitor_action() { local a="$1" m="$2" j case "$a" in kill) _mon_kill "$m" ;; htop_install|htop_remove) _job_alive htop && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } if j="$(_zm_busy_job)"; then printf '{"error":"%s"}\n' "$(esc "Сейчас идёт операция: $j — дождитесь её окончания")"; return 1; fi if [ "$a" = htop_install ]; then job_start htop do_htop_install; else job_start htop do_htop_remove; fi ;; htop_open) _mon_htop_open ;; htop_close) _mon_htop_close; printf '{"ok":true}\n' ;; *) echo '{"error":"неизвестное действие"}'; return 1 ;; esac } WATCH_OFF="$ZM_STATE_DIR/watch.off" WATCH_CRON_LINE='*/2 * * * * /opt/zapret-manager-luci/backend.sh zm_watch >/dev/null 2>&1 # zm-rpcd-watch' _watch_cron() { mkdir -p /etc/crontabs 2>/dev/null [ -f "$CRON_FILE" ] || : > "$CRON_FILE" sed -i '/# zm-rpcd-watch$/d' "$CRON_FILE" [ "$1" = on ] && echo "$WATCH_CRON_LINE" >> "$CRON_FILE" /etc/init.d/cron restart >/dev/null 2>&1 return 0 } watch_status() { local on=true cron=false last=0 items="" sep="" b [ -f "$WATCH_OFF" ] && on=false grep -q '# zm-rpcd-watch$' "$CRON_FILE" 2>/dev/null && cron=true [ -f "$JOBS_DIR/watch.last" ] && last="$(date -r "$JOBS_DIR/watch.last" +%s 2>/dev/null || echo 0)" _wi() { items="$items$sep{\"id\":\"$1\",\"title\":\"$(esc "$2")\",\"what\":\"$(esc "$3")\",\"active\":$4}"; sep=","; } _wi rpcd "Вход в LuCI и Web UI" "rpcd завис или не видит панель — перезапускает его. Без этого после такого сбоя войти получится только после перезагрузки роутера" true _wi recover "Возврат после оборванных операций" "Тест стратегий, смена DNS или сети оборвались (перезагрузка, пропало питание) — возвращает прежние настройки" true b=false; _ab_on && b=true _wi adblock "Блокировка рекламы после перезагрузки" "Списки dnsmasq живут в памяти: после перезагрузки сторож подключает их снова. Без него реклама не блокируется, пока не нажмёте «Применить» на вкладке" "$b" b=false; _fk_installed && ! _fk_foreign && _fk_enabled && b=true _wi forkozz "Forkozz" "Поднимает sing-box, если он упал, переключает на запасной сервер и возвращает правки панели после обновления движка" "$b" b=false; _st_installed && [ ! -f "$ST_OFF" ] && b=true _wi steer "Steer" "Следит за туннелями WARP и VPN по подписке, поднимает их после сбоя" "$b" b=false; [ -f "$TG_WATCH_FLAG" ] && _tg_any_installed && b=true _wi tg "TG WS Proxy" "Перезапускает прокси для Telegram, если он перестал работать" "$b" b=false; [ "$(_dn_get ru off)" = isp ] && b=true _wi dnsnet "DNS провайдера для российских сайтов" "Провайдер сменил адреса DNS — обновляет их в настройках" "$b" b=false; [ "$(_ver_mode)" = day ] && b=true _wi ver "Поиск новых версий ночью" "Раз в сутки, около 04:$(printf '%02d' "$(_ver_minute)"), узнаёт, вышли ли обновления панели и компонентов. Выключено — Дашборд проверит сам при открытии, если с прошлой проверки прошли сутки" "$b" b="$(grep -F '/opt/zapret-manager-luci/backend.sh' "$CRON_FILE" 2>/dev/null | grep -v -e '# zm-rpcd-watch$' -e '# zm-versions$' | grep -vc '^[[:space:]]*#')" printf '{"on":%s,"cron":%s,"last":%s,"now":%s,"others":%s,"items":[%s]}\n' "$on" "$cron" "${last:-0}" "$(date +%s)" "${b:-0}" "$items" } watch_set() { case "$1" in off) mkdir -p "$ZM_STATE_DIR"; : > "$WATCH_OFF" ZM_NO_CRON_RESTART=1 _ver_cron_sync _watch_cron off logger -t zapret-manager "Фоновые проверки выключены в «Системе» — сторож панели не запускается" ;; on) rm -f "$WATCH_OFF" ZM_NO_CRON_RESTART=1 _ver_cron_sync _watch_cron on ( ZM_WATCH_FORCE=1 /opt/zapret-manager-luci/backend.sh zm_watch >/dev/null 2>&1 /dev/null 2>&1 /dev/null)" [ -n "$ui" ] || return 1 case "$ui" in /*) [ -d "$ui" ] && { echo "$ui"; return 0; }; return 1 ;; esac pid="$(pidof sing-box 2>/dev/null | awk '{ print $1 }')" base="" [ -n "$pid" ] && base="$(readlink "/proc/$pid/cwd" 2>/dev/null)" for base in "$base" /etc/sing-box /tmp/sing-box /var/run/sing-box; do [ -n "$base" ] && [ -d "$base/$ui" ] && { echo "$base/$ui"; return 0; } done return 1 } _fk_zash_ensure() { local quiet="$1" d stage tmp="/tmp/zm-zash" i ok=0 fail="$ZM_STATE_DIR/fk.zash.fail" now last [ "$(uci -q get netshift.settings.enable_yacd)" = 1 ] || return 0 d="$(_fk_ui_dir)" || return 0 [ -f "$d/$FK_ZASH_MARK" ] && { rm -f "$fail"; return 0; } now="$(date +%s)" if [ -n "$quiet" ]; then last="$(cat "$fail" 2>/dev/null)"; last="${last:-0}" [ $((now - last)) -lt 1800 ] && return 0 else _fk_say "Ставим панель Zashboard вместо YACD" fi rm -rf "$tmp" "$tmp.zip"; mkdir -p "$tmp" for i in 1 2 3; do curl -sSfL --connect-timeout 5 --max-time 40 -o "$tmp.zip" "${GH_MAIN}/Zephyruso/zashboard/releases/latest/download/dist-cdn-fonts.zip" >/dev/null 2>&1 && { ok=1; break; } sleep 1 done if [ "$ok" = 1 ]; then command -v unzip >/dev/null 2>&1 || $INSTALL unzip >/dev/null 2>&1 unzip -oq "$tmp.zip" -d "$tmp" >/dev/null 2>&1 || ok=0 fi stage="$tmp/dist" [ "$ok" = 1 ] && [ -f "$stage/index.html" ] || ok=0 if [ "$ok" = 1 ]; then rm -rf "$d.zm-new" if cp -r "$stage" "$d.zm-new" 2>/dev/null && : > "$d.zm-new/$FK_ZASH_MARK"; then { rm -rf "$d" && mv "$d.zm-new" "$d"; } 2>/dev/null || { mkdir -p "$d"; cp -r "$d.zm-new"/. "$d"/ 2>/dev/null; rm -rf "$d.zm-new"; } else rm -rf "$d.zm-new"; ok=0 fi fi rm -rf "$tmp" "$tmp.zip" if [ "$ok" = 1 ] && [ -f "$d/$FK_ZASH_MARK" ]; then rm -f "$fail" [ -n "$quiet" ] && logger -t zapret-manager "Forkozz: панель sing-box — Zashboard" || echo " ✓ Zashboard установлен" return 0 fi echo "$now" > "$fail" [ -n "$quiet" ] || echo "!! Zashboard поставить не удалось (GitHub недоступен или мало памяти) — остаётся стандартная панель sing-box" return 0 } FK_REPO="yandexru45/netshift" FK_PIN="0.9.11" FK_MARK="/usr/lib/netshift/zm-managed" FK_UC="/opt/zapret-manager-luci/netshift.uc" 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_NFT_GUARD="NetShiftGuard" FK_REL="env NETSHIFT_BLOCK_LEAKS_RELEASE=1" FK_SB_PKGS="sing-box sing-box-tiny sing-box-extended podkop-engine podkop-engine-full" FK_DEPS_OWN="$ZM_STATE_DIR/netshift.deps" FK_FILES="/usr/lib/netshift /usr/lib/netshift.zm-old /etc/netshift /usr/bin/netshift /etc/init.d/netshift /etc/rc.d/*netshift /etc/config/netshift /etc/config/netshift-opkg /etc/config/netshift-070 /etc/config/netshift.apk-new \ /etc/init.d/netshift-naive /etc/rc.d/*netshift-naive /var/run/netshift* /var/lock/netshift* /tmp/netshift* /tmp/local-ruleset.json /tmp/local-ruleset.srs /tmp/sing-box-config.json /tmp/sing-box/rulesets /tmp/sing-box/subscription* \ /www/luci-static/resources/view/netshift /usr/share/luci/menu.d/luci-app-netshift.json /usr/share/rpcd/acl.d/luci-app-netshift.json \ /tmp/zm-fk-* /tmp/zm-zash /tmp/zm-zash.zip" _fk_say() { echo "==> $*"; } _fk_installed() { [ -x /usr/bin/netshift ] && [ -f /usr/lib/netshift/constants.sh ]; } _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_SERIAL_DIR="/var/run/netshift-zm-serial" _fk_engine_busy() { local p if [ -d "$FK_SERIAL_DIR" ]; then p="$(cat "$FK_SERIAL_DIR/pid" 2>/dev/null)" case "$p" in ''|*[!0-9]*) [ -n "$(find "$FK_SERIAL_DIR" -maxdepth 0 -mmin -1 2>/dev/null)" ] && return 0 ;; *) kill -0 "$p" 2>/dev/null && return 0 ;; esac fi ubus call service list '{"name":"netshift"}' 2>/dev/null | grep -q '"running": *true' } _fk_ready() { _fk_up && ! _fk_engine_busy; } _fk_settle() { local i=0 max="${1:-120}" _fk_engine_busy || return 0 [ -n "$2" ] && _fk_say "$2" while [ "$i" -lt "$max" ]; do sleep 1 i=$((i + 1)) _fk_engine_busy || return 0 done return 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 podkop-engine podkop-engine-full; 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_SBL_REPO="yandexru45/sing-box-extended-lite" _fk_sbl_latest() { curl -Ls --connect-timeout 5 --max-time 10 -o /dev/null -w '%{url_effective}' "https://github.com/$FK_SBL_REPO/releases/latest" 2>/dev/null | sed -n 's#.*/tag/[vV]\{0,1\}##p' | head -n1 } # Расширенный облегчённый sing-box ставит сам движок Forkozz (NetShift 0.9.9 и новее) _fk_lite_engine() { grep -q 'install_extended_lite' /usr/lib/netshift/updater.sh /usr/bin/netshift 2>/dev/null; } # Есть ли сборка lite под этот роутер: amd64, arm64, armv7, mips и mipsel (softfloat) _fk_lite_arch() { _tg_arch_init case "$TG_ARCH" in x86_64*) echo amd64 ;; aarch64*) echo arm64 ;; arm_cortex-a*|armv7*|arm_arm_cortex-a*) echo armv7 ;; mipsel*|mipsle*) echo mipsle-softfloat ;; mips64*) return 1 ;; mips*) echo mips-softfloat ;; *) return 1 ;; esac } _fk_lite_state() { _fk_lite_arch >/dev/null || { echo arch; return 0; } _fk_installed && ! _fk_lite_engine && { echo engine; return 0; } echo ok } _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() { if [ "$PKG" = apk ]; then apk info -e netshift >/dev/null 2>&1 || apk info -e luci-app-netshift >/dev/null 2>&1 else [ -f /usr/lib/opkg/info/netshift.control ] || [ -f /usr/lib/opkg/info/luci-app-netshift.control ]; fi } _fk_ours() { _fk_installed && ! _fk_foreign; } _fk_name() { if _fk_foreign; then echo NetShift; else echo Forkozz; fi; } _fk_foreign_err() { _fk_foreign || return 1; printf '{"error":"%s","foreign":true}\n' "$(esc "Стоит NetShift отдельным пакетом — он настраивается в своём разделе: Службы → 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_device_setup() { local res _fk_foreign && { echo '{"ok":true,"changed":false,"foreign":true}'; return 0; } res="$(_fk_ucj device_setup)" || { echo "$res"; return 1; } if [ "$1" = apply ] && [ "$(printf '%s' "$res" | jsonfilter -e '@.changed')" = true ] && _fk_up; then do_fk_service apply fi } _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" [ "$(uci -q get netshift.settings.block_leaks)" = 1 ] && [ -f /usr/lib/netshift/kill_switch.sh ] && ! _fk_up && ! _job_running forkop && w="$w leaks" echo $w } _fk_fwd_sync() { local want have x [ -f /usr/lib/netshift/dnsforward.sh ] || return 0 [ "$(uci -q get netshift.settings.dont_touch_dhcp)" = 1 ] && return 0 [ "$(uci -q get dhcp.@dnsmasq[0].netshift_configured)" = 1 ] || return 0 _fk_up || return 0 want="$(uci -q get netshift.settings.dns_forward)" for x in $want; do case "$x" in /*/*) ;; *) return 0 ;; esac; done want="$(printf '%s\n' $want | sed '/^$/d' | sort -u)" have="$(printf '%s\n' $(uci -q get dhcp.@dnsmasq[0].server) | grep '^/' | sort -u)" [ "$want" = "$have" ] && return 0 for x in $have; do uci -q del_list "dhcp.@dnsmasq[0].server=$x"; done for x in $want; do uci -q add_list "dhcp.@dnsmasq[0].server=$x"; done uci -q commit dhcp /etc/init.d/dnsmasq restart >/dev/null 2>&1 return 0 } _fk_naive_arch() { local a a="$(sed -n "s/^DISTRIB_ARCH=['\"]\{0,1\}\([^'\"]*\)['\"]\{0,1\}$/\1/p" /etc/openwrt_release 2>/dev/null | head -n1)" case "$a" in aarch64_cortex-a76) a=aarch64_generic ;; i386_pentium-mmx|i386_pentium4) a=x86 ;; mipsel_24kc_24kf|mipsel_74kc) a=mipsel_24kc ;; riscv64_riscv64) a=riscv64 ;; esac echo "$a" } _fk_naive_need() { [ -f /usr/lib/netshift/naive.sh ] || return 1 grep -Eqi "'naive\+(https|quic)://" /etc/config/netshift 2>/dev/null || return 1 [ -x /usr/bin/naive ] || [ -x /usr/bin/naiveproxy ] || command -v naive >/dev/null 2>&1 && return 1 grep -q '^get_core_capabilities)' /usr/bin/netshift 2>/dev/null && /usr/bin/netshift get_core_capabilities 2>/dev/null | jq -e '.naive_core == true' >/dev/null 2>&1 && return 1 return 0 } _fk_naive_install() { local arch rel asset url dig d x sz fr act unx arch="$(_fk_naive_arch)" [ -n "$arch" ] || { echo "не удалось определить архитектуру роутера"; return 1; } d="$JOBS_DIR/fk-naive" rm -rf "$d"; mkdir -p "$d/x" _zm_gh_get "https://api.github.com/repos/klzgrad/naiveproxy/releases/latest" "$d/rel.json" "" 30 || { rm -rf "$d"; echo "GitHub не ответил — ни напрямую, ни через WARP"; return 1; } rel="$(jq -r '.tag_name // empty' "$d/rel.json" 2>/dev/null)" [ -n "$rel" ] || { rm -rf "$d"; echo "GitHub прислал непонятный ответ"; return 1; } asset="naiveproxy-$rel-openwrt-$arch.tar.xz" url="$(jq -r --arg n "$asset" '[.assets[]? | select(.name == $n) | .browser_download_url] | first // empty' "$d/rel.json")" dig="$(jq -r --arg n "$asset" '[.assets[]? | select(.name == $n) | .digest] | first // empty' "$d/rel.json")" [ -n "$url" ] || { rm -rf "$d"; echo "для архитектуры $arch сборки NaiveProxy нет"; return 1; } if command -v xz >/dev/null 2>&1; then unx="xz -dc" elif command -v unxz >/dev/null 2>&1; then unx="unxz -c" else $INSTALL xz >/dev/null 2>&1 command -v xz >/dev/null 2>&1 || { rm -rf "$d"; echo "не удалось поставить пакет xz для распаковки"; return 1; } mkdir -p "$ZM_STATE_DIR"; grep -qx xz "$FK_DEPS_OWN" 2>/dev/null || echo xz >> "$FK_DEPS_OWN" unx="xz -dc" fi echo " → Скачиваем NaiveProxy $rel для $arch" _zm_gh_get "$url" "$d/n.tar.xz" "" 300 || { rm -rf "$d"; echo "архив не скачался"; return 1; } case "$dig" in sha256:*) act="$(sha256sum "$d/n.tar.xz" 2>/dev/null | awk '{print $1}')" [ "$act" = "$(echo "${dig#sha256:}" | tr 'A-F' 'a-f')" ] || { rm -rf "$d"; echo "архив не совпал с контрольной суммой релиза"; return 1; } ;; esac $unx "$d/n.tar.xz" 2>/dev/null | tar -x -C "$d/x" 2>/dev/null rm -f "$d/n.tar.xz" x="$(find "$d/x" -type f -name naive 2>/dev/null | head -n1)" [ -n "$x" ] || { rm -rf "$d"; echo "в архиве нет программы naive"; return 1; } chmod 0755 "$x" "$x" --version >/dev/null 2>&1 case "$?" in 126|127) rm -rf "$d"; echo "NaiveProxy не запускается на этом роутере"; return 1 ;; esac sz="$(du -k "$x" 2>/dev/null | awk '{print $1}')" fr="$(df -Pk /usr/bin 2>/dev/null | awk 'NR==2 {print $4}')" if [ -n "$sz" ] && [ -n "$fr" ] && [ "$fr" -lt $((sz + 1024)) ]; then rm -rf "$d"; echo "не хватает памяти: нужно $(((sz + 1023) / 1024)) МБ"; return 1; fi cp "$x" /usr/bin/naive.zm-new && mv /usr/bin/naive.zm-new /usr/bin/naive || { rm -f /usr/bin/naive.zm-new; rm -rf "$d"; echo "не удалось записать /usr/bin/naive"; return 1; } chmod 0755 /usr/bin/naive mkdir -p /etc/netshift echo "${rel#v}" > /etc/netshift/naive.version rm -rf "$d" echo " ✓ Клиент NaiveProxy ${rel#v} установлен" return 0 } _fk_naive_auto() { local r _fk_naive_need || return 0 if [ -f "$FK_NAIVE_FAIL" ] && [ -z "$(find "$FK_NAIVE_FAIL" -mmin +720 2>/dev/null)" ]; then _rb_warn "Клиент NaiveProxy недавно поставить не удалось — серверы NaiveProxy пока пропускаются" return 0 fi _fk_say "Среди серверов есть NaiveProxy — ставим его клиент с GitHub" if r="$(_fk_naive_install)"; then printf '%s\n' "$r" rm -f "$FK_NAIVE_FAIL" else printf '%s\n' "$r" | grep '^ ' mkdir -p "$ZM_STATE_DIR"; touch "$FK_NAIVE_FAIL" _rb_warn "Клиент NaiveProxy не встал: $(printf '%s\n' "$r" | grep -v '^ ' | tail -n1) — серверы NaiveProxy пропускаются" fi return 0 } _fk_health() { _fk_ours || { 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="" _fk_installed && { inst=true; ver="$(_fk_version)"; } latest="$(_fk_latest)" [ -n "$ver" ] && [ -n "$latest" ] && _st_ver_lt "$ver" "$latest" && newer=true _fk_enabled && en=true _fk_up && up=true 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,"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","watch":%s,"hosts_extra":%s,"sb_lite":"%s"}\n' \ "$inst" "$(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)")" \ "$(awk '{print $1 + 0; exit}' "$ZM_STATE_DIR/fk.watch" 2>/dev/null | grep . || echo 0)" "$(_hosts_extra)" "$(_fk_lite_state)" } _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 rm -f /etc/init.d/netshift-naive mv /usr/lib/netshift.zm-old/.naive /etc/init.d/netshift-naive 2>/dev/null mv /usr/lib/netshift.zm-old /usr/lib/netshift _fk_say "Вернули прежнюю версию Forkozz" } _fk_sb_pkg_switch() { local want="$1" pkg tmp="$JOBS_DIR/fk-sb-bak" oldpkg p f moved="" case "$want" in tiny) pkg=sing-box-tiny ;; *) pkg=sing-box ;; esac oldpkg="$(_fk_sb_pkg)" rm -rf "$tmp"; mkdir -p "$tmp" _fk_say "Ставим пакет $pkg из репозитория OpenWrt" if [ -z "$oldpkg" ]; then for f in /usr/bin/sing-box /usr/lib/libcronet.so /usr/libexec/sing-box-core /etc/init.d/sing-box; do [ -e "$f" ] || continue mkdir -p "$tmp/keep${f%/*}" mv -f "$f" "$tmp/keep$f" 2>/dev/null && moved="$moved $f" done [ -n "$moved" ] && echo " → Убираем прежний sing-box, поставленный не пакетом:$(echo "$moved" | sed 's# /# /#g')" else [ -e /usr/bin/sing-box ] && cp -p /usr/bin/sing-box "$tmp/sing-box" 2>/dev/null [ -e /etc/init.d/sing-box ] && cp -p /etc/init.d/sing-box "$tmp/init" 2>/dev/null fi for p in $FK_SB_PKGS; do [ "$p" = "$pkg" ] && continue _pkg_is_installed "$p" && { echo " → Удаляем прежний пакет $p"; $DELETE "$p" >/dev/null 2>&1; } done if _pkg_is_installed "$pkg"; then echo " → Переустанавливаем пакет $pkg" 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 echo " → Скачиваем и ставим пакет $pkg" $INSTALL "$pkg" >/dev/null 2>&1 fi if _pkg_is_installed "$pkg" && [ -x /usr/bin/sing-box ] && sing-box version >/dev/null 2>&1 && ! _fk_sb_is_ext; then echo " ✓ Пакет $pkg на месте, sing-box $(_fk_sb_ver) запускается" rm -rf "$tmp" _fk_sb_leftovers return 0 fi echo " → Пакет $pkg не установился — возвращаем прежний sing-box" _pkg_is_installed "$pkg" && [ "$pkg" != "$oldpkg" ] && $DELETE "$pkg" >/dev/null 2>&1 [ -n "$oldpkg" ] && [ "$oldpkg" != "$pkg" ] && ! _pkg_is_installed "$oldpkg" && $INSTALL "$oldpkg" >/dev/null 2>&1 for f in $moved; do mkdir -p "${f%/*}"; mv -f "$tmp/keep$f" "$f" 2>/dev/null; done [ -s "$tmp/sing-box" ] && ! sing-box version >/dev/null 2>&1 && cp -p "$tmp/sing-box" /usr/bin/sing-box 2>/dev/null [ -s "$tmp/init" ] && [ ! -e /etc/init.d/sing-box ] && { cp -p "$tmp/init" /etc/init.d/sing-box 2>/dev/null; chmod 0755 /etc/init.d/sing-box 2>/dev/null; } rm -rf "$tmp" return 1 } _fk_sb_drop_pkgs() { local p had="" k="$JOBS_DIR/fk-sb-keep" for p in $FK_SB_PKGS; do _pkg_is_installed "$p" && had="$had $p"; done [ -n "$had" ] || { _fk_sb_leftovers; return 0; } rm -rf "$k"; mkdir -p "$k" cp -p /etc/init.d/sing-box "$k/init" 2>/dev/null cp -p /etc/config/sing-box "$k/config" 2>/dev/null mv -f /usr/bin/sing-box /usr/bin/.sing-box.zm-keep 2>/dev/null || { rm -rf "$k"; return 1; } $DELETE $had >/dev/null 2>&1 mv -f /usr/bin/.sing-box.zm-keep /usr/bin/sing-box [ -e /etc/init.d/sing-box ] || { cp -p "$k/init" /etc/init.d/sing-box 2>/dev/null; chmod 0755 /etc/init.d/sing-box 2>/dev/null; } [ -e /etc/config/sing-box ] || cp -p "$k/config" /etc/config/sing-box 2>/dev/null mkdir -p /etc/sing-box rm -rf "$k" _fk_sb_leftovers echo " ✓ Прежний sing-box удалён полностью (пакет$(echo "$had" | sed 's/^ / /; s/ \([^ ]\)/, \1/g; s/^,//')) — остался только $([ "$(_fk_sb_var)" = lite ] && echo "расширенный облегчённый" || echo "расширенный")" } _fk_sb_leftovers() { local var var="$(_fk_sb_var)" rm -f /usr/bin/.sing-box.zm-keep 2>/dev/null rm -rf /tmp/netshift-sbext.* /tmp/netshift-sblite.* 2>/dev/null [ "$var" = lite ] || rm -f /usr/libexec/sing-box-core /etc/netshift/core-version.cache /etc/sing-box-version.cache 2>/dev/null case "$var" in extended|lite) ;; *) rm -f /usr/lib/libcronet.so 2>/dev/null ;; esac return 0 } _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 if [ -n "$v" ] && _st_ver_lt "$v" 1.12.0; then _fk_say "sing-box $v слишком старый для Forkozz (нужен 1.12 и новее) — заменяем" elif [ -n "$v" ]; then _fk_say "У sing-box $v нет службы запуска — ставим пакет со службой"; fi 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 $FK_SB_PKGS; do _pkg_is_installed "$p" && had="$had $p"; done _fk_say "Удаляем sing-box${v:+ $v}${var:+ ($(_fk_sb_name "$var"))} — все варианты, файлы, настройки и кэш" echo " → Останавливаем sing-box и убираем из автозапуска" if [ -x /etc/init.d/sing-box ]; then /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 killall sing-box >/dev/null 2>&1 sleep 1 killall -9 sing-box >/dev/null 2>&1 fi ZM_PKG_QUIET=1 for p in $had; do _zm_pkg_purge "$p"; done ZM_PKG_QUIET="" if _zm_wipe /usr/bin/sing-box /etc/sing-box /etc/config/sing-box /etc/config/sing-box-opkg /etc/config/sing-box.apk-new /var/run/sing-box /tmp/sing-box /usr/share/sing-box /etc/init.d/sing-box \ /usr/lib/libcronet.so /usr/libexec/sing-box-core /etc/sing-box-version.cache /etc/netshift/core-version.cache \ /usr/bin/.sing-box.zm-keep /etc/rc.d/*sing-box /etc/rc.d/*podkop-engine /etc/init.d/podkop-engine /usr/libexec/podkop-engine "$FK_SB_OWN" && [ -z "$(_fk_sb_pkg)" ] && [ -z "$(_fk_sb_ver)" ]; 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; 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 [ "$1" = q ] || echo " ✓ Настройки DNS роутера возвращены" fi return 0 } _fk_ip_clean() { local f l pr t for t in netshift; 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 if ip rule show 2>/dev/null | grep -qi 'netshift'; then [ "$1" = q ] || echo "!! Часть правил маршрутизации Forkozz осталась — они пропадут после перезагрузки роутера" return 1 fi [ "$1" = q ] || echo " ✓ Правила маршрутизации Forkozz убраны" return 0 } _fk_fast_patch() { local f=/usr/lib/netshift/rulesets.sh n [ -f "$f" ] && [ -f /usr/lib/netshift/nft.sh ] && [ -f /usr/lib/netshift/helpers.sh ] || return 0 grep -q '^# zm-fast-begin 3$' "$f" && return 0 for n in patch_source_ruleset_rules import_plain_domain_list_to_local_source_ruleset_chunked import_plain_subnet_list_to_local_source_ruleset_chunked extract_ip_cidr_from_json_ruleset_to_file; do grep -q "^$n() {" "$f" || return 0 done grep -q '^nft_add_set_elements() {' /usr/lib/netshift/nft.sh && grep -q '^nft_add_set_elements_from_file_chunked() {' /usr/lib/netshift/nft.sh || return 0 grep -q '^comma_string_to_json_array() {' /usr/lib/netshift/helpers.sh || return 0 grep -q '^# zm-fast-begin' "$f" && sed -i '/^# zm-fast-begin/,/^# zm-fast-end$/d' "$f" cat >> "$f" << 'ZM_FK_FAST_EOF' # zm-fast-begin 3 _zm_fast_subnets() { awk -v n="$2" '{ gsub(/\r/, ""); sub(/^[ \t]+/, ""); sub(/[ \t]+$/, ""); if ($0 == "") next m = split($0, p, "/"); if (m > 2) next if (m == 2 && (p[2] !~ /^[0-9]+$/ || length(p[2]) > 2 || p[2] + 0 > 32)) next if (split(p[1], o, ".") != 4) next ok = 1; for (i = 1; i <= 4; i++) if (o[i] !~ /^(0|[1-9][0-9]?[0-9]?)$/ || o[i] + 0 > 255) ok = 0 if (!ok || ($0 in seen)) next seen[$0] = 1; buf = buf (c ? "," : "") $0 if (++c == n) { print buf; buf = ""; c = 0 } } END { if (c) print buf }' "$1" } _zm_fast_domains() { awk -v n="$2" -v px="$3" '{ gsub(/\r/, ""); sub(/^[ \t]+/, ""); sub(/[ \t]+$/, ""); if ($0 == "") next d = tolower($0); if (px && d ~ /^(full|keyword|regexp?):/) next sub(/^(full|domain|suffix):/, "", d); t = d; sub(/^\./, "", t) if (t !~ /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/) next if (t ~ /^[0-9.]+$/ || (d in seen)) next seen[d] = 1; buf = buf (c ? "," : "") d if (++c == n) { print buf; buf = ""; c = 0 } } END { if (c) print buf }' "$1" } import_plain_domain_list_to_local_source_ruleset_chunked() { local plain_list_filepath="$1" ruleset_filepath="$2" chunk_size="${3:-1000}" zm_tmp zm_line zm_px="" zm_work command -v _import_plain_domain_list_into >/dev/null 2>&1 && command -v domain_rule_normalize >/dev/null 2>&1 && zm_px=1 zm_tmp="$(mktemp)" _zm_fast_domains "$plain_list_filepath" "$chunk_size" "$zm_px" > "$zm_tmp" while IFS= read -r zm_line; do [ -n "$zm_line" ] || continue patch_source_ruleset_rules "$ruleset_filepath" "domain_suffix" "$(comma_string_to_json_array "$zm_line")" done < "$zm_tmp" if [ -n "$zm_px" ]; then tr -d '\r' < "$plain_list_filepath" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -Ei '^(full|keyword|regexp?):' > "$zm_tmp" if [ -s "$zm_tmp" ] && zm_work="$(mktemp -d)"; then ( _import_plain_domain_list_into "$zm_work" "$zm_tmp" "$ruleset_filepath" "$chunk_size" ) rm -rf "$zm_work" fi fi rm -f "$zm_tmp" } import_plain_subnet_list_to_local_source_ruleset_chunked() { local plain_list_filepath="$1" ruleset_filepath="$2" chunk_size="${3:-1000}" zm_tmp zm_line zm_tmp="$(mktemp)" _zm_fast_subnets "$plain_list_filepath" "$chunk_size" > "$zm_tmp" while IFS= read -r zm_line; do [ -n "$zm_line" ] || continue patch_source_ruleset_rules "$ruleset_filepath" "ip_cidr" "$(comma_string_to_json_array "$zm_line")" done < "$zm_tmp" rm -f "$zm_tmp" } nft_add_set_elements_from_file_chunked() { local filepath="$1" nft_table_name="$2" nft_set_name="$3" chunk_size="${4:-5000}" zm_tmp zm_line zm_tmp="$(mktemp)" _zm_fast_subnets "$filepath" "$chunk_size" > "$zm_tmp" while IFS= read -r zm_line; do [ -n "$zm_line" ] || continue nft_add_set_elements "$nft_table_name" "$nft_set_name" "$zm_line" done < "$zm_tmp" rm -f "$zm_tmp" } extract_ip_cidr_from_json_ruleset_to_file() { jq -r '.rules[]? | .ip_cidr? // empty | if type == "array" then .[] else . end' "$1" > "$2" 2>/dev/null } # zm-fast-end ZM_FK_FAST_EOF return 0 } _fk_listsvia_patch() { local f=/usr/bin/netshift tmp [ -f "$f" ] || return 0 grep -q '^# ZM_LISTS_FALLBACK 1$' "$f" && return 0 [ "$(grep -c '^get_service_proxy_address() {$' "$f")" = 1 ] && [ "$(grep -c '^ if curl -s -x "http://\$service_proxy_address" -m \$curl_timeout https://github.com > /dev/null; then$' "$f")" = 1 ] || return 0 tmp="$f.zm-lists" awk ' $0 == "get_service_proxy_address() {" { print "# ZM_LISTS_FALLBACK 1" print "# Zapret Manager: when the VPN does not answer, list_update downloads lists directly this time" print "# (ZM_LISTS_DIRECT) instead of leaving every list undownloaded until the next scheduled update." print "get_service_proxy_address() {" print " [ \"$ZM_LISTS_DIRECT\" = 1 ] && return 0" print " zm_get_service_proxy_address_raw \"$@\"" print "}" print "" print "zm_get_service_proxy_address_raw() {" r++; next } $0 == " if curl -s -x \"http://$service_proxy_address\" -m $curl_timeout https://github.com > /dev/null; then" { a = 1 } { print } a && $0 == " fi" { a = 0; c++ print " # ZM_LISTS_FALLBACK: the VPN does not answer, GitHub opens directly - lists go directly this time" print " if [ \"$i\" -ge 3 ] && curl -s -m $curl_timeout https://github.com > /dev/null; then" print " ZM_LISTS_DIRECT=1" print " echolog \"GitHub is unavailable through the VPN, downloading lists directly this time\" \"warn\"" print " break" print " fi" } END { exit (r == 1 && c == 1) ? 0 : 1 }' "$f" > "$tmp" || { rm -f "$tmp"; return 0; } if sh -n "$tmp" && chmod 0755 "$tmp"; then mv -f "$tmp" "$f" fi rm -f "$tmp" return 0 } _fk_serial_patch() { local f=/usr/bin/netshift tmp [ -f "$f" ] || return 0 grep -q '^# ZM_SERIAL 1$' "$f" && return 0 [ "$(grep -c '^case "\$1" in$' "$f")" = 1 ] && [ "$(grep -c '^reload_sing_box_config_in_place() {$' "$f")" = 1 ] && [ "$(grep -c '^ /etc/init.d/sing-box start$' "$f")" = 1 ] || return 0 tmp="$f.zm-serial" cat > "$tmp.blk" << 'ZM_FK_SERIAL_EOF' # ZM_SERIAL 1 ZM_SERIAL_DIR="/var/run/netshift-zm-serial" zm_serial_me() { local zm_rest read -r ZM_SERIAL_ME zm_rest < /proc/self/stat } zm_serial_busy() { local zm_owner [ -d "$ZM_SERIAL_DIR" ] || return 1 zm_owner="$(cat "$ZM_SERIAL_DIR/pid" 2> /dev/null)" case "$zm_owner" in '' | *[!0-9]*) return 0 ;; esac zm_serial_me [ "$zm_owner" != "$ZM_SERIAL_ME" ] && kill -0 "$zm_owner" 2> /dev/null } zm_serial_takeover() { local zm_seen="$1" zm_guard="$ZM_SERIAL_DIR.takeover" zm_g if ! mkdir "$zm_guard" 2> /dev/null; then zm_g="$(cat "$zm_guard/pid" 2> /dev/null)" case "$zm_g" in '' | *[!0-9]*) ;; *) kill -0 "$zm_g" 2> /dev/null || rm -rf "$zm_guard" ;; esac return 0 fi echo "$ZM_SERIAL_ME" > "$zm_guard/pid" [ "$(cat "$ZM_SERIAL_DIR/pid" 2> /dev/null)" = "$zm_seen" ] && rm -rf "$ZM_SERIAL_DIR" rm -rf "$zm_guard" return 0 } zm_serial_lock() { local zm_owner zm_waited=0 zm_empty=0 zm_nodir=0 [ "$ZM_SERIAL_OWNED" = 1 ] && return 0 zm_serial_me while :; do mkdir "$ZM_SERIAL_DIR" 2> /dev/null && break if [ ! -d "$ZM_SERIAL_DIR" ]; then zm_nodir=$((zm_nodir + 1)) if [ "$zm_nodir" -ge 5 ]; then log "Cannot create the NetShift operation lock $ZM_SERIAL_DIR, continuing without it" "warn" return 0 fi mkdir -p "${ZM_SERIAL_DIR%/*}" 2> /dev/null continue fi zm_nodir=0 zm_owner="$(cat "$ZM_SERIAL_DIR/pid" 2> /dev/null)" case "$zm_owner" in '' | *[!0-9]*) zm_empty=$((zm_empty + 1)) [ "$zm_empty" -ge 10 ] && { zm_serial_takeover "$zm_owner"; zm_empty=0; } ;; *) zm_empty=0 if ! kill -0 "$zm_owner" 2> /dev/null; then zm_serial_takeover "$zm_owner" elif [ "$zm_waited" -eq 0 ]; then log "Another NetShift start, stop or subscription apply is running (pid $zm_owner), waiting for it to finish" "info" fi ;; esac if [ "$zm_waited" -ge 300 ]; then log "The NetShift operation lock is still held after 300s, continuing without it" "warn" return 0 fi sleep 1 zm_waited=$((zm_waited + 1)) done echo "$ZM_SERIAL_ME" > "$ZM_SERIAL_DIR/pid" ZM_SERIAL_OWNED=1 trap 'zm_serial_unlock' EXIT return 0 } zm_serial_unlock() { [ "$ZM_SERIAL_OWNED" = 1 ] || return 0 ZM_SERIAL_OWNED="" zm_serial_me [ "$(cat "$ZM_SERIAL_DIR/pid" 2> /dev/null)" = "$ZM_SERIAL_ME" ] && rm -rf "$ZM_SERIAL_DIR" return 0 } zm_rulesets_ensure() { local zm_conf="$1" zm_path [ -n "$zm_conf" ] || config_get zm_conf "settings" "config_path" [ -n "$zm_conf" ] && [ -s "$zm_conf" ] || return 0 for zm_path in $(jq -r '.route.rule_set[]? | select(.type == "local") | select((.format // "") == "source" or ((.path // "") | endswith(".json"))) | .path // empty' "$zm_conf" 2> /dev/null); do [ -s "$zm_path" ] && continue mkdir -p "${zm_path%/*}" 2> /dev/null if printf '%s\n' '{"version":3,"rules":[]}' > "$zm_path.zm-tmp" 2> /dev/null && mv -f "$zm_path.zm-tmp" "$zm_path"; then log "Rule set $zm_path was missing before sing-box read its config; created it empty so sing-box does not abort, the next lists update fills it" "warn" else rm -f "$zm_path.zm-tmp" fi done return 0 } reload_sing_box_config_in_place() { local zm_rc zm_held="$ZM_SERIAL_OWNED" zm_serial_lock zm_reload_sing_box_config_in_place_unlocked "$@" zm_rc=$? [ "$zm_held" = 1 ] || zm_serial_unlock return "$zm_rc" } case "$1" in start | stop | reload | restart) zm_serial_lock ;; esac # ZM_SERIAL_END ZM_FK_SERIAL_EOF awk -v bf="$tmp.blk" ' BEGIN { while ((getline l < bf) > 0) blk = blk l "\n" } /^monitor_sing_box\(\) \{$/ { m = 1 } m && $0 == "}" { m = 0 } $0 == "case \"$1\" in" { printf "%s", blk; d++ } $0 == "reload_sing_box_config_in_place() {" { print "zm_reload_sing_box_config_in_place_unlocked() {"; r++; next } $0 == " if ! kill -HUP \"$sing_box_pid\" 2>/dev/null; then" { print " zm_rulesets_ensure \"$config_path\""; h++ } $0 == " /etc/init.d/sing-box start" { print " zm_rulesets_ensure"; s++ } m && !mb && $0 == " config_load \"$NETSHIFT_CONFIG\"" { print " # ZM_SERIAL: sing-box is down because another NetShift operation is restarting it, not a crash" print " zm_serial_busy && continue" print "" mb++ } m && ml == 1 { ml = 2 if ($0 != " stop_main") bad = 1 print " # ZM_SERIAL: wait for any other NetShift operation. If sing-box is back meanwhile, no restart:" print " # only point dnsmasq at it again (it was restored above, idempotent when already done)" print " zm_serial_lock" print " if sing_box_process_exists; then" print " config_get_bool dont_touch_dhcp \"settings\" \"dont_touch_dhcp\" 0" print " [ \"$dont_touch_dhcp\" -eq 0 ] && dnsmasq_configure force" print " zm_serial_unlock" print " log \"sing-box is running again after another NetShift operation, recovery restart is not needed\" \"info\"" print " crash_count=0" print " continue" print " fi" } m && $0 == " log \"Attempting sing-box recovery restart\" \"warn\"" { ml++ } m && mu == 1 { mu = 2 if ($0 != " fi") bad = 1 print print " zm_serial_unlock" next } m && $0 == " log \"Recovery restart failed, will retry\" \"error\"" { mu++ } { print } END { exit (bad || d != 1 || r != 1 || h != 1 || s != 1 || mb != 1 || ml != 2 || mu != 2) ? 1 : 0 }' "$f" > "$tmp" || { rm -f "$tmp" "$tmp.blk"; return 0; } if sh -n "$tmp" && chmod 0755 "$tmp"; then mv -f "$tmp" "$f" fi rm -f "$tmp" "$tmp.blk" return 0 } _fk_mlkem_patch() { local f=/usr/lib/netshift/sing_box_config_facade.sh tmp [ -f "$f" ] || return 0 grep -q '^ # ZM_REALITY_MLKEM 1$' "$f" && return 0 grep -q '^_add_outbound_security() {$' "$f" && grep -q '^ local reality_mlkem=""$' "$f" || return 0 grep -q '^is_sing_box_extended_at_least() {' /usr/lib/netshift/helpers.sh 2>/dev/null || return 0 grep -q '^SB_EXTENDED_REALITY_MLKEM_MIN=' /usr/lib/netshift/constants.sh 2>/dev/null || return 0 [ "$(awk '/^_add_outbound_security\(\) \{$/ { f = 1 } f && /^}$/ { f = 0 } f && $0 == " config=$(" { n++ } END { print n + 0 }' "$f")" = 1 ] || return 0 tmp="$f.zm-mlkem" cat > "$tmp.blk" << 'ZM_FK_MLKEM_EOF' # ZM_REALITY_MLKEM 1 if [ "$security" = "reality" ] && [ -z "$reality_mlkem" ]; then case "$(url_get_query_param "$url" "support-x25519mlkem768")" in true | 1) case "$outbound_tag" in *-fb[0-9]*-out) fingerprint="chrome" reality_mlkem="true" ;; *) if is_sing_box_extended_at_least "$SB_EXTENDED_REALITY_MLKEM_MIN"; then [ "$fingerprint" = "chrome" ] || log "Link for '$outbound_tag' has support-x25519mlkem768: using fingerprint chrome instead of '${fingerprint:-none}', only it sends the X25519MLKEM768 key share" "info" fingerprint="chrome" reality_mlkem="true" else log "Link for '$outbound_tag' has support-x25519mlkem768: the X25519MLKEM768 key share requires sing-box-extended $SB_EXTENDED_REALITY_MLKEM_MIN or newer, the server will reject this connection" "warn" fi ;; esac ;; esac fi # ZM_REALITY_MLKEM_END ZM_FK_MLKEM_EOF awk -v bf="$tmp.blk" ' /^_add_outbound_security\(\) \{$/ { f = 1 } f && /^}$/ { f = 0 } /^ # ZM_REALITY_MLKEM / { skip = 1 } skip { if ($0 == " # ZM_REALITY_MLKEM_END") { skip = 0; drop = 1 } next } drop { drop = 0; if ($0 == "") next } f && !done && $0 == " config=$(" { while ((getline l < bf) > 0) print l; done = 1 } { print } END { exit done ? 0 : 1 }' "$f" > "$tmp" || { rm -f "$tmp" "$tmp.blk"; return 0; } if [ "$(grep -c -- '^ --argjson reality_mlkem "$reality_mlkem" \\$' "$tmp")" = 1 ] && [ "$(grep -c '^ then (if \$reality_mlkem$' "$tmp")" = 1 ]; then awk ' $0 == " --argjson reality_mlkem \"$reality_mlkem\" \\" { print print " --argjson zm_mlkem \"$(is_sing_box_extended_at_least \"$SB_EXTENDED_REALITY_MLKEM_MIN\" && echo true || echo false)\" \\" next } $0 == " then (if $reality_mlkem" { print " # ZM_REALITY_MLKEM_SUB: ключ и у узлов, чья ссылка его просила (support-x25519mlkem768)" print " then (if ($reality_mlkem or ($zm_mlkem and ((.tls.reality.support_x25519mlkem768 // false) == true)))" next } { print }' "$tmp" > "$tmp.2" && mv -f "$tmp.2" "$tmp" fi sh -n "$tmp" && cat "$tmp" > "$f" rm -f "$tmp" "$tmp.2" "$tmp.blk" return 0 } _fk_mlkem_why() { local f grep -Ei "security=reality" /etc/config/netshift 2>/dev/null | grep -Eqi '[?&]support-x25519mlkem768=(true|1)([&#'"'"' ]|$)' && { echo "среди серверов есть REALITY для Xray 26.9.8+ (ключ X25519MLKEM768)"; return 0; } [ "$1" = config ] && return 1 for f in /etc/netshift/subscriptions/*.json; do [ -s "$f" ] || continue grep -Eq '"support_x25519mlkem768"[[:space:]]*:[[:space:]]*true' "$f" && { echo "в подписке есть серверы REALITY для Xray 26.9.8+ (ключ X25519MLKEM768)"; return 0; } done return 1 } _fk_mlkem_core_ok() { local v v="$(sing-box version 2>/dev/null | head -n1 | awk '{ for (i = 1; i < NF; i++) if ($i == "version") { print $(i + 1); exit } print $NF }')" case "$v" in *-extended-*) ;; *) return 1 ;; esac v="${v##*-extended-}"; v="${v%%-*}" case "$v" in ''|*[!0-9.]*) return 1 ;; esac ! _st_ver_lt "$v" 2.7.2 } _fk_log_mark() { FK_LOGMARK="zm-lists-$$-$(date +%s)" logger -t netshift "[info] $FK_LOGMARK" 2>/dev/null FK_LOGSEEN=0 } _fk_lists_progress() { local all tot [ -n "$FK_LOGMARK" ] || return 0 all="$(logread 2>/dev/null | awk -v m="$FK_LOGMARK" 'f && /netshift/ { print } index($0, m) { f = 1 }')" tot="$(printf '%s\n' "$all" | grep -c .)" [ "$tot" -gt "${FK_LOGSEEN:-0}" ] 2>/dev/null || return 0 printf '%s\n' "$all" | awk -v skip="${FK_LOGSEEN:-0}" ' function base(u) { sub(/[?#].*$/, "", u); sub(/^.*\//, "", u); return u } NR <= skip { next } { sub(/^.*netshift[^:]*: /, ""); sub(/^\[[a-z]+\] /, "") } /Starting lists update/ { print " → Начинаем загрузку списков"; next } /DNS check passed/ { print " ✓ DNS отвечает"; next } /DNS check failed/ { print " ✗ DNS не ответил — списки не скачаны"; next } /DNS is unavailable/ { print " · DNS пока не отвечает — пробуем ещё раз"; next } /GitHub connection check passed \(via proxy\)/ { print " ✓ GitHub доступен через VPN — списки качаем через него"; next } /GitHub is unavailable through the VPN/ { print " ! Через VPN GitHub не ответил — в этот раз списки качаем напрямую"; next } /GitHub connection check passed/ { print " ✓ GitHub доступен"; next } /GitHub connection check failed/ { print " ✗ GitHub недоступен — списки не скачаны"; next } /GitHub is unavailable/ { print " · GitHub пока не отвечает — пробуем ещё раз"; next } /Downloading and processing lists/ { print " → Скачиваем и разбираем списки"; next } /Importing community subnet lists for/ { print " → Адреса встроенных сервисов — заносим в файрвол"; next } /Importing domains from URL: / { u = $0; sub(/^.*URL: /, "", u); print " → Домены из списка " base(u); next } /Importing subnets from URL: / { u = $0; sub(/^.*URL: /, "", u); print " → Адреса из списка " base(u) " — заносим в файрвол"; next } /^Download .* list failed/ { u = $2; print " ✗ Не скачался список " base(u); next } /Failed to decompile binary rule set/ { print " ✗ Набор правил не раскрылся — его адреса пропущены"; next } /Lists update completed successfully/ { print " ✓ Все списки загружены"; next } /Lists update failed/ { print " ✗ Часть списков не загрузилась — работают остальные"; next }' FK_LOGSEEN="$tot" return 0 } _fk_lists_wait() { local p i=0 max="${1:-240}" p="${2:-$(cat /var/run/netshift_list_update.pid 2>/dev/null)}" case "$p" in ''|*[!0-9]*) return 0 ;; esac kill -0 "$p" 2>/dev/null || { _fk_lists_progress; return 0; } _fk_say "Forkozz скачивает списки и заносит адреса в файрвол" while [ "$i" -lt "$max" ]; do _fk_lists_progress kill -0 "$p" 2>/dev/null || { _fk_lists_progress; return 0; } sleep 2 i=$((i + 2)) done _fk_lists_progress return 1 } _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://}" elif [ "$ty" = inline ]; then echo " · $t — встроен в конфиг sing-box" 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; } case "$set" in netshift_subnets|netshift_subnets_v6) echo " · $set — $cnt (адреса и подсети из списков)" ;; *) echo " · $set — $cnt" ;; esac done fi return 0 } _fk_lists_errors() { local e [ -n "$FK_LOGMARK" ] || return 0 e="$(logread 2>/dev/null | awk -v m="$FK_LOGMARK" ' function base(u) { sub(/[?#].*$/, "", u); sub(/^.*\//, "", u); return u } function why(s, c) { c = "" if (match(s, /error_class=[a-z_]+/)) c = substr(s, RSTART + 12, RLENGTH - 12) if (c == "operation_not_permitted") return "запрос заблокировал сам роутер: правила или туннель ещё не были готовы" if (c ~ /timeout|timed_out/) return "сервер не ответил вовремя" if (c ~ /dns|resolve/) return "не удалось узнать адрес сервера" if (c ~ /refused|unreachable|network/) return "сервер недоступен" if (c ~ /tls|ssl|cert/) return "не установилось защищённое соединение" if (c ~ /not_found|404/) return "такого файла на сервере нет" return c } function add(s, own) { if (seen[s]++) return; out[++n] = s; if (own) nn++ } !f { if (index($0, m)) f = 1; next } { if (!match($0, /(netshift|sing-box)(\[[0-9]+\])?: /)) next src = substr($0, RSTART, 8) l = substr($0, RSTART + RLENGTH) } src != "netshift" { if (l ~ /(ERROR|FATAL)/ && l ~ /rule[-_]?set/) add("sing-box: " substr(l, 1, 200)) next } { sub(/^\[[a-z]+\] /, "", l) } l ~ /Starting lists update/ { k = 0; for (i = 1; i <= n; i++) if (out[i] ~ /^sing-box: /) out[++k] = out[i]; n = k; nn = 0; last = ""; res = ""; split("", seen); for (i = 1; i <= n; i++) seen[out[i]] = 1; next } l ~ /Lists update completed successfully/ { res = "ok"; next } l ~ /Lists update failed/ { res = "fail"; next } l ~ /\[[0-9]+\/[0-9]+\]/ && l ~ /[Ff]ail|[Ee]rror/ { last = why(l); next } l ~ /^Download .* list failed/ { split(l, a, " "); add("Не скачался список " base(a[2]) (last != "" ? " — " last : ""), 1); last = ""; next } l ~ /Failed to decompile binary rule set/ { add("Набор правил не раскрылся — его адреса пропущены", 1); next } l ~ /DNS check failed/ { add("DNS не ответил — списки не скачаны", 1); next } l ~ /GitHub connection check failed/ { add("GitHub недоступен — списки не скачаны", 1); next } END { if (res == "ok") { k = 0; for (i = 1; i <= n; i++) if (out[i] ~ /^sing-box: /) out[++k] = out[i]; n = k } else if (res == "fail" && !nn) add("Forkozz сообщил об ошибке загрузки списков" (last != "" ? " — " last : ""), 1) from = n > 8 ? n - 7 : 1 for (i = from; i <= n; i++) print out[i] }')" [ -n "$e" ] || return 0 echo "!! Не все списки загрузились:" printf '%s\n' "$e" | sed 's/^/ ✗ /' return 0 } _fk_sub_wait() { local p n i=0 max="${1:-90}" lg p="$(cat /var/run/netshift_subscription_retry.pid 2>/dev/null)" case "$p" in ''|*[!0-9]*) return 0 ;; esac kill -0 "$p" 2>/dev/null || return 0 _fk_say "Подписка при запуске не скачалась — Forkozz догружает её сам, ждём (до $max с)" while [ "$i" -lt "$max" ]; do sleep 2 i=$((i + 2)) kill -0 "$p" 2>/dev/null && continue _fk_settle 120 || true n="$(cat /var/run/netshift_subscription_retry.pid 2>/dev/null)" case "$n" in ''|*[!0-9]*) ;; *) [ "$n" != "$p" ] && kill -0 "$n" 2>/dev/null && { p="$n"; continue; } ;; esac lg="" [ -n "$FK_LOGMARK" ] && lg="$(logread 2>/dev/null | awk -v m="$FK_LOGMARK" 'f; index($0, m) { f = 1 }')" case "$lg" in *'could not apply them'*) _rb_warn "Подписка скачалась, но не применилась — Forkozz повторит при плановом обновлении подписки" ;; *'Deferred subscription refresh succeeded'*) echo " ✓ Подписка догружена и применена" ;; *) echo " · Forkozz закончил догрузку подписки" ;; esac return 0 done _rb_warn "Подписка пока не скачалась — Forkozz сам пробует снова каждые 30 с; серверы появятся, когда она загрузится" return 1 } _fk_servers_report() { local s j err i lbl command -v jq >/dev/null 2>&1 || return 0 for s in $(_fk_uc secnames 2>/dev/null); do case "$s" in *[!A-Za-z0-9_]*|'') continue ;; esac [ "$(uci -q get netshift."$s".connection_type 2>/dev/null)" = proxy ] || continue lbl="$(uci -q get netshift."$s".zm_label 2>/dev/null)" lbl="${lbl:-$s}" i=0 while :; do j="$(_fk_uc servers "$s" 2>/dev/null)" err="$(printf '%s' "$j" | jq -r 'if type == "object" then (.error // empty) else "нет ответа" end' 2>/dev/null)" [ -n "$j" ] && [ -z "$err" ] && break i=$((i + 1)) [ "$i" -ge 8 ] && break sleep 2 done if [ -z "$j" ] || [ -n "$err" ]; then echo "!! Серверы ($lbl): список не получен${err:+ — $err}" continue fi printf '%s' "$j" | jq -r --arg sec "$lbl" ' def kind: ((.type // "") | ascii_downcase); def leaves($n; $t; $seen): if ($seen | index([$t])) != null then empty else ($n[$t]) as $x | if $x == null then empty elif ($x.members | type) == "array" then ($x.members[] as $m | leaves($n; $m; $seen + [$t])) elif ([ "direct", "reject", "block", "dns", "selector", "urltest" ] | index([$x | kind])) != null then empty else $t end end; def current($n; $t; $d): if $d < 16 and $n[$t] != null and (($n[$t].now // "") != "") and $n[$t].now != $t then current($n; $n[$t].now; $d + 1) else $t end; . as $j | ($j.nodes // {}) as $n | current($n; ($j.now // ""); 0) as $c | ([ ($j.list // [])[] as $t | leaves($n; $t; []) ] | reduce .[] as $t ([]; if index([$t]) != null then . else . + [$t] end)) as $L | "==> Серверы (\($sec)): \($L | length)", (if ($L | length) == 0 then " ✗ ни одного сервера — проверьте ссылку подписки или ссылки серверов" else empty end), (if $c != "" then " → сейчас трафик идёт \(if (($n[$c] // {}) | kind) == "direct" or ($c | contains("direct")) then "напрямую, без VPN" else "через «\($n[$c].name // $c)»" end)" else empty end)' 2>/dev/null || echo "!! Серверы ($lbl): не удалось разобрать ответ панели" done 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 обратно"; mkdir -p /etc/sing-box; /etc/init.d/netshift start >/dev/null 2>&1; } return 0 } 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 local 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 [ -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)) МБ}, конфликтов нет" _zm_net_prepare "$(_zm_feed_host)" || 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 mkdir -p "$ZM_STATE_DIR" for p in $need; do grep -qx "$p" "$FK_DEPS_OWN" 2>/dev/null || echo "$p" >> "$FK_DEPS_OWN"; 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 "Скачиваем движок Forkozz $tag с GitHub" 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 "ОШИБКА: в архиве движка Forkozz нет нужных файлов" 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 "ОШИБКА: не удалось прописать версию движка Forkozz"; return 1; } _fk_say "Источники: GitHub и репозиторий OpenWrt, без зеркал" if _fk_foreign; then _fk_say "Убираем отдельный пакет движка с 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 $FK_REL /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 $FK_REL /etc/init.d/netshift stop >/dev/null 2>&1; } fi _fk_say "Копируем файлы движка Forkozz в систему" 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 [ -f /etc/init.d/netshift-naive ] && mv /etc/init.d/netshift-naive /usr/lib/netshift.zm-old/.naive fi rm -f /etc/init.d/netshift-naive 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 || ! { [ ! -f "$src/etc/init.d/netshift-naive" ] || cp "$src/etc/init.d/netshift-naive" /etc/init.d/netshift-naive; } || ! cp "$src/etc/config/netshift" /usr/lib/netshift/default.config; then _fk_restore_old rm -rf "$tmp" _fk_resume "$was_en" "$was_run" echo "ОШИБКА: файлы не записались — похоже, кончилась память" return 1 fi chmod 0755 /usr/bin/netshift /etc/init.d/netshift [ -f /etc/init.d/netshift-naive ] && chmod 0755 /etc/init.d/netshift-naive chmod -R a+rX /usr/lib/netshift _fk_fast_patch _fk_mlkem_patch _fk_serial_patch _fk_listsvia_patch 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 _fk_say "DNS по умолчанию: Quad9 9.9.9.9 (обычный UDP). Сменить можно в «Настройках»" fi chmod 0644 /etc/config/netshift _fk_log_default if ! _fk_device_setup; then if [ -d /usr/lib/netshift.zm-old ]; then echo "ОШИБКА: новая версия движка не приняла доработки панели — возвращаем прежнюю" _fk_restore_old _fk_resume "$was_en" "$was_run" fi return 1 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 он не работает — выберите вариант sing-box в «Настройках» или нажмите «Переустановить»"; return 1; } [ "$was_en" = 1 ] && /etc/init.d/netshift enable >/dev/null 2>&1 if [ "$was_run" = 1 ]; then _fk_say "Запускаем Forkozz" mkdir -p /etc/sing-box _zm_quiet "" /etc/init.d/netshift start || true _fk_wait_up 90 || echo "!! Forkozz не запустился — нажмите «Проверить»" _fk_fwd_sync fi if [ "$mode" = reinstall ] && [ "$fresh" = 0 ]; then _fk_say "Готово: Forkozz $tag переустановлен, настройки сохранены" elif [ "$fresh" = 1 ]; then _fk_say "Готово: Forkozz $tag установлен. Настройте подключение и сервисы" else _fk_say "Готово: Forkozz обновлён до $tag" fi } _fk_tunnels() { local s i seen="" for s in $(uci -q show netshift 2>/dev/null | sed -n "s/^netshift\.\([^.=]*\)\.connection_type='vpn'\$/\1/p"); do i="$(uci -q get "netshift.$s.interface" 2>/dev/null)" [ -n "$i" ] || continue [ "$(uci -q get "network.$i.proto")" = amneziawg ] || continue _awg_is_steer "$i" && continue case " $seen " in *" $i "*) continue ;; esac seen="$seen $i" echo "$i" done } _fk_tunnels_json() { local i sep="" own route p up v u printf '{"tunnels":[' for i in $(_fk_tunnels); do own=false; _awg_owned "$i" && own=true p="$(_awg_peer_sec "$i")" route=false; [ -n "$p" ] && [ "$(uci -q get "network.$p.route_allowed_ips")" = 1 ] && route=true up=false; [ -d "/sys/class/net/$i" ] && up=true printf '%s{"name":"%s","owned":%s,"route_all":%s,"up":%s}' "$sep" "$(esc "$i")" "$own" "$route" "$up" sep="," done v="$(_fk_sb_ver)"; u="$(_fk_sb_users)" printf '],"singbox":"%s","singbox_users":"%s","awg_installed":%s,"awg_busy":"%s"}\n' "$(esc "$v")" "$(esc "$u")" \ "$(_awg_installed && echo true || echo false)" "$(esc "$(_fk_awg_busy $(_fk_tunnels))")" } _fk_awg_busy() { local i others="" _st_warp_on && { echo "он нужен Steer для WARP"; return 0; } _job_running awg && { echo "на вкладке AmneziaWG идёт операция"; return 0; } for i in $(_awg_ifaces); do case " $* " in *" $i "*) continue ;; esac _awg_is_steer "$i" && { echo "его туннели использует Steer"; return 0; } others="$others${others:+, }$i" done [ -n "$others" ] && echo "на нём работают другие туннели: $others" return 0 } _fk_tunnels_off() { local mode="$1" i p shift for i in "$@"; do if _st_uses_if "$i"; then echo " · Туннель $i оставлен как есть — через него работают сервисы Steer" continue fi if [ "$mode" = keep ]; then p="$(_awg_peer_sec "$i")" if [ -n "$p" ] && [ "$(uci -q get "network.$p.route_allowed_ips")" = 1 ]; then echo "!! Туннель $i оставлен включённым, и через него идёт ВЕСЬ интернет роутера. Не нужен — выключите его на вкладке AmneziaWG" else echo " · Туннель $i оставлен включённым. Маршрутов через него нет, трафик устройств в него не идёт" fi elif [ "$mode" = purge ] || { [ "$mode" = delete ] && _awg_owned "$i"; }; then _fk_say "Удаляем туннель $i" _awg_delete "$i" echo " ✓ Туннель $i удалён" else [ "$mode" = delete ] && echo " · Туннель $i создан не панелью — не удаляем, только выключаем" _fk_say "Выключаем туннель $i" uci set "network.$i.auto=0" uci commit network _awg_down "$i" echo " ✓ Туннель $i выключен, настройки сохранены (включить: вкладка AmneziaWG → «Включить»)" fi done } _fk_sb_park() { [ -n "$(_fk_sb_ver)" ] || return 0 if [ -n "$(_fk_sb_users)" ]; then _fk_say "sing-box оставляем — он нужен: $(_fk_sb_users)" return 0 fi _fk_say "Оставляем sing-box $(_fk_sb_ver) ($(_fk_sb_name "$(_fk_sb_var)"))" [ -x /etc/init.d/sing-box ] && { /etc/init.d/sing-box stop >/dev/null 2>&1; /etc/init.d/sing-box disable >/dev/null 2>&1; } pidof sing-box >/dev/null 2>&1 && { killall sing-box >/dev/null 2>&1; sleep 1; killall -9 sing-box >/dev/null 2>&1; } rm -rf /tmp/sing-box 2>/dev/null echo " ✓ Он остановлен и убран из автозапуска — без Forkozz ему нечего делать. Удалить его можно на вкладке Forkozz" } do_fk_remove() { local arg="$1" tmode=stop sbmode=remove awg=keep tun rc=0 why case "$arg" in delete|stop|keep) tmode="$arg" ;; esac case "$arg" in *tun=delete*) tmode=delete ;; *tun=keep*) tmode=keep ;; *tun=stop*) tmode=stop ;; *tun=purge*) tmode=purge; awg=remove ;; esac case "$arg" in *sb=keep*) sbmode=keep ;; esac case "$arg" in *awg=remove*) awg=remove ;; esac tun="$(_fk_tunnels | tr '\n' ' ')" if [ "$awg" = remove ]; then why="$(_fk_awg_busy $tun)" if [ -n "$why" ]; then _rb_warn "AmneziaWG оставляем: $why" awg=keep [ "$tmode" = purge ] && tmode=delete fi fi _fk_say "Останавливаем Forkozz" if [ -x /etc/init.d/netshift ]; then /etc/init.d/netshift disable >/dev/null 2>&1 _zm_run 90 $FK_REL /etc/init.d/netshift stop >/dev/null 2>&1 fi 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 pidof sing-box >/dev/null 2>&1 && { killall sing-box >/dev/null 2>&1; sleep 1; killall -9 sing-box >/dev/null 2>&1; } fi echo " ✓ Forkozz остановлен и убран из автозапуска" _fk_say "Снимаем перехват трафика и возвращаем DNS роутера" nft delete table inet "$FK_NFT" >/dev/null 2>&1 nft delete table inet "$FK_NFT_GUARD" >/dev/null 2>&1 if nft list table inet "$FK_NFT" >/dev/null 2>&1; then echo "!! Правила файрвола Forkozz не снялись — они пропадут после перезагрузки роутера" elif _fk_ip_clean q; then echo " ✓ Правила файрвола и маршруты Forkozz убраны" else echo "!! Правила файрвола убраны, но часть маршрутов осталась — она пропадёт после перезагрузки роутера"; fi _fk_dnsmasq_clean q /etc/init.d/dnsmasq restart >/dev/null 2>&1 echo " ✓ DNS роутера работает как обычно — интернет идёт напрямую" [ -n "$tun" ] && _fk_tunnels_off "$tmode" $tun _fk_say "Удаляем файлы, настройки и кэш Forkozz" if [ -x /etc/init.d/netshift-naive ]; then _zm_run 30 /etc/init.d/netshift-naive stop >/dev/null 2>&1 /etc/init.d/netshift-naive disable >/dev/null 2>&1 fi sed -i '/^naive:x:6501:/d' /etc/passwd /etc/group 2>/dev/null _fk_bg_stop _zm_pkg_wipe luci-i18n-netshift-ru luci-app-netshift netshift || rc=1 sed -i '/netshift/d' /etc/sysupgrade.conf 2>/dev/null if grep -q 'netshift' "$CRON_FILE" 2>/dev/null; then sed -i '/netshift/d' "$CRON_FILE" /etc/init.d/cron restart >/dev/null 2>&1 fi _zm_wipe $FK_FILES "$JOBS_DIR/netshift-src" $(_fk_naive_own) \ "$FK_MARK" "$ZM_STATE_DIR/latest.netshift" "$ZM_STATE_DIR"/fk.sub.* "$ZM_STATE_DIR"/fk.fell* "$ZM_STATE_DIR"/fk.home.* "$ZM_STATE_DIR/fk.dnsfo" "$ZM_STATE_DIR/fk.watch" "$ZM_STATE_DIR/fk.zash.fail" "$FK_EXT_FAIL" "$FK_NAIVE_FAIL" "$JOBS_DIR/netshift.conf.bak" \ "$JOBS_DIR"/fk.heal* "$JOBS_DIR"/fk-* "$JOBS_DIR"/fkdns.* "$JOBS_DIR/forkop-svc.out" "$JOBS_DIR/forkop-sb.json" "$JOBS_DIR/forkop-lists.out" && echo " ✓ Файлы, настройки, кэш и задания расписания удалены" || rc=1 if [ "$sbmode" = keep ]; then _fk_sb_park; else _fk_sb_purge || rc=1; fi _fk_deps_purge if [ "$awg" = remove ] && _awg_installed; then _fk_say "Удаляем AmneziaWG — больше он нигде не используется" ZM_NO_NETCHECK=1 do_awg_remove all || rc=1 fi rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* /var/luci-indexcache* 2>/dev/null _zm_after_remove "Forkozz" _zm_rm_done "$rc" "Forkozz" } _fk_bg_stop() { local f p for f in "$FK_GUARD_PID" /tmp/zm-fk-pingloop.*/pid; do p="$(cat "$f" 2>/dev/null)" case "$p" in ''|*[!0-9]*) continue ;; esac [ "$p" != "$$" ] && kill "$p" 2>/dev/null done return 0 } _fk_naive_own() { [ -e /usr/bin/naive ] || return 0 _pkg_is_installed naiveproxy && return 0 echo /usr/bin/naive } _fk_deps_purge() { local p keep left="" gone="" list list="$(cat "$FK_DEPS_OWN" 2>/dev/null) jq bind-dig coreutils-base64 kmod-inet-diag" _fk_say "Удаляем пакеты, которые ставил Forkozz" for p in $(printf '%s\n' $list | awk '!s[$0]++'); do _pkg_is_installed "$p" || continue keep="" case "$p" in curl|ca-bundle|ucode|ucode-mod-fs|ucode-mod-uci|ip-full) keep="нужен самой панели и LuCI" ;; kmod-tun) _mx_installed && keep="нужен Mixomo"; [ -z "$keep" ] && _bt_installed && keep="нужен ByeTube" ;; kmod-nft-tproxy) _mx_installed && keep="нужен Mixomo" ;; esac if [ -n "$keep" ]; then echo " · $p оставляем — $keep"; left="$left $p"; continue; fi if [ "$PKG" = apk ]; then _zm_run 180 apk del "$p" >/dev/null 2>&1 else _zm_run 180 opkg remove --autoremove "$p" >/dev/null 2>&1; fi _zm_pkg_unlock if _pkg_is_installed "$p"; then echo " · $p оставляем — он нужен другим пакетам"; left="$left $p" else gone="$gone $p"; fi done [ -n "$gone" ] && echo " ✓ Удалены:$gone" [ -z "$gone$left" ] && echo " ✓ Таких пакетов на роутере нет" rm -f "$FK_DEPS_OWN" return 0 } 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 rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* 2>/dev/null _fk_say "Готово: sing-box удалён полностью, остатков нет" } _fk_start_once() { mkdir -p /etc/sing-box _zm_run 60 /etc/init.d/netshift start >> "$JOBS_DIR/forkop-svc.out" 2>&1 } _fk_wait_up() { local i=0 max="$1" while [ "$i" -lt "$max" ]; do _fk_ready && break sleep 1 i=$((i + 1)) [ "$i" = 20 ] && printf 'Подождите ' [ "$i" -gt 20 ] && [ $((i % 5)) = 0 ] && printf '.' [ "$i" = "$max" ] && [ "$max" = "$1" ] && _fk_engine_busy && max=$((max + 150)) done [ "$i" -ge 20 ] && echo _fk_ready } _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 "в секции выбран список, которого Forkozz не знает — сохраните сервисы заново" ;; *"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|Command failed: ubus call service (delete|list|signal)' | 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 _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 $FK_REL /etc/init.d/netshift stop >/dev/null 2>&1 nft delete table inet "$FK_NFT" >/dev/null 2>&1 nft delete table inet "$FK_NFT_GUARD" >/dev/null 2>&1 if nft list table inet "$FK_NFT" >/dev/null 2>&1; then echo "!! Служба остановлена, но её правила в firewall остались — перезагрузите роутер" else echo " ✓ Служба остановлена, правила сняты"; fi _ab_free_sync >/dev/null 2>&1 _fk_say "Готово: Forkozz выключен" return 0 ;; esac _fk_device_setup || return 1 b="$(_fk_blocker)" [ -n "$b" ] && { echo "ОШИБКА: $(_fk_blocker_text "$b")"; return 1; } [ -n "$(_fk_sb_ver)" ] || _fk_sb_install || return 1 _fk_ext_auto config "" norestart _fk_naive_auto _fk_log_default if _fk_sub_fmt_auto; then _fk_say "Скачиваем подписку в новом формате" if _zm_quiet "" /usr/bin/netshift subscription_update all; then echo " ✓ Подписка обновлена" else echo "!! Подписка в новом формате не обновилась — Forkozz возьмёт её при следующем обновлении"; fi fi local lx; lx="$(_fk_log_ext)" /etc/init.d/netshift enable >/dev/null 2>&1 _fk_fast_patch _fk_mlkem_patch _fk_serial_patch _fk_listsvia_patch mkdir -p /etc/sing-box _fk_log_mark : > "$JOBS_DIR/forkop-svc.out" _fk_settle 180 "Forkozz ещё заканчивает прошлую операцию (запуск или применение подписки) — ждём её" || _rb_warn "Прошлая операция Forkozz не закончилась за 3 минуты — продолжаем, движок выполнит нашу следом" 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_ready; 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_ready || _fk_wait_up 90 fi rm -f "$JOBS_DIR/forkop-svc.out" if _fk_up; then _fk_say "Проверяем, что всё работает" _fk_check_live _fk_sub_wait 90 _fk_ext_auto all "$lx" _fk_lists_wait 240 || _rb_warn "Списки ещё скачиваются — числа ниже могут быть неполными. Они догрузятся сами; позже нажмите «Обновить списки сейчас», чтобы увидеть итог" _fk_lists_report _fk_lists_errors _fk_servers_report _ab_free_sync >/dev/null 2>&1 _fk_zash_ensure local ps for ps in $(_fk_uc secnames 2>/dev/null); do [ "$(uci -q get netshift."$ps".zm_sub_priority)" = 1 ] && _fk_fallback_sec "$ps" >/dev/null 2>&1 & done _fk_fwd_sync _fk_say "Готово: Forkozz работает" _fk_guard 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_settle 180 "Forkozz ещё заканчивает запуск или применение подписки — ждём, затем обновим списки" || true _fk_up || { echo "ОШИБКА: Forkozz выключен — включите его, и списки скачаются сами"; return 1; } local lp _fk_fast_patch _fk_mlkem_patch _fk_serial_patch _fk_listsvia_patch _fk_log_mark /usr/bin/netshift list_update > "$out" 2>&1 & lp=$! if ! _fk_lists_wait 300 "$lp"; then _zm_kill_tree "$lp" _rb_warn "Загрузка списков не завершилась за 5 минут — прерываем её" rc=1 fi wait "$lp" 2>/dev/null || rc=1 [ "$rc" = 0 ] || sed 's/\x1b\[[0-9;]*m//g' "$out" 2>/dev/null | grep -v '^[[:space:]]*$' | grep -iE 'error|fail|ошиб' | grep -v '^jq: ' | grep -vE '\[[0-9]+/[0-9]+\]' | tail -n 8 | 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 "Готово: списки обновлены" } _fk_sub_keys() { jq -r ' def ap($a; $p): if $a == null or $p == null then empty else "\($a):\($p)" end; if type == "object" and (.outbounds | type) == "array" and ((.outbounds[0].protocol // null) == null) then [.outbounds[] | select(.type == "vless" or .type == "trojan" or .type == "shadowsocks" or .type == "hysteria2" or .type == "vmess" or .type == "tuic" or .type == "anytls") | "\(.type)|" + ap(.server; .server_port)] | unique | .[] else [(if type == "array" then .[] else . end) | .outbounds[]? | select(type == "object") | select(.protocol == "vless" or .protocol == "trojan" or .protocol == "shadowsocks" or .protocol == "hysteria") | select((.streamSettings.sockopt.dialerProxy // "") == "") | select(.protocol != "hysteria" or ((.streamSettings.hysteriaSettings.version // 0) == 2)) | (.settings // {}) as $s | ($s.vnext[0] // $s.servers[0] // $s) as $x | (if .protocol == "hysteria" then "hysteria2" else .protocol end) + "|" + ap($x.address; $x.port)] | unique | .[] end' 2>/dev/null } _fk_sub_fmt_auto() { local sec url ver f sb xr ch="" d="$JOBS_DIR/fk-subfmt" mark n_sb n_xr miss extra command -v jq >/dev/null 2>&1 || return 1 ver="$(sing-box version 2>/dev/null | head -n1 | awk '{print $3}')" for sec in $(uci -q show netshift | sed -n "s/^netshift\.\([A-Za-z0-9_]*\)\.subscription_url=.*/\1/p" | sort -u); do case "$(uci -q get "netshift.$sec.subscription_format_preference")" in ''|auto) ;; *) continue ;; esac [ -z "$(uci -q get "netshift.$sec.subscription_user_agent")" ] || continue [ "$(uci -q get "netshift.$sec.proxy_config_type")" = subscription ] || continue url="$(uci -q get "netshift.$sec.subscription_url")" case "$url" in *' '*) continue ;; esac case "$url" in http://*|https://*) ;; *) continue ;; esac mark="$ZM_STATE_DIR/fk.subfmt.$sec" [ "$1" = force ] || { [ -f "$mark" ] && [ "$(cat "$mark" 2>/dev/null)" = "$(printf '%s' "$url" | md5sum | cut -c1-16)" ] && [ -z "$(find "$mark" -mmin +1440 2>/dev/null)" ] && continue; } _fk_say "Подписка «$(uci -q get "netshift.$sec.zm_label" || echo "$sec")»: сравниваем, что сервер отдаёт в форматах sing-box и Happ" rm -rf "$d"; mkdir -p "$d"; : > "$d/sb"; : > "$d/xr" curl -fsSL --compressed -m 15 -A "singbox/${ver:-1.12.0}" -o "$d/sb" "$url" 2>/dev/null curl -fsSL --compressed -m 15 -A "Happ/1.0.0" -o "$d/xr" "$url" 2>/dev/null _fk_sub_keys < "$d/sb" > "$d/sb.k"; _fk_sub_keys < "$d/xr" > "$d/xr.k" mkdir -p "$ZM_STATE_DIR"; printf '%s' "$url" | md5sum | cut -c1-16 > "$mark" n_sb="$(cut -d'|' -f2 "$d/sb.k" | sort -u | grep -c .)"; n_xr="$(cut -d'|' -f2 "$d/xr.k" | sort -u | grep -c .)" if [ "$n_sb" = 0 ] && [ "$n_xr" = 0 ]; then echo " · подписка не скачалась напрямую — формат оставляем как есть"; rm -rf "$d"; continue; fi [ "$n_xr" -gt 0 ] || { echo " ✓ формат sing-box подходит: в формате Happ сервер подписку не отдал"; rm -rf "$d"; continue; } [ "$n_sb" -gt 0 ] && [ "$n_xr" -gt "$n_sb" ] || { echo " ✓ формат sing-box подходит: серверов в нём не меньше ($n_sb)"; rm -rf "$d"; continue; } miss="$(cut -d'|' -f2 "$d/sb.k" | sort -u | grep -vxF -f "$(cut -d'|' -f2 "$d/xr.k" | sort -u > "$d/xa"; echo "$d/xa")" | grep -c .)" [ "$miss" = 0 ] || { echo " ✓ формат оставляем: в sing-box есть серверы, которых нет в Happ"; rm -rf "$d"; continue; } extra="$(cut -d'|' -f1 "$d/xr.k" | sort -u | grep -vxF -f "$(cut -d'|' -f1 "$d/sb.k" | sort -u > "$d/sa"; echo "$d/sa")" | tr '\n' ' ' | sed 's/ $//')" uci set "netshift.$sec.subscription_format_preference=xray" ch=1 echo " ✓ в формате Happ серверов больше ($n_xr против $n_sb${extra:+, в том числе $extra}) — подписка переключена на формат Happ" logger -t zapret-manager "Forkozz: подписка секции $sec переключена на формат Happ ($n_xr серверов против $n_sb)" rm -rf "$d" done [ -n "$ch" ] || return 1 uci commit netshift return 0 } do_fk_subs() { _fk_installed || { echo "ОШИБКА: Forkozz не установлен"; return 1; } _fk_say "Обновляем подписки" _fk_sub_fmt_auto force local lx; lx="$(_fk_log_ext)" _fk_mlkem_patch _fk_serial_patch _fk_listsvia_patch _zm_quiet "" /usr/bin/netshift subscription_update all || { echo "ОШИБКА: подписка не обновилась — проверьте ссылку"; return 1; } _fk_ext_auto all "$lx" _fk_say "Готово: подписки обновлены" } FK_EXT_FAIL="$ZM_STATE_DIR/fk.ext.fail" FK_NAIVE_FAIL="$ZM_STATE_DIR/fk.naive.fail" _fk_sb_is_ext() { sing-box version 2>/dev/null | head -n1 | grep -qi extended; } _fk_log_ext() { logread 2>/dev/null | grep -ci 'requires sing-box-extended'; } _fk_log_default() { [ -s /etc/config/netshift ] || return 0 [ "$(uci -q get netshift.settings.zm_log)" = 1 ] && return 0 uci -q get netshift.settings >/dev/null || return 0 uci -q set netshift.settings.log_level='panic' uci -q set netshift.settings.zm_log='1' uci -q commit netshift } _fk_ext_why() { local f var="${3:-$(_fk_sb_var)}" grep -Eqi "[?&](type|transport)=(xhttp|splithttp)|'vmess://" /etc/config/netshift 2>/dev/null && { echo "среди серверов есть XHTTP или VMess"; return 0; } _fk_mlkem_why "$1" && return 0 [ "$1" = config ] && return 1 for f in /etc/netshift/subscriptions/*.json; do [ -s "$f" ] || continue grep -Eqi '"type"[[:space:]]*:[[:space:]]*"(xhttp|splithttp)"' "$f" && { echo "в подписке есть серверы XHTTP"; return 0; } grep -Eqi '"type"[[:space:]]*:[[:space:]]*"vmess"' "$f" && { echo "в подписке есть серверы VMess"; return 0; } [ "$var" = tiny ] && grep -Eqi '"type"[[:space:]]*:[[:space:]]*"wireguard"' "$f" && { echo "в подписке есть серверы WireGuard"; return 0; } done [ -n "$2" ] && [ "$(_fk_log_ext)" -gt "$2" ] 2>/dev/null && { echo "в подписке есть серверы, которые работают только на расширенном sing-box"; return 0; } return 1 } _fk_ext_need() { if [ -n "$(_fk_sb_ver)" ] && _fk_sb_is_ext; then _fk_mlkem_core_ok && return 1 _fk_mlkem_why "$1" return fi _fk_ext_why "$1" "$2" } _fk_ext_auto() { local why rc why="$(_fk_ext_need "$1" "$2")" || return 0 if [ -f "$FK_EXT_FAIL" ] && [ -z "$(find "$FK_EXT_FAIL" -mmin +720 2>/dev/null)" ]; then _rb_warn "${why}, а расширенный sing-box поставить не удалось — такие серверы пока пропускаются" return 0 fi local v=extended fr hv fr="$(df -k /overlay 2>/dev/null | awk 'NR==2 {print $4}')"; [ -n "$fr" ] || fr="$(df -k / 2>/dev/null | awk 'NR==2 {print $4}')" hv="$(ls -l /usr/bin/sing-box 2>/dev/null | awk '{print int($5 / 1024)}')" [ -n "$fr" ] && [ $((fr + ${hv:-0})) -lt 40960 ] && _fk_lite_arch >/dev/null && _fk_lite_engine && v=lite if [ "$v" = lite ]; then _fk_say "${why} — нынешний sing-box их не умеет, а для расширенного мало места на флеше — ставим расширенный облегчённый" else _fk_say "${why} — нынешний sing-box их не умеет, ставим расширенный"; fi ZM_FK_SB_NESTED=1 do_fk_singbox "$v" "$3"; rc=$? ZM_FK_SB_NESTED="" if [ "$rc" = 0 ]; then rm -f "$FK_EXT_FAIL" else mkdir -p "$ZM_STATE_DIR"; touch "$FK_EXT_FAIL"; _rb_warn "Расширенный sing-box не встал — Forkozz работает на прежнем, такие серверы пропускаются"; fi return 0 } _fk_sbext_say() { local m="$1" h a case "$m" in *"pre-flight: DNS resolve of "*" ok"*) h="${m#*resolve of }"; echo " ✓ Адрес ${h% ok} найден" ;; *"pre-flight: DNS resolve of "*"FAILED"*) h="${m#*resolve of }"; echo " ! Адрес ${h% FAILED} не находится" ;; *"pre-flight: HTTPS reachability of "*" ok"*) h="${m#*reachability of }"; echo " ✓ ${h%% *} отвечает" ;; *"pre-flight: HTTPS reachability of "*"FAILED"*) h="${m#*reachability of }"; echo " ! ${h%% *} не отвечает" ;; *"is not reachable via the service proxy"*) h="${m#*pre-flight: }"; echo " ! ${h%% *} не отвечает через VPN — пробуем напрямую" ;; *"attempting self-heal"*) echo " → Нет доступа к GitHub — временно чиним DNS и выход в интернет" ;; *"Self-heal: connectivity restored"*) echo " ✓ Доступ к GitHub есть" ;; *"Self-heal: keeping the NetShift redirect up"*) echo " · Скачиваем через VPN Forkozz" ;; *"Self-heal: connectivity could NOT be restored"*) echo " ✗ Доступ к GitHub восстановить не удалось" ;; *"Downloading sing-box extended lite "*) h="${m#*Downloading sing-box extended lite }"; a="${h#* }"; a="${a#(}"; a="${a%)}" case "$a" in compressed,*) a="сжатая сборка (UPX), ${a#*, }" ;; elf,*) a="обычная сборка, ${a#*, }" ;; esac echo " → Скачиваем sing-box-extended-lite ${h%% *} — $a" ;; *"sha256 mismatch"*) echo " ✗ Архив скачался повреждённым (не сошлась контрольная сумма) — ничего не меняли" ;; *"Extended Lite is not available"*) echo " ✗ Для этого роутера нет облегчённой сборки" ;; *"Installed sing-box extended lite"*) ;; *"Downloading sing-box-extended "*) h="${m#*Downloading sing-box-extended }"; a="${h#* }"; a="${a#(}"; echo " → Скачиваем sing-box-extended ${h%% *} для ${a%)}" ;; *"Download via the service proxy"*) echo " ! Через VPN не скачалось — качаем напрямую" ;; *"Found a non-runnable /usr/bin/sing-box"*) echo " · Убираем недоустановленный sing-box" ;; *"Restarting netshift after component change"*) echo " → Новое ядро на месте — движок перезапускает Forkozz на нём" ;; *"Restore: "*) [ -n "$ZM_SBX_RESTORE" ] || { ZM_SBX_RESTORE=1; echo " · Возвращаем временные настройки DNS как было"; } ;; *"Installed sing-box-extended"*) ;; *"[error]"*|*"[fatal]"*) echo " ✗ ${m#*Updater: }" ;; *"[warn]"*) echo " ! ${m#*Updater: }" ;; esac } _fk_sbext_run() { local out="$1" p seen="$JOBS_DIR/fk-sbx.seen" new="$JOBS_DIR/fk-sbx.new" t=0 a sz=0 last=0 still=0 unp=0 dl=0 nolog=0 rs=0 l local act=install_extended tg="/tmp/netshift-sbext.*/sing-box-extended.tar.gz" td="/tmp/netshift-sbext.*" nm=sing-box-extended [ "$2" = lite ] && { act=install_extended_lite; tg="/tmp/netshift-sblite.*/sing-box-extended-lite.tar.gz"; td="/tmp/netshift-sblite.*"; nm=sing-box-extended-lite; } ZM_SBX_RESTORE="" logread 2>/dev/null | grep 'Updater: ' > "$seen" /usr/bin/netshift component_action sing_box "$act" > "$out" 2>/dev/null & p=$! while kill -0 "$p" 2>/dev/null; do sleep 2; t=$((t + 2)) if [ "$t" -ge 900 ]; then _zm_kill_tree "$p" echo "!! Движок не закончил установку за 15 минут — прерываем" break fi logread 2>/dev/null | grep 'Updater: ' | grep -vxF -f "$seen" > "$new" if [ -s "$new" ]; then while IFS= read -r l; do case "$l" in *"Downloading sing-box-extended"*|*"Downloading sing-box extended lite"*) dl=1 ;; *"Restarting netshift"*) unp=1; [ "$rs" = 1 ] && continue; rs=1 ;; esac _fk_sbext_say "$l" done < "$new" cat "$new" >> "$seen" fi a="$(ls -l $tg 2>/dev/null | awk '{ print int($5 / 1048576); exit }')" if [ -n "$a" ] && [ "$dl" = 0 ]; then nolog=$((nolog + 1)) if [ "$nolog" -ge 2 ]; then dl=1; echo " → Скачиваем $nm"; else a=""; fi fi if [ -n "$a" ]; then if [ "$a" -gt "$sz" ]; then sz="$a"; still=0 [ "$((sz - last))" -ge 5 ] && { last="$sz"; echo " · Скачано $sz МБ"; } elif [ "$dl" = 1 ] && [ "$unp" = 0 ] && [ "$sz" -gt 0 ]; then still=$((still + 1)) [ "$still" = 2 ] && { unp=1; echo " ✓ Архив скачан ($sz МБ) — распаковываем и проверяем новое ядро"; } fi fi if [ "$unp" = 1 ] && [ "$rs" = 0 ] && ! ls -d $td >/dev/null 2>&1; then rs=1; echo " → Новое ядро на месте — движок перезапускает Forkozz на нём" fi done wait "$p" 2>/dev/null logread 2>/dev/null | grep 'Updater: ' | grep -vxF -f "$seen" | while IFS= read -r l; do _fk_sbext_say "$l"; done rm -f "$seen" "$new" } do_fk_singbox() { local want="$1" norestart="$2" free tfree have need tneed out="$JOBS_DIR/forkop-sb.json" old oldvar msg why same=0 verb _fk_installed || { echo "ОШИБКА: Forkozz не установлен"; return 1; } case "$want" in tiny|stable|extended|lite) ;; *) echo "ОШИБКА: неизвестный вариант sing-box"; return 1 ;; esac if [ "$want" = lite ]; then _fk_lite_arch >/dev/null || { echo "ОШИБКА: для этого роутера ($TG_ARCH) нет облегчённой сборки — она выходит для amd64, arm64, armv7, mips и mipsel. Выберите «Расширенный»"; return 1; } _fk_lite_engine || { echo "ОШИБКА: этот движок Forkozz ($(_fk_version)) ещё не умеет ставить облегчённый расширенный sing-box — обновите Forkozz"; return 1; } fi old="$(_fk_sb_ver)"; oldvar="$(_fk_sb_var)" if [ "$want" != extended ] && [ "$want" != lite ] && why="$(_fk_ext_why all "" "$want")"; then echo "ОШИБКА: ${why} — такие серверы работают только на расширенном sing-box, а Forkozz при следующем запуске сам вернул бы расширенный. Чтобы сменить вариант, сначала уберите их в «Подключении»" return 1 fi [ -n "$old" ] && [ "$oldvar" = "$want" ] && same=1 if [ "$same" = 1 ]; then verb="Переустанавливаем"; elif [ -n "$old" ]; then verb="Меняем"; else verb="Ставим"; fi _fk_say "$verb sing-box: $([ -n "$old" ] && [ "$same" = 0 ] && echo "$old ($(_fk_sb_name "$oldvar")) → ")$(_fk_sb_name "$want")" 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 ;; lite) need=12288; tneed=$((18432 + ${have:-0})) ;; *) 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)) МБ${old:+. Если новый не встанет, останется прежний $old}" 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 МБ" _fk_sbext_run "$out" _fk_sb_is_ext && [ "$(jsonfilter -i "$out" -e '@.success' 2>/dev/null)" = true ] && echo " ✓ Расширенный sing-box $(_fk_sb_ver) скачан и поставлен" if [ "$(jsonfilter -i "$out" -e '@.success' 2>/dev/null)" != true ] || ! _fk_sb_is_ext; 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" _fk_settle 180 "Движок перезапускает Forkozz на новом ядре — ждём, это до пары минут" || _rb_warn "Перезапуск Forkozz затянулся — продолжаем" _fk_sb_drop_pkgs ;; lite) _zm_net_prepare api.github.com github.com objects.githubusercontent.com || return 1 _fk_say "Скачиваем расширенный облегчённый sing-box с GitHub (github.com/$FK_SBL_REPO) — около 10–17 МБ" _fk_sbext_run "$out" lite if [ "$(jsonfilter -i "$out" -e '@.success' 2>/dev/null)" != true ] || [ "$(_fk_sb_var)" != lite ]; 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 case "$(jsonfilter -i "$out" -e '@.build' 2>/dev/null)" in compressed) echo " ✓ Расширенный облегчённый sing-box $(_fk_sb_ver) поставлен — сжатая сборка (UPX), на флеше около 10 МБ" ;; *) echo " ✓ Расширенный облегчённый sing-box $(_fk_sb_ver) поставлен" ;; esac [ "$(jsonfilter -i "$out" -e '@.warning' 2>/dev/null)" = upx_ram_spike ] && _rb_warn "Памяти у роутера меньше 256 МБ: сжатая сборка при каждом запуске ненадолго занимает 35–45 МБ — если Forkozz будет падать, выберите «Облегчённый»" rm -f "$out" _fk_settle 180 "Движок перезапускает Forkozz на новом ядре — ждём, это до пары минут" || _rb_warn "Перезапуск Forkozz затянулся — продолжаем" _fk_sb_drop_pkgs ;; *) _zm_net_prepare "$(_zm_feed_host)" || return 1 _fk_say "Обновляем список пакетов" if $UPDATE >/dev/null 2>&1; then echo " ✓ Список пакетов обновлён"; else echo " ! Список пакетов не обновился — пробуем с прежним"; fi _fk_sb_pkg_switch "$want" || { echo "ОШИБКА: $(_fk_sb_name "$want") sing-box не встал$([ "$want" = tiny ] && echo " — возможно, для этого роутера его нет в репозитории OpenWrt; выберите «Обычный»")" [ -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 " ✓ $([ "$same" = 1 ] && echo Переустановлен || echo Установлен) sing-box $(_fk_sb_ver) ($(_fk_sb_name "$(_fk_sb_var)"))" if [ "$norestart" != norestart ] && _fk_enabled; then _fk_say "Перезапускаем Forkozz на $([ "$same" = 1 ] && echo переустановленном || echo новом) sing-box" _zm_run 90 /etc/init.d/netshift stop >/dev/null 2>&1 : > "$JOBS_DIR/forkop-svc.out" _fk_start_once _fk_svc_report rm -f "$JOBS_DIR/forkop-svc.out" _fk_wait_up 90 if ! _fk_up; then msg="$(_fk_why)" echo "ОШИБКА: Forkozz не запустился на этом sing-box${msg:+: $msg}. Выберите другой вариант в «Настройках» → sing-box" return 1 fi _fk_check_live fi [ -n "$ZM_FK_SB_NESTED" ] && return 0 _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 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" ;; *) 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_foreign_err && return 1 _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_foreign_err && return 1 _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.* "$ZM_STATE_DIR"/fk.home.* "$JOBS_DIR"/fk.heal "$JOBS_DIR"/fk.heal.* _ab_dns_bg 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" } _st_where() { local host="$1" ip="$2" man="$ST_RUN/where.$$" res="$ST_RUN/where.$$.r" on id name f3 f4 f5 f6 f7 f8 rest x f n=0 st path ent sep="" lab mkdir -p "$ST_RUN" { for f in "$ST_USER_DIR/exclude.lst" "$ST_USER_DIR/exclude.pfx"; do [ -s "$f" ] && printf 'x\texclude\tИсключения\t%s\n' "$f"; done if [ -s "$ST_DIR/model.svc" ] && _st_owns "steer-spec" && [ ! -f "$ST_OFF" ]; then ZM_SN="$(for x in $(_st_secs); do printf '%s=%s\t' "$x" "$(_st_sec_name "$x")"; done)" awk -F'\t' 'BEGIN { n = split(ENVIRON["ZM_SN"], a, "\t"); for (i = 1; i <= n; i++) if (a[i] != "") { k = a[i]; sub(/=.*/, "", k); v = a[i]; sub(/^[^=]*=/, "", v); SN[k] = v } } $1 == "sec" { sec = $2; next } $1 == "svc" { id = $2; nm = $3 (sec != "" && (sec in SN) ? " · " SN[sec] : ""); next } ($1 == "dom" || $1 == "pfx" || $1 == "srs") && id != "" { print "on\t" id "\t" nm "\t" $2 }' "$ST_DIR/model.svc" fi } > "$man" on=" $(awk -F'\t' '$1 == "on" { print $2 }' "$man" | sort -u | tr '\n' ' ') " _rb_svc_src | while IFS='|' read -r id name f3 f4 f5 f6 f7 f8 rest; do [ -n "$id" ] || continue case "$on" in *" $id "*) continue ;; esac { if [ "$id" = custom ]; then printf '%s\n' "$ST_USER_DIR/custom.lst" "$ST_USER_DIR/custom.pfx" for x in $(_st_secs); do printf '%s\n' "$ST_SEC_DIR/$x/custom.lst" "$ST_SEC_DIR/$x/custom.pfx"; done else for x in $(printf '%s,%s' "$f3" "$f4" | tr ',' ' '); do printf '%s\n' "$ZM_LISTS_DIR/$x"; done for x in $(printf '%s' "$f8" | tr ',' ' '); do printf '%s\n' "$ST_DIR/lists/$x.dom" "$ST_DIR/lists/$x.pfx" "$ST_DIR/lists/$x.srs"; done fi } | while IFS= read -r f; do [ -s "$f" ] && printf 'off\t%s\t%s\t%s\n' "$id" "$name" "$f"; done done | awk -F'\t' '!s[$2 "\t" $4]++' >> "$man" mkdir -p "$ST_RUN/srsdump" awk -F'\t' '{ print NR "\t" $0 }' "$man" | while IFS="$(printf '\t')" read -r n st id name path; do case "$path" in *.srs) x="$ST_RUN/srsdump/$(printf '%s' "$path" | tr -c 'A-Za-z0-9_.-' '_')" if { [ ! -s "$x.dom" ] && [ ! -s "$x.pfx" ]; } || [ "$path" -nt "$x.dom" ]; then rm -f "$x.dom" "$x.pfx" steer srs-read "$path" --out "$x.dom" --prefixes-out "$x.pfx" >/dev/null 2>&1 || rm -f "$x.dom" "$x.pfx" fi if [ -f "$x.dom" ]; then printf '%s\t%s\t%s\t%s\t%s\n' "$st" "$id" "$name" "$x.dom" "$path" [ -s "$x.pfx" ] && printf '%s\t%s\t%s\t%s\t%s\n' "$st" "$id" "$name" "$x.pfx" "$path" else printf '%s\t%s\t%s\t%s\t%s\n' "$st" "$id" "$name" "$path" "$path" fi ;; *) printf '%s\t%s\t%s\t%s\t%s\n' "$st" "$id" "$name" "$path" "$path" ;; esac done > "$man.x" mv -f "$man.x" "$man" { awk -F'\t' -v h="$host" -v ip="$ip" ' function ipn(a, p) { if (split(a, p, ".") != 4) return -1; return ((p[1] * 256 + p[2]) * 256 + p[3]) * 256 + p[4] } function under(n, d) { return length(n) > length(d) && substr(n, length(n) - length(d)) == "." d } BEGIN { iv = ip != "" ? ipn(ip) : -1 } $4 ~ /\.srs$/ { next } { best = ""; bw = -1; path = $4 while ((getline l < path) > 0) { sub(/[#;].*$/, "", l); gsub(/^[ \t\r]+|[ \t\r]+$/, "", l); l = tolower(l) if (l == "" || l ~ /^re:/ || l ~ /[ \t]/) continue if (iv >= 0 && l ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(\/[0-9]+)?$/) { split(l, c, "/"); b = (c[2] == "") ? 32 : c[2] + 0; if (b < 0 || b > 32) continue z = 2 ^ (32 - b); a = ipn(c[1]) if (a >= 0 && int(iv / z) == int(a / z) && 100 + b > bw) { best = l; bw = 100 + b } continue } if (iv >= 0 && l ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+-[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) { split(l, c, "-"); if (iv >= ipn(c[1]) && iv <= ipn(c[2]) && 100 > bw) { best = l; bw = 100 } continue } if (h == "") continue if (substr(l, 1, 1) == "=") { w = substr(l, 2); if (h == w && length(w) > bw) { best = l; bw = length(w) } continue } if (substr(l, 1, 2) == "*.") { w = substr(l, 3); if (under(h, w) && length(w) > bw) { best = l; bw = length(w) } continue } if (l ~ /[*?]/) continue if ((h == l || under(h, l)) && length(l) > bw) { best = l; bw = length(l) } } close(path) if (best != "") print NR "\t" $1 "\t" $2 "\t" $3 "\t" ($5 != "" ? $5 : $4) "\t" best }' "$man" if command -v sing-box >/dev/null 2>&1; then awk -F'\t' '$4 ~ /\.srs$/ { print NR "\t" $1 "\t" $2 "\t" $3 "\t" $4 }' "$man" | while IFS="$(printf '\t')" read -r n st id name path; do for x in $host $ip; do sing-box rule-set match -f binary "$path" "$x" 2>&1 | grep -q 'match rules' && { printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$n" "$st" "$id" "$name" "$path" ""; break; } done done fi } | sort -n -k1,1 | head -n 40 > "$res" printf '[' while IFS="$(printf '\t')" read -r n st id name path ent; do case "$path" in "$ST_USER_DIR"/exclude.*) lab="Исключения" ;; "$ST_USER_DIR"/custom.*|"$ST_SEC_DIR"/*/custom.*) lab="Свой список" ;; "$ST_DIR"/lists/*.base.dom) lab="основные домены сервиса" ;; "$ST_DIR"/lists/*) lab="${path##*/}"; lab="${lab%.*} (каталог списков)" ;; *) lab="${path##*/}" ;; esac printf '%s{"state":"%s","id":"%s","name":"%s","list":"%s","entry":"%s"}' "$sep" "$st" "$(esc "$id")" "$(esc "$name")" "$(esc "$lab")" "$(esc "$ent")" sep="," done < "$res" printf ']' rm -f "$man" "$man.x" "$res" } _st_dnslog_fix() { local src="$1" man="$ST_RUN/dlfix.$$" nl="$ST_RUN/dlfix.$$.n" map="$ST_RUN/dlfix.$$.m" tab x path nm tab="$(printf '\t')" if [ ! -s "$ST_DIR/model.svc" ] || [ "$(grep -c '^svc ' "$ST_DIR/model.svc")" -le 1 ]; then cat "$src"; return 0; fi mkdir -p "$ST_RUN/srsdump" awk -F'\t' '$1 == "svc" { nm = $3; next } ($1 == "dom" || $1 == "srs") && nm != "" { print nm "\t" $2 }' "$ST_DIR/model.svc" | while IFS="$tab" read -r nm path; do case "$path" in *.srs) x="$ST_RUN/srsdump/$(printf '%s' "$path" | tr -c 'A-Za-z0-9_.-' '_')" if [ -s "$path" ] && { { [ ! -s "$x.dom" ] && [ ! -s "$x.pfx" ]; } || [ "$path" -nt "$x.dom" ]; }; then rm -f "$x.dom" "$x.pfx" steer srs-read "$path" --out "$x.dom" --prefixes-out "$x.pfx" >/dev/null 2>&1 || rm -f "$x.dom" "$x.pfx" fi [ -s "$x.dom" ] && printf '%s\t%s\n' "$nm" "$x.dom" ;; *) [ -s "$path" ] && printf '%s\t%s\n' "$nm" "$path" ;; esac done > "$man" grep -o '{"name":"[^"]*","channel":"' "$src" | sed 's/^{"name":"//; s/",.*$//' | awk '!s[$0]++' > "$nl" if [ ! -s "$man" ] || [ ! -s "$nl" ]; then cat "$src"; rm -f "$man" "$nl"; return 0; fi awk -F'\t' ' FNR == NR { n = $0; sub(/\.$/, "", n); has[n] = 1; ord[++nn] = $0; key[$0] = n s = n while (1) { suf[s] = 1; i = index(s, "."); if (!i) break; s = substr(s, i + 1) } next } function add(k, lab) { if (!((k, lab) in got)) { got[k, lab] = 1; hit[k] = hit[k] SUBSEP lab } } { lab = $1; path = $2 if (!(lab in rank)) rank[lab] = ++nr while ((getline l < path) > 0) { sub(/[#;].*$/, "", l); gsub(/^[ \t\r]+|[ \t\r]+$/, "", l) if (l == "") continue l = tolower(l) c = substr(l, 1, 1) if (c == "=") { w = substr(l, 2); if (w in has) add("e" SUBSEP w, lab); continue } if (c == "*" && substr(l, 2, 1) == ".") { w = substr(l, 3); if (w in suf) add("s" SUBSEP w, lab); continue } if (l ~ /^re:/ || l ~ /[*? \t\/]/) continue if (l in suf) add("d" SUBSEP l, lab) } close(path) } END { for (j = 1; j <= nn; j++) { n = key[ord[j]]; all = ""; delete pick s = n while (1) { all = all hit["d" SUBSEP s] if (s == n) all = all hit["e" SUBSEP s]; else all = all hit["s" SUBSEP s] i = index(s, "."); if (!i) break; s = substr(s, i + 1) } m = split(all, p, SUBSEP); out = "" for (r = 1; r <= nr; r++) for (t = 2; t <= m; t++) if (rank[p[t]] == r && !(p[t] in pick)) { pick[p[t]] = 1; out = out (out == "" ? "" : ", ") p[t] } print ord[j] "\t" out } }' "$nl" "$man" > "$map" awk -v map="$map" -v direct="$ST_DIRECT_OUT" ' BEGIN { while ((getline l < map) > 0) { k = index(l, "\t"); n = substr(l, 1, k - 1); v = substr(l, k + 1); gsub(/["\\]/, "", v); lab[n] = v; known[n] = 1 } close(map) } { sep = "{\"name\":\""; pre = "\",\"channel\":\""; rest = $0; o = "" while ((i = index(rest, sep)) > 0) { o = o substr(rest, 1, i - 1 + length(sep)); rest = substr(rest, i + length(sep)) k = index(rest, "\""); nm = substr(rest, 1, k - 1); t = substr(rest, k) if (!(nm in known) || substr(t, 1, length(pre)) != pre) continue t = substr(t, length(pre) + 1); e = 0 for (z = 1; z <= length(t); z++) { c = substr(t, z, 1); if (c == "\\") { z++; continue } if (c == "\"") { e = z; break } } if (!e) continue after = substr(t, e + 1) if (index(after, ",\"out\":\"" direct "\"") == 1) continue o = o nm "\",\"channel\":" (lab[nm] != "" ? "\"" lab[nm] "\"" : "null"); rest = after } print o rest }' "$src" rm -f "$man" "$nl" "$map" } _st_src_dev() { local ip="$1" mac line s f [ -s "$ST_DIR/devices" ] || [ -n "$(_st_secs)" ] || return 1 mac="$(awk -v ip="$ip" '$3 == ip { print tolower($2); exit }' /tmp/dhcp.leases 2>/dev/null)" [ -n "$mac" ] || mac="$(awk -v ip="$ip" 'NR > 1 && $1 == ip && $3 != "0x0" { print tolower($4); exit }' /proc/net/arp 2>/dev/null)" [ -n "$mac" ] || mac="$(uci -q -X show dhcp 2>/dev/null | awk -F= -v ip="$ip" ' /^dhcp\.[^.]*\.(mac|ip)=/ { split($1, a, "."); v = $2; gsub(/\x27/, "", v); x[a[2], a[3]] = v } END { for (k in x) { split(k, z, SUBSEP); if (z[2] == "ip" && x[k] == ip && x[z[1], "mac"] != "") { split(x[z[1], "mac"], m, " "); print tolower(m[1]); exit } } }')" for s in main $(_st_secs); do if [ "$s" = main ]; then f="$ST_DIR/devices"; else f="$ST_SEC_DIR/$s/devices"; fi [ -s "$f" ] || continue line="$(awk -F'\t' -v k="$ip" -v m="$mac" '($1 == "all" || $1 == "byp" || $1 == "only") && ($2 == k || (m != "" && $2 == m)) { print $1 "\t" $2 "\t" $3; exit }' "$f")" [ -n "$line" ] && { printf '%s\t%s\n' "$line" "$s"; return 0; } done return 1 } _st_lan_ip() { case "$1" in 10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[01].*|169.254.*|127.*|fe80:*|fd*|fc*) return 0 ;; esac return 1 } _st_x_node() { local n n="$(cat "$ST_SUB_NODE" 2>/dev/null)" [ "$STL_X_VERDICT" = vpn ] && stl_state vpn && [ "$STL_UP" = true ] && [ -n "$STL_NODE" ] && { n="$(_st_active_key)"; n="${n:-$STL_NODE}"; } printf '%s' "$n" } steer_explain() { local q host="" ip="" fd where arg="$1" src="" sdev="" smode="" skey="" sname="" ssec="" case "$arg" in *'|'*) src="${arg#*|}"; arg="${arg%%|*}" ;; esac q="$(_zm_route_target "$arg")" || { echo '{"error":"введите домен (например, youtube.com) или IP-адрес"}'; return 1; } stl_present || { echo '{"error":"Steer не установлен"}'; return 1; } src="$(printf '%s' "$src" | tr -d ' \t\r\n')" if [ -n "$src" ]; then printf '%s' "$src" | grep -Eq '^(25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])){3}$' || { echo '{"error":"IP устройства — адрес вида 192.168.1.100"}'; return 1; } if sdev="$(_st_src_dev "$src")"; then smode="$(printf '%s' "$sdev" | cut -f1)"; skey="$(printf '%s' "$sdev" | cut -f2)"; sname="$(printf '%s' "$sdev" | cut -f3)"; ssec="$(printf '%s' "$sdev" | cut -f4)" [ "$ssec" = main ] && ssec="" fi fi if printf '%s' "$q" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then ip="$q" elif printf '%s' "$q" | grep -q ':'; then : else host="$q"; fi if ! { _st_owns "steer-spec" && [ ! -f "$ST_OFF" ]; }; then printf '{"target":"%s","verdict":"off","text":"","where":%s}\n' "$(esc "$q")" "$(_st_where "$host" "$ip")" return 0 fi local node probe="" p stl_explain "$q" if [ "$STL_X_VERDICT" = noaddr ] && [ -n "$host" ]; then for p in $ZM_ROUTE_PROBES; do case "$host" in "$p".*) continue ;; esac stl_explain "$p.$host" case "$STL_X_VERDICT" in noaddr|unknown) ;; *) probe="$p.$host"; break ;; esac done [ -n "$probe" ] || stl_explain "$q" fi local xs="" case "$STL_X_OUT" in zm_vpn[2-9]|zm_if[2-9]|zmv[2-9]_*) xs="s$(printf '%s' "$STL_X_OUT" | tr -cd '0-9' | cut -c1)" ;; esac [ -z "$xs" ] && case "$STL_X_SET" in s[2-9]_*) xs="${STL_X_SET%%_*}" ;; esac _st_sec_ok "$xs" || xs="" node="$(_st_sec_with "${xs:-main}" _st_x_node)" fd="$(printf '%s\n' "$STL_X_TEXT" | sed -n 's/^[[:space:]]*поддельный адрес имени \([^ ]*\).*/\1/p' | head -n1)" [ -z "$host" ] && [ -n "$fd" ] && host="$fd" && ip="" [ -z "$ip" ] && [ "$STL_X_FAKE" != true ] && printf '%s' "$STL_X_ADDR" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' && ip="$STL_X_ADDR" where="$(_st_where "$host" "$ip")" [ "$STL_X_VERDICT" = direct ] && [ "$STL_X_OUT" = "$ST_DIRECT_OUT" ] && case "$where" in *'"state":"x"'*) STL_X_VERDICT=exclude ;; esac if [ "$smode" = byp ]; then STL_X_VERDICT=devbyp elif [ "$smode" = all ]; then case "$where" in *'"state":"x"'*) STL_X_VERDICT=exclude ;; *) if [ -n "$ip" ] && _st_lan_ip "$ip"; then STL_X_VERDICT=devlan elif [ -n "$ssec" ]; then xs="$ssec"; STL_X_DEV=""; STL_X_OUT="$(_st_sec_out "$ssec")" case "$(_st_sec_kind "$ssec")" in vpn) STL_X_VERDICT=vpn; node="$(_st_sec_with "$ssec" _st_x_node)" ;; iface) STL_X_VERDICT=iface; STL_X_DEV="$(_st_sec_iface "$ssec")" ;; *) STL_X_VERDICT=warp ;; esac elif _st_use_vpn; then STL_X_VERDICT=vpn; STL_X_DEV=""; node="$(cat "$ST_SUB_NODE" 2>/dev/null)"; stl_state vpn && [ "$STL_UP" = true ] && [ -n "$STL_NODE" ] && node="$(_st_active_key)" && node="${node:-$STL_NODE}" else STL_X_VERDICT=warp; STL_X_DEV=""; fi ;; esac fi printf '{"target":"%s","verdict":"%s","out":"%s","dev":"%s","channel":"%s","addr":"%s","fake":%s,"node":"%s","probe":"%s","where":%s,"src":"%s","src_mode":"%s","src_key":"%s","src_name":"%s","src_sec":"%s","src_sec_name":"%s","sec":"%s","sec_name":"%s","sec_sub":"%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 "$node")" "$(esc "$probe")" "$where" "$(esc "$src")" "$smode" "$(esc "$skey")" "$(esc "$sname")" "$ssec" "$(esc "$([ -n "$ssec" ] && _st_sec_name "$ssec")")" "$xs" "$(esc "$([ -n "$xs" ] && _st_sec_name "$xs")")" "$(esc "$([ -n "$xs" ] && _st_sec_with "$xs" _st_sub_label)")" "$(esc_ml "$STL_X_TEXT")" } fk_route_check() { local q addr="" how="" verdict=direct tr rule="" ob="" ac="" sec="" s isip=0 caught=0 arg="$1" source="" err wh= case "$arg" in *'|'*) source="${arg#*|}"; arg="${arg%%|*}" ;; esac q="$(_zm_route_target "$arg")" || { 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; } local probe="" p name="$q" noaddr=0 if printf '%s' "$q" | grep -Eq '^[0-9.]+$|:'; then addr="$q"; isip=1; else addr="$(_zm_resolve_any "$q")"; fi if [ -z "$addr" ] && [ "$isip" = 0 ] && [ -n "$(_zm_resolve4 openwrt.org)" ]; then noaddr=1 for p in $ZM_ROUTE_PROBES; do case "$q" in "$p".*) continue ;; esac addr="$(_zm_resolve_any "$p.$q")" [ -n "$addr" ] && { probe="$p.$q"; name="$probe"; noaddr=0; break; } done fi case "$addr" in '') ;; 198.18.*|198.19.*|2001:2::*|2001:2:0:*|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 _zm_tmo_ok; then tr="$(timeout 40 ucode "$FK_UC" route "$name" "$addr" "$source" 2>/dev/null)" else tr="$(_zm_tmo 40 _fk_uc route "$name" "$addr" "$source" 2>/dev/null)"; fi case "$tr" in '{'*) err="$(printf '%s' "$tr" | jsonfilter -e '@.error' 2>/dev/null)" [ -z "$err" ] || { printf '%s\n' "$tr"; return 1; } 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)" wh="$(printf '%s' "$tr" | sed -n 's/.*"where": *\(\[.*\]\) *}[[:space:]]*$/\1/p' | head -n1)" ;; esac case "$wh" in '['*']') ;; *) wh='[]' ;; esac case "$ac" in source_required) verdict=unknown ;; fallback_direct) verdict=direct ;; 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 [ "$verdict" = unknown ] && [ "$noaddr" = 1 ] && verdict=noaddr printf '{"target":"%s","verdict":"%s","how":"%s","addr":"%s","ip":%s,"rule":"%s","outbound":"%s","action":"%s","section":"%s","source":"%s","probe":"%s","where":%s}\n' \ "$(esc "$q")" "$verdict" "$how" "$(esc "$addr")" "$([ "$isip" = 1 ] && echo true || echo false)" "$(esc "$rule")" "$(esc "$ob")" "$(esc "$ac")" "$(esc "$sec")" \ "$(esc "$source")" "$(esc "$probe")" "$wh" } forkop_action() { local a="$1" arg="$2" b u _fk_foreign_err && return 1 case "$a" in install|update|reinstall) b="$(_fk_blocker)" [ -n "$b" ] && { printf '{"error":"%s"}\n' "$(esc "$(_fk_blocker_text "$b")")"; return 1; } [ "$a" = reinstall ] && ! _fk_installed && { echo '{"error":"Forkozz не установлен"}'; return 1; } job_start forkop do_fk_install "$a" ;; remove) job_start forkop do_fk_remove "$arg" ;; tunnels) _fk_tunnels_json ;; 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|singbox_reinstall) 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) b="$(_fk_ucj diag "$arg")" || { printf '%s\n' "$b"; return 1; } u="$(_fk_src_checks)" case "$b" in *'}') printf '%s,"extra":[%s]}\n' "${b%\}}" "$u" ;; *) printf '%s\n' "$b" ;; esac ;; src_fix) _job_running forkop && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } job_start forkop do_fk_src_fix ;; tsc_clean) _tsc_installed && { echo '{"error":"Tailscale установлен — его настройки убираются только вместе с ним (страница «Удалённый доступ» → «Удалить»)"}'; return 1; } _job_running forkop && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } job_start forkop do_tsc_leftovers_clean ;; secstate) _fk_ucj secstate "$([ "$arg" = probe ] && echo probe)" ;; servers) _fk_ucj servers "$arg" ;; latency) _fk_ucj latency "$arg" ;; latmode) _fk_ucj latmode "$arg" ;; nodepol) _fk_ucj nodepol "$arg" ;; probe) case "$arg" in *'|'*) _fk_ucj probe "${arg%%|*}" "${arg#*|}" ;; *) _fk_ucj probe "" "$arg" ;; esac ;; home_forget) case "$arg" in *[!A-Za-z0-9_]*|'') echo '{"error":"неверное имя секции"}'; return 1 ;; esac rm -f "$ZM_STATE_DIR/fk.home.$arg" printf '{"ok":true}\n' ;; stats) _fk_ucj stats ;; conns) _fk_ucj conns ;; 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 } _tsc_ip_rules_left() { local f for f in -4 -6; do ip "$f" rule show 2>/dev/null | grep -qE 'lookup 52( |$)|fwmark 0x80000/0xff0000' && return 0; done return 1 } _tsc_leftovers() { local z lz _tsc_installed && return 0 uci -q get netshift.settings.source_network_interfaces | tr ' ' '\n' | grep -qx "$TSC_DEV" && echo "Forkozz ждёт трафик с $TSC_DEV" [ -f "$TSC_ON" ] && echo "панель считает удалённый доступ включённым" uci -q get "network.$TSC_NET" >/dev/null && echo "интерфейс «$TSC_NET» в сети" lz="$(_zm_lan_zone)" z="$(_zm_zone_sec "$lz" 2>/dev/null)" && case " $(uci -q get "firewall.$z.network") " in *" $TSC_NET "*) echo "«$TSC_NET» в зоне «$lz»" ;; esac uci -q get "firewall.$TSC_FW_RULE" >/dev/null && echo "правило firewall Allow-Tailscale" _tsc_nft_left && echo "правила Tailscale в firewall (ts-*)" _tsc_ip_rules_left && echo "правила маршрутизации Tailscale (таблица 52)" [ -f "$TSC_LS_PREV" ] && echo "изменённая настройка DNS роутера (localservice)" grep -qF "$TSC_CRON_TAG" "$CRON_FILE" 2>/dev/null && echo "сторож Tailscale в cron" return 0 } _fk_src_bad() { local d [ "$1" = "$TSC_DEV" ] && ! _tsc_installed && { echo tsc; return 0; } [ -d "/sys/class/net/$1" ] || { echo miss; return 0; } case "$(cat "/sys/class/net/$1/type" 2>/dev/null)" in 1|'') ;; *) [ "$1" = "$TSC_DEV" ] || { echo tun; return 0; } ;; esac for d in $(uci -q show netshift 2>/dev/null | sed -n "s/^netshift\.[^.]*\.\(interface\|output_network_interface\)='\([^']*\)'$/\2/p"); do [ "$d" = "$1" ] && { echo out; return 0; } done return 1 } _fk_src_checks() { local src d have="" bad="" sep="," k src="$(uci -q get netshift.settings.source_network_interfaces)" [ -n "$src" ] || src="br-lan" for d in $src; do if k="$(_fk_src_bad "$d")"; then case "$k" in miss) bad="$bad; $d — такого интерфейса нет" ;; tsc) bad="$bad; $d — остался от удалённого Tailscale" ;; *) bad="$bad; $d — это туннель, а не домашняя сеть: трафик пойдёт по кругу" ;; esac else have="$have $d"; fi done have="${have# }"; bad="${bad#; }" if [ -z "$bad" ]; then printf '{"verdict":"ok","what":"Откуда берёт трафик","why":"%s"}' "$(esc "$have")" else printf '{"verdict":"%s","what":"Откуда берёт трафик","why":"%s","fix":"src_fix"}' "$([ -z "$have" ] && echo fail || echo warn)" \ "$(esc "${have:+$have; }лишнее: $bad")" fi local left left="$(_tsc_leftovers | tr '\n' ';' | sed 's/;$//; s/;/; /g')" [ -n "$left" ] && printf '%s{"verdict":"fail","what":"Остатки удалённого доступа (Tailscale)","why":"%s","fix":"tsc_clean"}' "$sep" "$(esc "$left")" return 0 } do_fk_src_fix() { local src d keep="" echo "==> Проверяем, откуда Forkozz берёт трафик" uci -q get netshift.settings >/dev/null || { echo "ОШИБКА: настроек Forkozz нет"; return 1; } src="$(uci -q get netshift.settings.source_network_interfaces)" [ -n "$src" ] || src="br-lan" for d in $src; do if _fk_src_bad "$d" >/dev/null; then echo " ✓ Убираем $d"; else keep="$keep $d"; fi done [ -n "$keep" ] || { keep=" br-lan"; echo " ✓ Других сетей не осталось — возвращаем домашнюю сеть по умолчанию: br-lan"; } uci -q delete netshift.settings.source_network_interfaces for d in $keep; do uci add_list netshift.settings.source_network_interfaces="$d"; done uci commit netshift echo " ✓ Источники трафика:$keep" if [ -x /etc/init.d/netshift ] && /etc/init.d/netshift enabled 2>/dev/null; then echo "==> Перезапускаем Forkozz" _zm_run 180 /etc/init.d/netshift restart >/dev/null 2>&1 || echo "!! Forkozz перезапустился с ошибкой — смотрите журнал" fi echo "==> Готово" } do_tsc_leftovers_clean() { local f p echo "==> Ищем остатки удалённого доступа (Tailscale)" _tsc_installed && { echo "ОШИБКА: Tailscale установлен — удалите его на странице «Удалённый доступ»"; return 1; } _tsc_leftovers | sed 's/^/ · /' if uci -q get netshift.settings.source_network_interfaces | tr ' ' '\n' | grep -qx "$TSC_DEV"; then uci -q del_list netshift.settings.source_network_interfaces="$TSC_DEV" uci -q get netshift.settings.source_network_interfaces >/dev/null || uci add_list netshift.settings.source_network_interfaces="br-lan" uci commit netshift echo " ✓ Forkozz: $TSC_DEV убран из источников трафика" fi rm -f "$TSC_DIR/forkozz.own" for f in -4 -6; do for p in $(ip "$f" rule show 2>/dev/null | grep -E 'lookup 52( |$)|fwmark 0x80000/0xff0000' | sed -n 's/^\([0-9]*\):.*/\1/p'); do ip "$f" rule del pref "$p" 2>/dev/null done ip "$f" route flush table 52 >/dev/null 2>&1 done _tsc_ip_rules_left || echo " ✓ Правила маршрутизации Tailscale убраны" _tsc_unsetup rm -rf "$TSC_DIR" if [ -n "$(_tsc_leftovers)" ]; then echo "!! Осталось:"; _tsc_leftovers | sed 's/^/ · /' return 1 fi echo "==> Готово: остатков нет" } SI_OUT="$JOBS_DIR/sysinfo.json" _si_clean() { printf '%s' "$1" | tr '\t\r' ' ' | tr -d '\000-\010\013\014\016-\037'; } # Отчёт уходит в чаты поддержки: адреса, почта и ссылки с ключами в него не попадают. # IPv4 заменяется на x.x.x.x, кроме общеизвестных публичных DNS — они ничего не говорят о человеке. _si_mask() { printf '%s' "$1" | awk ' BEGIN { n = split("1.1.1.1 1.0.0.1 8.8.8.8 8.8.4.4 9.9.9.9 149.112.112.112 77.88.8.8 77.88.8.1 77.88.8.88 77.88.8.2 77.88.8.7 77.88.8.3 76.76.2.0 76.76.10.0 86.54.11.100 86.54.11.200 94.140.14.14 94.140.15.15 208.67.222.222 208.67.220.220 127.0.0.1 0.0.0.0", w, " ") for (i = 1; i <= n; i++) ok[w[i]] = 1 } { o = ""; t = $0 gsub("(vless|vmess|trojan|ss|ssr|hysteria2?|tuic|wireguard|socks5?|https?)://[^ ]*(@|token=|key=|sub/|[?][^ ]*=)[^ ]*", "(ссылка скрыта)", t) while (match(t, /[0-9]+[.][0-9]+[.][0-9]+[.][0-9]+/)) { ip = substr(t, RSTART, RLENGTH) o = o substr(t, 1, RSTART - 1) ((ip in ok) ? ip : "x.x.x.x") t = substr(t, RSTART + RLENGTH) } o = o t gsub(/[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+[.][A-Za-z0-9.-]*[A-Za-z]/, "(почта скрыта)", o) printf "%s%s", (NR > 1 ? "\n" : ""), o }' } _si_row() { printf '%s["%s","%s","%s"]' "$SI_SEP" "$(esc "$(_si_mask "$(_si_clean "$1")")")" "$(esc "$(_si_mask "$(_si_clean "$2")")")" "$3" SI_SEP="," } _si_sec() { printf ',{"title":"%s","rows":[' "$(esc "$1")"; SI_SEP=""; } _si_end() { printf ']}'; } _si_j() { jsonfilter -s "$1" -e "$2" 2>/dev/null; } _si_jl() { jsonfilter -s "$1" -e "$2" 2>/dev/null | grep . | tr '\n' ',' | sed 's/,$//; s/,/, /g'; } _si_yes() { case "$1" in true|1|on|yes) echo да ;; *) echo нет ;; esac; } SI_DOH_INIT="/etc/init.d/https-dns-proxy" # m5 / h6 / d4 → понятный интервал _si_every() { case "$1" in ''|off|0) echo нет ;; m[0-9]*) echo "каждые ${1#m} мин" ;; h1) echo "каждый час" ;; h[0-9]*) echo "каждые ${1#h} ч" ;; d[0-9]*) echo "раз в сутки в ${1#d}:00" ;; *) echo "$1" ;; esac } _si_run() { if [ "$1" = true ]; then _si_row "${2:-Состояние}" работает ok; else _si_row "${2:-Состояние}" остановлен bad; fi; } _si_pkgver() { if [ "$PKG" = apk ]; then apk list -I "$1" 2>/dev/null | awk -v p="$1" 'index($1, p "-") == 1 { v = substr($1, length(p) + 2); sub(/-r[0-9]+$/, "", v); print v; exit }' else _opkg_ver "$1" | sed -E 's/-r?[0-9]+$//'; fi } # Память: всё, что поможет понять, хватает ли её роутеру, и кто её занимает _si_memory() { local mt ma mf mc mb sw sf z top set -- $(awk '/^MemTotal:/ {t=$2} /^MemAvailable:/ {a=$2} /^MemFree:/ {f=$2} /^Cached:/ {c=$2} /^Buffers:/ {b=$2} /^SwapTotal:/ {s=$2} /^SwapFree:/ {sf=$2} END {print int(t/1024), int(a/1024), int(f/1024), int(c/1024), int(b/1024), int(s/1024), int(sf/1024)}' /proc/meminfo) mt="$1"; ma="$2"; mf="$3"; mc="$4"; mb="$5"; sw="$6"; sf="$7" _si_sec "Память" _si_row "Всего" "$mt МБ" "" _si_row "Занято" "$((mt - ma)) МБ ($(( (mt - ma) * 100 / (mt ? mt : 1) ))%)" "$([ $(( (mt - ma) * 100 / (mt ? mt : 1) )) -ge 85 ] && echo bad || { [ $(( (mt - ma) * 100 / (mt ? mt : 1) )) -ge 70 ] && echo warn; })" _si_row "Доступно" "$ma МБ" "" _si_row "Свободно совсем" "$mf МБ" "" _si_row "Кеш и буферы" "$((mc + mb)) МБ" "" if [ "${sw:-0}" -gt 0 ]; then _si_row "Подкачка" "занято $((sw - sf)) МБ из $sw МБ" ""; else _si_row "Подкачка" "нет" ""; fi z="$(cat /sys/block/zram0/disksize 2>/dev/null)"; [ -n "$z" ] && [ "$z" -gt 0 ] 2>/dev/null && _si_row "zram" "$((z / 1048576)) МБ" "" top="$(for f in /proc/[0-9]*/status; do awk '/^Name:/ {n=$2} /^VmRSS:/ {r=$2} END {if (r > 0) print r, n}' "$f" 2>/dev/null; done | awk '{s[$2] += $1} END {for (n in s) print s[n], n}' | sort -rn | head -n 6 | awk '{printf "%s%s %d МБ", (NR > 1 ? ", " : ""), $2, ($1 + 512) / 1024}')" [ -n "$top" ] && _si_row "Больше всех занимают" "$top" "" _si_end } # Система: процессор, нагрузка, температура, время, менеджер пакетов _si_system() { local cpu n la t tz ntp cpu="$(sed -En 's/^(model name|cpu model|Hardware)[[:space:]]*:[[:space:]]*//p' /proc/cpuinfo 2>/dev/null | head -n1)" n="$(grep -c '^processor' /proc/cpuinfo 2>/dev/null)" la="$(cut -d' ' -f1-3 /proc/loadavg 2>/dev/null)" t="$(_cpu_temp 2>/dev/null)" tz="$(uci -q get system.@system[0].zonename)"; [ -n "$tz" ] || tz="$(uci -q get system.@system[0].timezone)" ntp="$(uci -q get system.ntp.enabled)" _si_sec "Система" [ -n "$cpu" ] && _si_row "Процессор" "$cpu" "" _si_row "Ядер" "${n:-1}" "" _si_row "Нагрузка (1/5/15 мин)" "${la:-—}" "" [ -n "$t" ] && _si_row "Температура" "$t °C" "$([ "${t%.*}" -ge 85 ] 2>/dev/null && echo bad || { [ "${t%.*}" -ge 70 ] 2>/dev/null && echo warn; })" _si_row "Время на роутере" "$(date '+%d.%m.%Y %H:%M')" "" _si_row "Часовой пояс" "${tz:-не задан}" "" _si_row "Синхронизация времени" "$([ "$ntp" = 0 ] && echo выключена || echo включена)" "$([ "$ntp" = 0 ] && echo warn)" _si_row "Пакеты" "$PKG" "" _si_row "Соединений" "$(cat /proc/sys/net/netfilter/nf_conntrack_count 2>/dev/null || echo —) из $(cat /proc/sys/net/netfilter/nf_conntrack_max 2>/dev/null || echo —)" "" _si_end } # Сеть роутера без адресов: как подключён интернет, Wi-Fi, интерфейсы, firewall и DNS _si_net_more() { local proto v6 r band ch dis wl ifs ip fw dns proto="$(uci -q get network.wan.proto)" _si_row "Подключение к интернету" "${proto:-—}$(uci -q get network.wwan.proto >/dev/null 2>&1 && echo ' · есть wwan (Wi-Fi-клиент)')" "" v6="$(uci -q get network.wan6.proto)" _si_row "IPv6 у провайдера" "$([ -n "$v6" ] && [ "$(uci -q get network.wan6.disabled)" != 1 ] && echo "включён ($v6)" || echo выключен)" "" ifs="$(ip -o link show up 2>/dev/null | awk -F': ' '{print $2}' | sed 's/@.*//' | grep -vE '^(lo|ifb|gre|erspan|teql|ip6|sit|tun.?l|dummy)' | tr '\n' ' ')" [ -n "$ifs" ] && _si_row "Интерфейсы (включены)" "$ifs" "" for r in $(uci -q show wireless | sed -n "s/^wireless\.\([^.=]*\)=wifi-device$/\1/p"); do band="$(uci -q get "wireless.$r.band")"; ch="$(uci -q get "wireless.$r.channel")"; dis="$(uci -q get "wireless.$r.disabled")" wl="$(uci -q show wireless | grep -c "\.device='$r'")" _si_row "Wi-Fi $r" "$([ "$dis" = 1 ] && echo выключен || echo "${band:-?}, канал ${ch:-auto}, $(uci -q get "wireless.$r.htmode"), сетей: $wl")" "" done fw="$(uci -q show firewall | sed -n "s/^firewall\.\([^.=]*\)\.name='wan'$/\1/p" | head -n1)" [ -n "$fw" ] && _si_row "Вход из интернета" "$(uci -q get "firewall.$fw.input")" "$(case "$(uci -q get "firewall.$fw.input")" in REJECT|DROP) echo ok ;; *) echo bad ;; esac)" dns="$(uci -q get dhcp.@dnsmasq[0].server | wc -w)" _si_row "dnsmasq" "своих серверов: $dns, noresolv: $(_si_yes "$(uci -q get dhcp.@dnsmasq[0].noresolv)")" "" } # Компоненты ZM: сводка и подробные настройки каждого установленного _si_components() { local j v x s sec n _si_sec "Компоненты ZM" _si_row "Zapret Manager" "$ZM_VERSION" "" [ -f /etc/init.d/zapret ] && _si_row "Zapret" "$ZAPRET_VER · $(pgrep -f '/opt/zapre[t]/' >/dev/null && echo работает || echo остановлен)" "$(pgrep -f '/opt/zapre[t]/' >/dev/null && echo ok || echo bad)" [ -f /etc/init.d/zapret2 ] && _si_row "Zapret2" "$(_si_pkgver zapret2) · $(/etc/init.d/zapret2 status >/dev/null 2>&1 && echo работает || echo остановлен)" "" _st_installed 2>/dev/null && _si_row "Steer" "установлен" "" _fk_installed && _si_row "$(_fk_name)" "$(_fk_up && echo работает || echo выключен)" "$(_fk_up && echo ok)" [ -x /usr/bin/bytetube ] && _si_row "ByeTube" "установлен" "" { [ -f /etc/init.d/tg-ws-proxy ] || [ -f "$TG_INIT_GO" ] || [ -f "$TG_INIT_RS" ]; } && _si_row "TG WS Proxy" "установлен" "" [ -x /etc/init.d/tgws ] && _si_row "sTGWS" "установлен" "" [ -x "$MIHOMO_BIN" ] && _si_row "Mixomo" "установлен" "" _ab_on && _si_row "Блокировка рекламы" "включена" ok [ -x "$SI_DOH_INIT" ] && _si_row "DNS over HTTPS" "установлен" "" _awg_installed 2>/dev/null && _si_row "AmneziaWG" "установлен" "" _tsc_installed && _si_row "Tailscale" "$(_tsc_vnum "$(_tsc_ver)")" "" _term_installed && _si_row "Терминал (ttyd)" "$(_term_version)" "" [ -x /usr/bin/htop ] && _si_row "htop" "установлен" "" _si_end echo "==> Смотрим компоненты ZM" >&2 if [ -f /etc/init.d/zapret2 ]; then _si_sec "Zapret2" _si_row "Версия" "$(_si_pkgver zapret2)" "" _si_run "$(/etc/init.d/zapret2 status >/dev/null 2>&1 && echo true || echo false)" _si_row "Автозапуск" "$(ls /etc/rc.d/S*zapret2 >/dev/null 2>&1 && echo да || echo нет)" "" _si_end fi if _st_installed 2>/dev/null; then j="$(steer_status 2>/dev/null)" _si_sec "Steer" _si_row "Версия" "$(_si_j "$j" '@.version')" "" _si_run "$(_si_j "$j" '@.steer_running')" [ "$(_si_j "$j" '@.stopped')" = true ] && _si_row "Остановлен вручную" "да" warn _si_row "Выход" "$(case "$(_si_j "$j" '@.exit')" in warp) echo WARP ;; vpn) echo VPN по подписке ;; none|'') echo не подключён ;; *) _si_j "$j" '@.exit' ;; esac)" "" _si_row "WARP" "$([ "$(_si_j "$j" '@.warp_on')" = true ] && echo "включён, $([ "$(_si_j "$j" '@.warp_up')" = true ] && echo поднят || echo не поднят), режим $(_si_j "$j" '@.warp_mode'), точка $(_si_j "$j" '@.warp_colo')" || echo выключен)" "$([ "$(_si_j "$j" '@.warp_on')" = true ] && { [ "$(_si_j "$j" '@.warp_up')" = true ] && echo ok || echo bad; })" _si_row "VPN по подписке" "$([ "$(_si_j "$j" '@.has_sub')" = true ] && echo "есть, $([ "$(_si_j "$j" '@.vpn_up')" = true ] && echo поднят || echo не поднят)" || echo нет)" "" _si_row "Туннелей" "$(_si_j "$j" '@.tunnels[*]' | grep -c .)" "" _si_row "Каналов" "$(_si_j "$j" '@.channels')" "" _si_row "Сервисы" "$(_si_jl "$j" '@.services[@.on=true].name')" "" _si_row "Устройств с отдельными правилами" "$(_si_j "$j" '@.devices[*]' | grep -c .)" "" _si_row "DNS" "$([ "$(_si_j "$j" '@.dnsmode')" = router ] && echo через роутер || echo через туннель) · DoH $(_si_j "$j" '@.doh_mode')" "" _si_row "Если туннель упал" "$([ "$(_si_j "$j" '@.onfail')" = drop ] && echo без интернета || echo напрямую)" "" _si_row "Списки через туннель" "$(_si_yes "$(_si_j "$j" '@.lists_via')")" "" _si_row "Автоперезапуск" "$(_si_every "$(_si_j "$j" '@.autorestart')")" "" _si_end fi if _fk_installed; then j="$(forkop_status 2>/dev/null)" _si_sec "$(_fk_name)" _si_row "Версия" "$(_si_j "$j" '@.version')" "" _si_row "sing-box" "$(_si_j "$j" '@.singbox')$( v="$(_si_j "$j" '@.singbox_pkg')"; [ -n "$v" ] && printf ' · %s' "$(_fk_sb_name "$v")")" "" _si_run "$(_fk_up && echo true || echo false)" _si_row "Автозапуск" "$(_si_yes "$(_si_j "$j" '@.enabled')")" "" [ "$(_si_j "$j" '@.foreign')" = true ] && _si_row "Поставлен не панелью" "да" warn v="$(_si_j "$j" '@.blocker_text')"; [ -n "$v" ] && _si_row "Мешает" "$v" bad _si_row "DNS" "$(uci -q get netshift.settings.dns_type) · $(uci -q get netshift.settings.dns_server)" "" _si_row "Откуда берёт трафик" "$(uci -q get netshift.settings.source_network_interfaces)" "" _si_row "QUIC" "$(case "$(uci -q get netshift.settings.disable_quic)" in 1) echo отклоняется в туннеле ;; all) echo отклоняется везде ;; *) echo не трогаем ;; esac)" "" _si_row "IPv6" "$(_si_yes "$(uci -q get netshift.settings.enable_ipv6)")" "" _si_row "Блокировать DoH" "$(_si_yes "$(uci -q get netshift.settings.block_doh)")" "" _si_row "Обновление списков" "$(uci -q get netshift.settings.update_interval)" "" for sec in $(uci -q show netshift | sed -n "s/^netshift\.\([^.=]*\)=section$/\1/p"); do x="$(uci -q get "netshift.$sec.connection_type")" v="$(uci -q get "netshift.$sec.proxy_config_type")" n="$(uci -q get "netshift.$sec.community_lists")" _si_row "Секция $sec" "${x:-?}$([ -n "$v" ] && echo " · $v")$([ "$(uci -q get "netshift.$sec.global_proxy")" = 1 ] && echo ' · весь трафик')$([ -n "$n" ] && echo " · списки: $n")" "" done _si_end fi if [ -x /usr/bin/bytetube ]; then j="$(/usr/bin/bytetube status 2>/dev/null)" _si_sec "ByeTube" _si_row "Включён" "$(_si_yes "$(_si_j "$j" '@.enabled')")" "" _si_row "ByeDPI" "$(_si_yes "$(_si_j "$j" '@.byedpi')")" "$([ "$(_si_j "$j" '@.byedpi')" = true ] && echo ok || echo bad)" _si_row "hev-socks5-tunnel" "$(_si_yes "$(_si_j "$j" '@.hev')")" "$([ "$(_si_j "$j" '@.hev')" = true ] && echo ok || echo bad)" _si_end fi if [ -f /etc/init.d/tg-ws-proxy ] || [ -f "$TG_INIT_GO" ] || [ -f "$TG_INIT_RS" ] || [ -x /etc/init.d/tgws ]; then j="$(tg_status 2>/dev/null)" _si_sec "Telegram" for x in mtproto:MTProto socks5:SOCKS5 rust:Rust; do v="${x%%:*}" [ "$(_si_j "$j" "@.$v")" = installed ] || continue _si_row "${x#*:}" "$(_si_j "$j" "@.${v}_version") · $([ "$(_si_j "$j" "@.${v}_running")" = true ] && echo работает || echo остановлен) · перезапуск: $(_si_every "$(_si_j "$j" "@.auto.$v")")" "$([ "$(_si_j "$j" "@.${v}_running")" = true ] && echo ok || echo bad)" done [ "$(_si_j "$j" '@.rust')" = installed ] && _si_row "Rust: порт" "$(_si_j "$j" '@.rust_port')" "" if [ -x /etc/init.d/tgws ]; then x="$(tgws_status 2>/dev/null)" _si_row "sTGWS" "$(_si_j "$x" '@.version') · $([ "$(_si_j "$x" '@.running')" = true ] && echo работает || echo остановлен) · перезапуск: $(_si_every "$(_si_j "$j" '@.auto.tgws')")" "$([ "$(_si_j "$x" '@.running')" = true ] && echo ok || echo bad)" fi _si_row "Присмотр за прокси" "$(_si_yes "$(_si_j "$j" '@.auto.watch')")" "" _si_end fi if [ -x "$MIHOMO_BIN" ]; then j="$(mixomo_status 2>/dev/null)" _si_sec "Mixomo" _si_row "Mihomo" "$(_si_j "$j" '@.mihomo_version') · $([ "$(_si_j "$j" '@.mihomo_running')" = true ] && echo работает || echo остановлен)" "$([ "$(_si_j "$j" '@.mihomo_running')" = true ] && echo ok || echo bad)" [ "$(_si_j "$j" '@.magitrickle')" = installed ] && _si_row "MagiTrickle" "$(_si_j "$j" '@.magitrickle_version') · $([ "$(_si_j "$j" '@.magitrickle_running')" = true ] && echo работает || echo остановлен) · список: $(_si_j "$j" '@.magitrickle_list')" "" [ "$(_si_j "$j" '@.hev')" = installed ] && _si_row "hev-socks5-tunnel" "$(_si_j "$j" '@.hev_version') · $([ "$(_si_j "$j" '@.hev_running')" = true ] && echo работает || echo остановлен)" "" _si_row "Подписка" "$([ -n "$(_si_j "$j" '@.subscription')" ] && [ "$(_si_j "$j" '@.subscription')" != false ] && echo есть || echo нет)" "" _si_row "Автоперезапуск" "$(_si_every "$(_si_j "$j" '@.autorestart')")" "" _si_end fi j="$(hosts_status 2>/dev/null)" n="$(_si_j "$j" '@.items[@.enabled=true].id' | grep -c .)" if [ "$n" -gt 0 ] || [ -n "$(_si_j "$j" '@.geohide')" ]; then _si_sec "Hosts" _si_row "Включено наборов" "$n" "" [ "$n" -gt 0 ] && _si_row "Наборы" "$(_si_jl "$j" '@.items[@.enabled=true].id')" "" _si_row "Список GeoHide" "$(_si_j "$j" '@.geohide' | sed 's/^$/нет/')" "" _si_end fi if _ab_on; then j="$(adblock_status 2>/dev/null)" _si_sec "Блокировка рекламы" _si_row "Применена" "$(_si_yes "$(_si_j "$j" '@.applied')")" "$([ "$(_si_j "$j" '@.applied')" = true ] && echo ok || echo bad)" _si_row "Список" "$(_si_j "$j" '@.list')" "" _si_row "Доменов" "$(_si_j "$j" '@.count')" "" _si_row "Автообновление" "$(_si_yes "$(_si_j "$j" '@.auto')")" "" _si_row "Блокировать DoH браузеров" "$(_si_yes "$(_si_j "$j" '@.doh')")" "" _si_row "Занимает в памяти" "$(( $(_si_j "$j" '@.ram_kb' | grep -o '^[0-9]*' | sed 's/^$/0/') / 1024 )) МБ" "" v="$(_si_j "$j" '@.err')"; [ -n "$v" ] && _si_row "Последняя ошибка" "$v" bad _si_end fi if [ -x "$SI_DOH_INIT" ]; then j="$(doh_status 2>/dev/null)" _si_sec "DNS over HTTPS" _si_run "$(_si_j "$j" '@.running')" _si_row "Провайдеры" "$(_si_jl "$j" '@.resolvers[*].provider')" "" _si_row "Серверы" "$(_si_jl "$j" '@.resolvers[*].url')" "" _si_row "Перехват DNS устройств" "$(_si_yes "$(_si_j "$j" '@.force_dns')") ($(_si_j "$j" '@.force_mode'))" "" _si_row "Запасной DNS при сбое" "$(_si_yes "$(_si_j "$j" '@.failover.on')")" "" _si_end fi if _awg_installed 2>/dev/null; then _si_sec "AmneziaWG" _si_row "Модуль ядра" "$(_awg_kmod_ver)" "" _si_row "awg-tools" "$(_si_pkgver amneziawg-tools)" "" n=0; v="" for x in $(_awg_ifaces); do n=$((n + 1)); v="$v $x($([ "$(cat "/sys/class/net/$x/operstate" 2>/dev/null)" = down ] || [ ! -d "/sys/class/net/$x" ] && echo выкл || echo вкл))"; done _si_row "Туннели" "$([ "$n" -gt 0 ] && echo "$n:$v" || echo нет)" "" _si_end fi if _tsc_installed; then j="$(tsc_status 2>/dev/null)" _si_sec "Tailscale" _si_row "Версия" "$(_si_j "$j" '@.version') · $(_si_j "$j" '@.source' | sed 's/^$/неизвестно откуда/')" "" _si_run "$(_si_j "$j" '@.running')" "Служба" _si_row "Вход" "$(_si_j "$j" '@.state')" "$([ "$(_si_j "$j" '@.state')" = Running ] && echo ok || echo warn)" _si_row "Сервер" "$(case "$(_si_j "$j" '@.server_kind')" in routerich) echo Routerich ;; custom) echo свой сервер ;; *) echo Tailscale ;; esac)" "" _si_row "Выход в интернет" "$([ "$(_si_j "$j" '@.exit_want')" = true ] && { [ "$(_si_j "$j" '@.exit_ok')" = true ] && echo одобрен || echo ждёт одобрения; } || echo выключен)" "" _si_row "Доступ к домашней сети" "$([ "$(_si_j "$j" '@.lan_want')" = true ] && { [ "$(_si_j "$j" '@.lan_ok')" = true ] && echo одобрен || echo ждёт одобрения; } || echo выключен)" "" _si_row "Настройки Tailscale" "$([ "$(_si_j "$j" '@.prefs_ok')" = false ] && echo сбились || echo в порядке)" "$([ "$(_si_j "$j" '@.prefs_ok')" = false ] && echo bad || echo ok)" v="$(_si_j "$j" '@.key_expiry')"; _si_row "Вход роутера" "$([ -n "$v" ] && echo "до ${v%%T*}" || echo бессрочный)" "" _si_row "Forkozz для телефона" "$(_si_yes "$(_si_j "$j" '@.fk_ok')")" "" v="$(_si_j "$j" '@.term_mode')"; [ -n "$v" ] && _si_row "Терминал через Tailscale" "$([ "$v" = all ] || [ "$v" = ts ] && echo открывается || echo только дома)" "" _si_row "Страница в LuCI" "$(_si_yes "$(_si_j "$j" '@.luci_app')")" "" _si_end fi if _term_installed; then _si_sec "Терминал" _si_run "$(_term_running && echo true || echo false)" _si_row "Где открывается" "$(case "$(_term_mode)" in all) echo дома и через Tailscale ;; ts) echo только Tailscale ;; other) echo "интерфейс $(_term_iface)" ;; *) echo только дома ;; esac)" "" _si_row "Порт" "$(_term_port)" "" _si_end fi _si_sec "Панель Zapret Manager" _si_row "Версия" "$ZM_VERSION" "" _si_row "Фоновые проверки" "$([ -f "$WATCH_OFF" ] && echo выключены || echo включены)" "$([ -f "$WATCH_OFF" ] && echo warn)" _si_row "Поиск обновлений" "$(case "$(_ver_mode)" in day) echo ночью ;; open) echo при открытии ;; *) echo по кнопке ;; esac)" "" v="$(cat "$ZM_STATE_DIR/ui.theme" 2>/dev/null)"; _si_row "Тема Web UI" "${v:-авто}" "" v="$(ui_nav_get)"; [ -n "$v" ] && _si_row "Скрыто в меню" "$v" "" _si_row "Задания cron панели" "$(grep -c 'zapret-manager\|zm-' "$CRON_FILE" 2>/dev/null)" "" _si_end } _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)" if [ -z "$t" ] && [ "$1" = -6 ] && t="$(_zm_v6_ms $ZM_V6_ADDRS)"; then _si_row "$2" "работает, $t мс; DNS роутера IPv6-адреса сайтов не отдаёт" ok return fi [ -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 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 ']}' _si_memory _si_system } > "$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 _si_net_more 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 выключен)" "" _si_row "Автозапуск" "$(ls /etc/rc.d/S*zapret >/dev/null 2>&1 && echo да || echo нет)" "$(ls /etc/rc.d/S*zapret >/dev/null 2>&1 || echo bad)" _si_row "Режим списков" "$(if [ "$(jsonfilter -s "$st" -e '@.rkn_on' 2>/dev/null)" = true ]; then echo "только списки РКН ($(jsonfilter -s "$st" -e '@.rkn_count' 2>/dev/null) доменов)"; elif [ "$(jsonfilter -s "$st" -e '@.ulist_on' 2>/dev/null)" = true ]; then echo "только свой список ($(jsonfilter -s "$st" -e '@.ulist_count' 2>/dev/null))"; else echo "все сайты, кроме исключений"; fi)" "" _si_row "QUIC для YouTube" "$(_si_yes "$(jsonfilter -s "$st" -e '@.quic_yt' 2>/dev/null)")" "" _si_row "wssize" "$(_si_yes "$(jsonfilter -s "$st" -e '@.wssize' 2>/dev/null)")" "" _si_row "--ip-id=zero для YouTube" "$(_si_yes "$(jsonfilter -s "$st" -e '@.ipid' 2>/dev/null)")" "" _si_row "Режим эксперта" "$(_si_yes "$(jsonfilter -s "$st" -e '@.expert' 2>/dev/null)")" "" v="$(jsonfilter -s "$st" -e '@.flowseal' 2>/dev/null)"; [ -n "$v" ] && _si_row "Стратегия Flowseal" "$v" "" _si_row "Исключений" "$(grep -c . /opt/zapret/ipset/zapret-hosts-user-exclude.txt 2>/dev/null || echo 0)" "" else _si_row "Состояние" "не установлен" "" fi printf ']}' _si_components 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":[]}' } AB_DIR="$ZM_STATE_DIR/adblock" AB_SET="$AB_DIR/settings" AB_ALLOW="$AB_DIR/allow.txt" AB_BLOCK="$AB_DIR/block.txt" AB_NAME="zm-adblock.conf" AB_CRON_TAG="# zm-adblock" AB_FREE="$AB_DIR/free.txt" AB_FREE_PORT=53535 AB_FREE_CONF="/var/etc/zm-adblock-free.conf" AB_FREE_PID="/var/run/zm-adblock-free.pid" AB_FREE_NFT="zm_adblock_free" AB_STATS_CONF="zm-adblock-stats.conf" AB_STATS_LOG="/var/run/dnsmasq/zm-queries.log" AB_STATS_DIR="/tmp/zm-adblock-stats" AB_STATS_CRON="# zm-abstats" AB_STATS_ALL="$AB_DIR/stats-all.txt" _ab_devmap() { { printf 'R\t127.0.0.1\nR\t::1\n' ip -o addr show | awk '{ a = $4; sub(/\/.*/, "", a); if (a != "") printf "R\t%s\n", a }' awk 'NR > 1 && $4 ~ /^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$/ && $4 != "00:00:00:00:00:00" { printf "M\t%s\t%s\n", $1, tolower($4) }' /proc/net/arp ip neigh show | awk '{ for (i = 2; i < NF; i++) if ($i == "lladdr") { printf "M\t%s\t%s\n", $1, tolower($(i + 1)); break } }' awk '$2 ~ /^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$/ && $3 != "" { m = tolower($2); printf "M\t%s\t%s\nF\t%s\t%s\n", $3, m, m, $3; if ($4 != "" && $4 != "*") printf "N\t%s\t%s\n", m, $4 }' /tmp/dhcp.leases uci -q -X show dhcp | awk -F= '{ split($1, p, "."); v = $2; gsub("\047", "", v) } p[3] == "name" { nm[p[2]] = v } p[3] == "mac" { mc[p[2]] = tolower(v) } END { for (k in nm) if (k in mc) { n = split(mc[k], a, " "); for (i = 1; i <= n; i++) printf "N\t%s\t%s\n", a[i], nm[k] } }' awk '$1 !~ /^#/ && $1 ~ /:/ && NF >= 2 { n = $NF; sub(/\..*/, "", n); if (n != "") printf "H\t%s\t%s\n", $1, n }' /tmp/hosts/odhcpd } 2>/dev/null } AB_KEY_AWK=' function abmap(f, l, a) { while ((getline l < f) > 0) { split(l, a, "\t"); if (a[1] == "R") R[a[2]] = 1; else if (a[1] == "M") M[a[2]] = a[3]; else if (a[1] == "F") F4[a[2]] = a[3]; else if (a[1] == "N") N[a[2]] = a[3]; else if (a[1] == "H") H[a[2]] = a[3] } close(f) } function abkey(k) { if (k == "r" || k ~ /^[mn]:/) return k; if (k in R) return "r"; if (k in M) return "m:" M[k]; if (k in H) return "n:" H[k]; return k } ' AB_URL_GH="${GH_RAW}/hagezi/dns-blocklists/main/dnsmasq" AB_URL_CDN="https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/dnsmasq" AB_ALLOW_DEFAULT='# Переходы по рекламе в поиске Яндекса yabs.yandex.ru # Ссылки из писем и приложений Яндекса redirect.appmetrica.yandex.com # Ссылки из рассылок Mail.ru trk.mail.ru # Партнёрские ссылки и кешбэк AliExpress s.click.aliexpress.com' _ab_say() { echo "==> $*"; } _ab_get() { local v v="$(_kv_last "$AB_SET" "$1")" echo "${v:-$2}" } _ab_put() { local k v tmp="$AB_SET.zm-new" mkdir -p "$AB_DIR" [ -f "$AB_SET" ] || : > "$AB_SET" cp -f "$AB_SET" "$tmp" while [ $# -ge 2 ]; do k="$1"; v="$2"; shift 2 grep -v "^$k=" "$tmp" > "$tmp.2" 2>/dev/null; mv -f "$tmp.2" "$tmp" echo "$k=$v" >> "$tmp" done mv -f "$tmp" "$AB_SET" } _ab_file() { case "$1" in light) echo light.txt ;; multi) echo multi.txt ;; pro) echo pro.txt ;; proplus) echo pro.plus.txt ;; ultimate) echo ultimate.txt ;; tif) echo tif.mini.txt ;; doh) echo doh.txt ;; *) return 1 ;; esac } _ab_title() { case "$1" in light) echo Light ;; multi) echo Multi ;; pro) echo Pro ;; proplus) echo "Pro++" ;; ultimate) echo Ultimate ;; tif) echo "TIF mini (фишинг)" ;; doh) echo "чужих DoH-серверов" ;; *) echo "$1" ;; esac } _ab_min() { case "$1" in light) echo 20000 ;; multi|pro|proplus|ultimate) echo 60000 ;; tif) echo 80000 ;; doh) echo 1000 ;; esac } _ab_gz() { command -v gzip >/dev/null 2>&1; } _ab_src() { if [ -s "$AB_DIR/src/$1.gz" ]; then echo "$AB_DIR/src/$1.gz"; elif [ -s "$AB_DIR/src/$1.txt" ]; then echo "$AB_DIR/src/$1.txt"; else return 1; fi; } _ab_cat() { local f f="$(_ab_src "$1")" || return 1 case "$f" in *.gz) gzip -dc "$f" ;; *) cat "$f" ;; esac } _ab_num() { echo "${1:-0}" | awk '{ s = $1; o = ""; while (length(s) > 3) { o = " " substr(s, length(s) - 2) o; s = substr(s, 1, length(s) - 3) } print s o }'; } _ab_dom() { echo "$(_ab_num "$1") $(echo "${1:-0}" | awk '{ n = $1 % 100; m = $1 % 10; print (n >= 11 && n <= 14) ? "доменов" : (m == 1 ? "домен" : (m >= 2 && m <= 4 ? "домена" : "доменов")) }')"; } _ab_on() { [ "$(_ab_get on 0)" = 1 ]; } _ab_ids() { local ids ids="$(_ab_get list pro)" [ "$(_ab_get tif 0)" = 1 ] && ids="$ids tif" [ "$(_ab_get doh 0)" = 1 ] && ids="$ids doh" echo "$ids" } _ab_dirs() { local d d="$(sed -n 's/^conf-dir=\([^,]*\).*/\1/p' /var/etc/dnsmasq.conf.* 2>/dev/null | sort -u)" echo "${d:-/tmp/dnsmasq.d}" } _ab_applied() { local d n=0 for d in $(_ab_dirs); do [ -s "$d/$AB_NAME" ] || return 1; n=1; done [ "$n" = 1 ] } _ab_dns_rss() { local p t=0 v for p in $(pidof dnsmasq 2>/dev/null); do v="$(awk '/^VmRSS:/ { print $2 }' "/proc/$p/status" 2>/dev/null)" t=$((t + ${v:-0})) done echo "$t" } _ab_dns_alive() { pidof dnsmasq >/dev/null 2>&1 && { nslookup localhost 127.0.0.1 >/dev/null 2>&1 || nslookup openwrt.org 127.0.0.1 >/dev/null 2>&1; }; } _ab_doh_router_hosts() { local i=0 u while u="$(uci -q get "https-dns-proxy.@https-dns-proxy[$i].resolver_url")"; do printf '%s\n' "$u" | sed -n 's#^[a-z]*://\([^/:]*\).*#\1#p' i=$((i + 1)) done } _ab_own_hosts() { _ab_doh_router_hosts _ab_dns_hosts _ab_tg_hosts _ab_never } AB_TG_LIST="$AB_DIR/tg.domains" AB_TG_URL="${GH_RAW}/Flowseal/tg-ws-proxy/refs/heads/main/.github/cfproxy-domains.txt" _ab_tg_hosts() { cat << 'ZM_AB_TG_EOF' web.telegram.org pclead.co.uk offshor.co.uk cakeisalie.co.uk noskomnadzor.co.uk lovetrue.co.uk sorokdva.co.uk pyatdesyatdva.co.uk kartoshka.co.uk sorokodin.co.uk pyatdesyatodin.co.uk notelega.co.uk ebally.co.uk nebally.co.uk havegreatday.co.uk pomogite.co.uk fixtelega.co.uk sadnews.co.uk onedaychamp.co.uk stopblocking.co.uk nothingthere.co.uk ZM_AB_TG_EOF [ -s "$AB_TG_LIST" ] && cat "$AB_TG_LIST" [ -x /etc/init.d/tgws ] && command -v tgws >/dev/null 2>&1 && _tgws_domain 2>/dev/null | sed 's#^[a-z]*://##; s#[/:].*$##' return 0 } _ab_tg_fetch() { local tmp="$JOBS_DIR/adblock.tg.dl" mkdir -p "$AB_DIR" "$JOBS_DIR" curl -fsSL --connect-timeout 8 --max-time 20 -o "$tmp" "$AB_TG_URL" >/dev/null 2>&1 || { rm -f "$tmp"; return 1; } tr -d '\r' < "$tmp" | tr 'A-Z' 'a-z' | awk '/^[a-z0-9.-]+\.com$/ { p = substr($0, 1, length($0) - 4); n = gsub(/[a-z]/, "&", p); o = "" for (i = 1; i <= length(p); i++) { c = substr(p, i, 1); k = index("abcdefghijklmnopqrstuvwxyz", c) if (k) { k = (k - 1 - n) % 26; if (k < 0) k += 26; c = substr("abcdefghijklmnopqrstuvwxyz", k + 1, 1) } o = o c } print o ".co.uk" }' | head -n 200 > "$tmp.dec" if [ "$(wc -l < "$tmp.dec" | tr -d ' ')" -ge 3 ]; then mv -f "$tmp.dec" "$AB_TG_LIST"; else rm -f "$tmp.dec"; fi rm -f "$tmp" return 0 } _ab_dns_hosts() { _ab_dns_builtin [ -f /etc/config/netshift ] || return 0 { uci -q get netshift.settings.zm_dns_servers; uci -q get netshift.settings.dns_server uci -q show netshift | sed -n "s/^netshift\.[^.=]*\.domain_resolver_dns_server='\(.*\)'\$/\1/p"; } 2>/dev/null | tr ' ' '\n' | sed 's#^[a-z]*://##; s#[/:?].*$##' | grep '[a-zA-Z]' } _ab_dns_builtin() { cat << 'ZM_AB_DNS_EOF' dns.quad9.net dns10.quad9.net cloudflare-dns.com security.cloudflare-dns.com family.cloudflare-dns.com one.one.one.one 1dot1dot1dot1.cloudflare-dns.com dns.google common.dot.dns.yandex.net safe.dot.dns.yandex.net family.dot.dns.yandex.net dns.comss.one xbox-dns.ru dns.dns-ai.ru dns.bezmezhau.com geohide.ru eu.geohide.ru us.geohide.ru dns.malw.link dns.zapret.moe d.webshiftvpn.ru dns.dagami.ru freedns.controld.com unfiltered.joindns4.eu protective.joindns4.eu child.joindns4.eu ZM_AB_DNS_EOF } _ab_jl() { awk 'NF && !/^[0-9.]*$/ { printf "%s\"%s\"", (n++ ? "," : ""), $0 }' "$@"; } _ab_exempt_json() { local t="$JOBS_DIR/ab.exempt.$$" mkdir -p "$JOBS_DIR" _ab_dns_builtin | _ab_allow_norm > "$t.b" { _ab_dns_hosts; _ab_doh_router_hosts; } | _ab_allow_norm > "$t.d" _ab_tg_hosts | _ab_allow_norm > "$t.t" _ab_never | _ab_allow_norm > "$t.n" printf '{"dns":[%s],"dns_own":[%s],"tg":[%s],"never":[%s]}\n' "$(_ab_jl "$t.d")" "$(grep -vxF -f "$t.b" "$t.d" | _ab_jl)" "$(_ab_jl "$t.t")" "$(_ab_jl "$t.n")" rm -f "$t.b" "$t.d" "$t.t" "$t.n" } AB_DNS_STAMP="$AB_DIR/dns.hosts" _ab_dns_now() { { _ab_dns_hosts; _ab_tg_hosts; } | _ab_allow_norm | grep -v '^[0-9.]*$' | sort; } _ab_dns_sync() { local i=0 cur _ab_on && _ab_applied || return 0 cur="$(_ab_dns_now)" [ "$cur" = "$(cat "$AB_DNS_STAMP" 2>/dev/null)" ] && return 0 while [ "$i" -lt 20 ] && { _job_alive adblock || [ -n "$(_zm_busy_job)" ]; }; do sleep 15; i=$((i + 1)); done _job_alive adblock && return 0 if _ab_rebuild >/dev/null 2>&1; then logger -t zapret-manager "Блокировка рекламы: серверы DNS (Forkozz, DoH) и TG WS Proxy добавлены в исключения" fi } _ab_dns_bg() { ( sleep "${1:-20}"; _ab_dns_sync ) >/dev/null 2>&1 /dev/null; return 0; } _ab_dl() { local id="$1" f tmp="$JOBS_DIR/adblock.$1.dl" dom="$JOBS_DIR/adblock.$1.dom" url host n min ver ok=0 f="$(_ab_file "$id")" || return 1 min="$(_ab_min "$id")" mkdir -p "$AB_DIR/src" for url in "$AB_URL_GH/$f" "$AB_URL_CDN/$f"; do host="$(printf '%s' "$url" | sed 's#^https://\([^/]*\).*#\1#')" echo " → Скачиваем $(_ab_title "$id") ($host)" rm -f "$tmp" if curl -fsSL --connect-timeout 10 --max-time 240 --retry 2 -o "$tmp" "$url" >/dev/null 2>&1 && [ -s "$tmp" ]; then awk -F/ '/^(local|address|server)=\/[^\/]+\// { d = tolower($2); if (d ~ /^[a-z0-9_.-]+$/ && index(d, ".")) print d }' "$tmp" > "$dom" n="$(wc -l < "$dom" | tr -d ' ')" if [ "${n:-0}" -ge "$min" ]; then ok=1; break; fi echo " ! Получен неполный файл ($(_ab_dom "${n:-0}")) — пробуем другой адрес" else echo " ! Не скачалось" fi done if [ "$ok" != 1 ]; then rm -f "$tmp" "$dom"; return 1; fi ver="$(sed -n 's/^# Version: *//p' "$tmp" | head -n1 | tr -cd '0-9.')" rm -f "$tmp" if _ab_gz; then gzip -c "$dom" > "$AB_DIR/src/$id.gz.zm-new" && mv -f "$AB_DIR/src/$id.gz.zm-new" "$AB_DIR/src/$id.gz" && rm -f "$AB_DIR/src/$id.txt" else mv -f "$dom" "$AB_DIR/src/$id.txt" && rm -f "$AB_DIR/src/$id.gz" fi rm -f "$dom" "$AB_DIR/src/$id."*.zm-new _ab_put "n_$id" "$n" "v_$id" "$ver" "t_$id" "$(date +%s)" echo " ✓ $(_ab_title "$id"): $(_ab_dom "$n")${ver:+, версия $ver}" return 0 } _ab_build() { local out="$1" ids id allow="$JOBS_DIR/adblock.allow" blk="$JOBS_DIR/adblock.block" dedup=0 n ids="$(_ab_ids)" for id in $ids; do _ab_src "$id" >/dev/null || { echo "нет скачанного списка $(_ab_title "$id")" >&2; return 1; }; done set -- $ids; [ $# -gt 1 ] && dedup=1 { _ab_allow_text | _ab_allow_norm; _ab_own_hosts | _ab_allow_norm; } | awk '!s[$0]++' > "$allow" _ab_block_text | _ab_allow_norm > "$blk" _ab_dns_now > "$AB_DNS_STAMP" 2>/dev/null { for id in $ids; do _ab_cat "$id"; done; } | awk -v af="$allow" -v bf="$blk" -v dedup="$dedup" -v cf="$out.count" ' BEGIN { while ((getline l < af) > 0) if (l != "") A[l] = 1; close(af) while ((getline l < bf) > 0) if (l != "" && !(l in K)) { K[l] = 1; print "local=/" l "/"; n++ } close(bf) } { d = $0 if (d == "" || (d in K)) next if (dedup) { if (d in S) next; S[d] = 1 } x = d while (1) { if (x in A) next i = index(x, "."); if (!i) break x = substr(x, i + 1) } print "local=/" d "/"; n++ } END { for (a in A) if (!(a in K)) print "server=/" a "/#"; print n + 0 > cf }' > "$out" || { rm -f "$allow" "$blk" "$out.count"; return 1; } rm -f "$allow" "$blk" n="$(cat "$out.count" 2>/dev/null)"; rm -f "$out.count" [ "${n:-0}" -gt 0 ] || { echo "список получился пустым" >&2; return 1; } echo "$n" } _ab_install() { local new="$1" d dirs bak="$JOBS_DIR/adblock.old" t=0 had=0 dirs="$(_ab_dirs)" if ! dnsmasq --test --conf-file="$new" >/dev/null 2>&1; then echo "ОШИБКА: dnsmasq не принял собранный список — ничего не меняем" return 1 fi rm -f "$bak" for d in $dirs; do [ -s "$d/$AB_NAME" ] && { cp -f "$d/$AB_NAME" "$bak" 2>/dev/null && had=1; break; }; done for d in $dirs; do mkdir -p "$d" && cp -f "$new" "$d/$AB_NAME.zm-tmp" && mv -f "$d/$AB_NAME.zm-tmp" "$d/$AB_NAME" || { echo "ОШИБКА: не хватило места в памяти роутера для списка — возвращаем прежнее состояние" rm -f "$d/$AB_NAME.zm-tmp" for d in $dirs; do if [ "$had" = 1 ] && [ -s "$bak" ]; then cp -f "$bak" "$d/$AB_NAME" 2>/dev/null || rm -f "$d/$AB_NAME" else rm -f "$d/$AB_NAME"; fi done rm -f "$bak" /etc/init.d/dnsmasq restart >/dev/null 2>&1 return 1 } _ab_stats_on && _ab_stats_text > "$d/$AB_STATS_CONF" done /etc/init.d/dnsmasq restart >/dev/null 2>&1 while [ "$t" -lt 30 ]; do sleep 2; t=$((t + 2)) _ab_dns_alive && { rm -f "$bak"; return 0; } done echo "!! dnsmasq не отвечает с новым списком — возвращаем прежнее состояние" for d in $dirs; do if [ "$had" = 1 ]; then cp -f "$bak" "$d/$AB_NAME"; else rm -f "$d/$AB_NAME"; fi done rm -f "$bak" /etc/init.d/dnsmasq restart >/dev/null 2>&1 return 1 } _ab_alldirs() { { _ab_dirs; echo /tmp/dnsmasq.d; ls -d /tmp/dnsmasq.*.d 2>/dev/null; uci -q show dhcp 2>/dev/null | sed -n "s/^dhcp\.[^.]*\.confdir='\(.*\)'\$/\1/p"; } | sort -u; } _ab_unplug() { local d for d in $(_ab_alldirs); do [ -d "$d" ] || continue rm -f "$d/$AB_NAME" "$d/$AB_NAME.zm-tmp" done } _ab_stats_on() { [ "$(_ab_get stats 0)" = 1 ]; } _ab_stats_text() { printf '%s\n' "# Zapret Manager: статистика блокировки рекламы" "log-queries=extra" "log-async=25" "log-facility=$AB_STATS_LOG"; } _ab_stats_plug() { local d for d in $(_ab_dirs); do mkdir -p "$d" && _ab_stats_text > "${d:?}/$AB_STATS_CONF"; done } _ab_stats_unplug() { local d for d in $(_ab_alldirs); do [ -d "$d" ] && rm -f "${d:?}/$AB_STATS_CONF"; done } _ab_stats_wipe() { rm -rf "$AB_STATS_DIR" rm -f "$AB_STATS_LOG" "$AB_STATS_ALL" "$AB_STATS_ALL.zm" "$AB_STATS_ALL.c" "$AB_STATS_ALL.new" return 0 } _ab_stats_applied() { local d n=0 for d in $(_ab_dirs); do [ -s "$d/$AB_STATS_CONF" ] || return 1; n=1; done [ "$n" = 1 ] } _ab_stats_cron() { local line="*/2 * * * * /opt/zapret-manager-luci/backend.sh adblock_stats_tick >/dev/null 2>&1 $AB_STATS_CRON" if [ "$1" = on ]; then grep -qxF "$line" "$CRON_FILE" 2>/dev/null && return 0 mkdir -p "$(dirname "$CRON_FILE")"; touch "$CRON_FILE" grep -vF "$AB_STATS_CRON" "$CRON_FILE" > "$CRON_FILE.zm" 2>/dev/null; cat "$CRON_FILE.zm" > "$CRON_FILE"; rm -f "$CRON_FILE.zm" echo "$line" >> "$CRON_FILE" else grep -qF "$AB_STATS_CRON" "$CRON_FILE" 2>/dev/null || return 0 grep -vF "$AB_STATS_CRON" "$CRON_FILE" > "$CRON_FILE.zm" 2>/dev/null; cat "$CRON_FILE.zm" > "$CRON_FILE"; rm -f "$CRON_FILE.zm" fi /etc/init.d/cron restart >/dev/null 2>&1 return 0 } adblock_stats_tick() { local lk="$AB_STATS_DIR/.lock" tmp hf hour lan t=0 _ab_stats_on || return 0 mkdir -p "$AB_STATS_DIR" while ! mkdir "$lk" 2>/dev/null; do [ -n "$(find "$lk" -maxdepth 0 -mmin +2 2>/dev/null)" ] && { rmdir "$lk" 2>/dev/null; continue; } t=$((t + 1)); [ "$t" -gt 10 ] && return 0 sleep 1 done tmp="$AB_STATS_DIR/chunk.$$" if [ -s "$AB_STATS_LOG" ]; then cat "$AB_STATS_LOG" > "$tmp" 2>/dev/null && : > "$AB_STATS_LOG" hour=$(( $(date +%s) / 3600 )); hf="$AB_STATS_DIR/h.$hour" lan="$(sed -n 's#^local=/\([^/]*\)/$#\1#p' /var/etc/dnsmasq.conf.* 2>/dev/null | sort -u | tr '\n' ' ')" awk -v lan="$lan" -v hf="$hf" -v other="$tmp.other" ' BEGIN { nl = split(lan, L, " ") while ((getline l < hf) > 0) { split(l, a, " ") if (a[1] == "T") T += a[2]; else if (a[1] == "B") B += a[2] else if (a[1] == "C") C[a[2]] += a[3]; else if (a[1] == "X") X[a[2]] += a[3]; else if (a[1] == "D") D[a[2]] += a[3] } close(hf) } function local_name(n, i) { if (index(n, ".") == 0) return 1 for (i = 1; i <= nl; i++) if (n == L[i] || substr(n, length(n) - length(L[i])) == "." L[i]) return 1 return 0 } match($0, /dnsmasq\[[0-9]+\]: [0-9]+ /) { m = split(substr($0, RSTART + RLENGTH), f, " ") i = 1; cl = "" if (f[1] ~ /\/[0-9]+$/) { cl = f[1]; sub(/\/[0-9]+$/, "", cl); i = 2 } if (f[i] ~ /^query\[/) { T++; if (cl != "") C[cl]++; next } if (f[i] == "config" && f[i + 2] == "is" && f[i + 3] ~ /^(NXDOMAIN|NODATA)/) { n = tolower(f[i + 1]) if (local_name(n)) next B++; D[n]++; if (cl != "") X[cl]++ } next } NF { print > other } END { print "T " T + 0; print "B " B + 0 for (k in C) print "C " k " " C[k] for (k in X) print "X " k " " X[k] for (k in D) print "D " k " " D[k] }' "$tmp" > "$hf.new" 2>/dev/null if [ -s "$hf.new" ]; then { grep -v '^D ' "$hf.new"; grep '^D ' "$hf.new" | sort -k3,3nr | head -n 500; } > "$hf.tmp" && mv -f "$hf.tmp" "$hf" fi rm -f "$hf.new" "$hf.tmp" if [ -s "$tmp.other" ]; then head -n 300 "$tmp.other" | sed 's/^[A-Z][a-z][a-z] [ 0-9]*[0-9] [0-9:]* //' | while IFS= read -r t; do case "$t" in *"]: "*) logger -t "${t%%\[*}" -- "${t#*]: }" ;; *) logger -t dnsmasq -- "$t" ;; esac done fi rm -f "$tmp" "$tmp.other" fi _ab_stats_fold hour=$(( $(date +%s) / 3600 - 25 )) for hf in "$AB_STATS_DIR"/h.*; do [ -f "$hf" ] || continue t="${hf##*.}" case "$t" in ''|*[!0-9]*) rm -f "$hf"; continue ;; esac [ "$t" -lt "$hour" ] && rm -f "$hf" done if [ -s "$AB_STATS_DIR/folded" ]; then while read -r t; do [ -f "$AB_STATS_DIR/h.$t" ] && echo "$t"; done < "$AB_STATS_DIR/folded" > "$AB_STATS_DIR/folded.zm" mv -f "$AB_STATS_DIR/folded.zm" "$AB_STATS_DIR/folded" fi rmdir "$lk" 2>/dev/null return 0 } _ab_stats_fold() { local cur f h todo="" cur=$(( $(date +%s) / 3600 )) for f in "$AB_STATS_DIR"/h.*; do [ -f "$f" ] || continue h="${f##*.}"; case "$h" in ''|*[!0-9]*) continue ;; esac [ "$h" = "$cur" ] && continue grep -qx "$h" "$AB_STATS_DIR/folded" 2>/dev/null && continue todo="$todo $h" done [ -n "$todo" ] || return 0 mkdir -p "$(dirname "$AB_STATS_ALL")" _ab_devmap > "$AB_STATS_DIR/devmap.fold" ( cd "$AB_STATS_DIR" && awk -v s0="$(( $(for h in $todo; do echo "$h"; done | sort -n | head -n1) * 3600 ))" -v mf="$AB_STATS_DIR/devmap.fold" "$AB_KEY_AWK"' BEGIN { abmap(mf) } $1 == "S" { S = $2 } $1 == "T" { T += $2 } $1 == "B" { B += $2 } $1 == "D" { D[$2] += $3 } $1 == "W" { WA[$2] = ($3 == "-" ? "" : $3); WN[$2] = ($4 == "-" ? "" : $4) } $1 == "C" || $1 == "X" { k = abkey($2) if ($1 == "C") C[k] += $3; else X[k] += $3 if ($2 != "r" && $2 !~ /^[mn]:/ && (!(k in IP) || (IP[k] ~ /:/ && $2 ~ /\./))) IP[k] = $2 } END { print "S " (S ? S : s0); print "T " T + 0; print "B " B + 0 for (k in C) print "C " k " " C[k] for (k in X) print "X " k " " X[k] for (k in D) print "D " k " " D[k] for (k in C) { if (k !~ /^[mn]:/) continue m = k ~ /^m:/ ? substr(k, 3) : "" nm = m != "" ? N[m] : substr(k, 3); if (nm == "") nm = WN[k] ad = m != "" && (m in F4) ? F4[m] : (k in IP) ? IP[k] : WA[k] gsub(/[ \t]/, "_", nm) if (nm != "" || ad != "") print "W " k " " (ad == "" ? "-" : ad) " " (nm == "" ? "-" : nm) } }' $([ -s "$AB_STATS_ALL" ] && echo "$AB_STATS_ALL") $(for h in $todo; do echo "h.$h"; done) ) > "$AB_STATS_ALL.zm" 2>/dev/null || { rm -f "$AB_STATS_ALL.zm"; return 0; } { grep -E '^(S|T|B) ' "$AB_STATS_ALL.zm"; grep '^C ' "$AB_STATS_ALL.zm" | sort -k3,3nr | head -n 256 > "$AB_STATS_ALL.c"; cat "$AB_STATS_ALL.c" awk 'NR == FNR { k[$2] = 1; next } ($1 == "X" || $1 == "W") && ($2 in k)' "$AB_STATS_ALL.c" "$AB_STATS_ALL.zm" grep '^D ' "$AB_STATS_ALL.zm" | sort -k3,3nr | head -n 1000; } > "$AB_STATS_ALL.new" rm -f "$AB_STATS_ALL.zm" "$AB_STATS_ALL.c" "$AB_STATS_DIR/devmap.fold" if [ -s "$AB_STATS_ALL.new" ]; then mv -f "$AB_STATS_ALL.new" "$AB_STATS_ALL"; else rm -f "$AB_STATS_ALL.new"; return 0; fi for h in $todo; do echo "$h"; done >> "$AB_STATS_DIR/folded" } _ab_stats_json() { local per="${1:-24}" from f h list="" since s1 via case "$per" in 1|24|all) ;; *) per=24 ;; esac since="$(_ab_get stats_since 0)" if [ "$per" = all ]; then [ -s "$AB_STATS_ALL" ] && list="$AB_STATS_ALL" s1="$(sed -n 's/^S \([0-9]*\)$/\1/p' "$AB_STATS_ALL" 2>/dev/null | head -n1)" for f in "$AB_STATS_DIR"/h.*; do [ -f "$f" ] || continue h="${f##*.}"; case "$h" in ''|*[!0-9]*) continue ;; esac grep -qx "$h" "$AB_STATS_DIR/folded" 2>/dev/null && continue list="$list $f" [ -n "$s1" ] || s1=$((h * 3600)) done [ -n "$s1" ] && [ "$s1" -gt "${since:-0}" ] 2>/dev/null && since="$s1" from=0 else from=$(( $(date +%s) / 3600 - per + 1 )) for f in "$AB_STATS_DIR"/h.*; do [ -f "$f" ] || continue h="${f##*.}"; case "$h" in ''|*[!0-9]*) continue ;; esac [ "$h" -ge "$from" ] && list="$list $f" done fi mkdir -p "$AB_STATS_DIR" _ab_devmap > "$AB_STATS_DIR/devmap.$$" via="" stl_running 2>/dev/null && via="Steer" awk -v per="$([ "$per" = all ] && echo 0 || echo "$per")" -v since="${since:-0}" -v mf="$AB_STATS_DIR/devmap.$$" -v via="$via" "$AB_KEY_AWK"' function js(v) { gsub(/[\\"]/, "", v); gsub(/[\001-\037]/, "", v); return v } BEGIN { abmap(mf) } $1 == "T" { T += $2 } $1 == "B" { B += $2 } $1 == "D" { D[$2] += $3 } $1 == "W" { WA[$2] = ($3 == "-" ? "" : $3); WN[$2] = ($4 == "-" ? "" : $4) } $1 == "C" || $1 == "X" { k = abkey($2) if ($1 == "C") C[k] += $3; else X[k] += $3 if ($2 != "r" && $2 !~ /^[mn]:/ && (!(k in IP) || (IP[k] ~ /:/ && $2 ~ /\./))) IP[k] = $2 } END { rq = C["r"] + 0; relay = rq > 0 && rq * 2 >= T if (!relay) { T -= rq; B -= X["r"]; if (T < 0) T = 0; if (B < 0) B = 0; delete C["r"]; delete X["r"] } for (k in C) { m = k ~ /^m:/ ? substr(k, 3) : "" NAME[k] = m != "" ? N[m] : k ~ /^n:/ ? substr(k, 3) : "" if (NAME[k] == "" && (k in WN)) NAME[k] = WN[k] ADDR[k] = m != "" && (m in F4) ? F4[m] : (k in IP) ? IP[k] : k ~ /^[mn]:/ ? "" : k if (ADDR[k] == "" && (k in WA)) ADDR[k] = WA[k] } for (k in C) if (k ~ /^m:/ && NAME[k] != "") NM[NAME[k]] = k nn = 0; for (k in C) if (k ~ /^n:/ && (NAME[k] in NM)) L[++nn] = k for (i = 1; i <= nn; i++) { k = L[i]; t = NM[NAME[k]]; C[t] += C[k]; X[t] += X[k]; if (ADDR[t] == "") ADDR[t] = ADDR[k]; delete C[k] } if (relay) { NAME["r"] = ""; ADDR["r"] = "" } nc = 0; for (k in C) if (k != "r") nc++ printf "{\"on\":true,\"period\":%d,\"since\":%d,\"total\":%d,\"blocked\":%d,\"clients\":%d,\"relay\":%s,\"via\":\"%s\",\"top\":[", per, since, T, B, nc, relay ? "true" : "false", js(via) for (j = 0; j < 10; j++) { best = ""; bn = 0; for (k in D) if (D[k] > bn) { bn = D[k]; best = k } if (best == "") break; printf "%s{\"d\":\"%s\",\"n\":%d}", (j ? "," : ""), js(best), bn; delete D[best] } printf "],\"devs\":[" for (j = 0; j < 6; j++) { best = ""; bn = 0; for (k in C) if (C[k] > bn) { bn = C[k]; best = k } if (best == "") break; printf "%s{\"ip\":\"%s\",\"name\":\"%s\",\"q\":%d,\"b\":%d%s}", (j ? "," : ""), js(ADDR[best]), js(NAME[best]), bn, X[best] + 0, best == "r" ? ",\"relay\":true" : ""; delete C[best] } printf "]}\n" }' $list /dev/null rm -f "$AB_STATS_DIR/devmap.$$" } _ab_leftover() { local d for d in $(_ab_alldirs); do [ -e "$d/$AB_NAME" ] || [ -e "$d/$AB_STATS_CONF" ] && return 0; done return 1 } _ab_free_macs() { [ -s "$AB_FREE" ] && sed -n 's/^\([0-9A-Fa-f:]\{17\}\).*/\1/p' "$AB_FREE" | tr 'A-F' 'a-f' | sort -u; } _ab_free_alive() { local p; p="$(cat "$AB_FREE_PID" 2>/dev/null)"; [ -n "$p" ] && kill -0 "$p" 2>/dev/null; } _ab_free_conf() { local f f="$(grep -l '^conf-dir=' /var/etc/dnsmasq.conf.* 2>/dev/null | head -n1)" [ -n "$f" ] || f="$(ls /var/etc/dnsmasq.conf.* 2>/dev/null | head -n1)" [ -s "$f" ] || return 1 echo "# Zapret Manager: DNS без блокировки рекламы для выбранных устройств (копия пересылки основного dnsmasq)" echo "port=$AB_FREE_PORT" echo "bind-dynamic" echo "no-hosts" echo "pid-file=$AB_FREE_PID" grep -E '^(server=|no-resolv$|resolv-file=|strict-order$|all-servers$|cache-size=|domain-needed$|bogus-priv$|stop-dns-rebind$|rebind-localhost-ok$|rebind-domain-ok=)' "$f" grep -qE '^(no-resolv$|resolv-file=)' "$f" || { [ -s /tmp/resolv.conf.d/resolv.conf.auto ] && echo "resolv-file=/tmp/resolv.conf.d/resolv.conf.auto"; } grep -q '^cache-size=' "$f" || echo "cache-size=1000" sed -n 's#^local=/\([^/]*\)/$#server=/\1/127.0.0.1#p' "$f" } _ab_free_nft() { local els els="$(printf '%s\n' "$1" | grep . | tr '\n' ',' | sed 's/,$//')" [ -n "$els" ] || return 1 nft -f - << ZM_AB_NFT_EOF table inet $AB_FREE_NFT delete table inet $AB_FREE_NFT table inet $AB_FREE_NFT { set macs { type ether_addr; elements = { $els } } chain pre { type nat hook prerouting priority dstnat - 10; policy accept; ether saddr @macs meta l4proto { tcp, udp } th dport 53 redirect to :$AB_FREE_PORT } } ZM_AB_NFT_EOF } _ab_free_stop() { local p nft delete table inet "$AB_FREE_NFT" >/dev/null 2>&1 p="$(cat "$AB_FREE_PID" 2>/dev/null)" [ -n "$p" ] && kill "$p" 2>/dev/null _kill_match "-C $AB_FREE_CONF" rm -f "$AB_FREE_PID" "$AB_FREE_CONF" "$AB_FREE_CONF.zm-new" "$AB_FREE_CONF.macs" return 0 } _ab_free_sync() { local macs new="$AB_FREE_CONF.zm-new" t=0 macs="$(_ab_free_macs)" if ! _ab_on || [ -z "$macs" ]; then _ab_free_stop; return 0; fi mkdir -p "${AB_FREE_CONF%/*}" _ab_free_conf > "$new" 2>/dev/null || { rm -f "$new"; _ab_free_stop; return 1; } if _ab_free_alive && cmp -s "$new" "$AB_FREE_CONF"; then rm -f "$new"; touch "$AB_FREE_CONF" else _ab_free_alive && kill "$(cat "$AB_FREE_PID" 2>/dev/null)" 2>/dev/null while _ab_free_alive && [ "$t" -lt 5 ]; do sleep 1; t=$((t + 1)); done mv -f "$new" "$AB_FREE_CONF" rm -f "$AB_FREE_PID" dnsmasq -C "$AB_FREE_CONF" >/dev/null 2>&1 t=0 while ! _ab_free_alive && [ "$t" -lt 5 ]; do sleep 1; t=$((t + 1)); done if ! _ab_free_alive; then nft delete table inet "$AB_FREE_NFT" >/dev/null 2>&1 rm -f "$AB_FREE_CONF.macs" logger -t zapret-manager "Блокировка рекламы: DNS для устройств без блокировки не запустился — они пока получают обычный DNS" return 1 fi fi if ! nft list table inet "$AB_FREE_NFT" >/dev/null 2>&1 || [ "$(cat "$AB_FREE_CONF.macs" 2>/dev/null)" != "$macs" ]; then _ab_free_nft "$macs" || return 1 printf '%s\n' "$macs" > "$AB_FREE_CONF.macs" fi return 0 } _ab_free_json() { local run=false _ab_free_alive && nft list table inet "$AB_FREE_NFT" >/dev/null 2>&1 && run=true printf '{"running":%s,"free":[' "$run" [ -s "$AB_FREE" ] && awk -F'|' 'BEGIN { s = "" } $1 ~ /^[0-9a-fA-F:]{17}$/ { gsub(/["\\]/, " ", $2); printf "%s{\"mac\":\"%s\",\"name\":\"%s\"}", s, tolower($1), $2; s = "," }' "$AB_FREE" printf '],"devices":[' [ -s /tmp/dhcp.leases ] && awk 'BEGIN { s = "" } $2 ~ /^[0-9a-fA-F:]{17}$/ { n = $4; if (n == "*") n = ""; gsub(/["\\]/, " ", n); printf "%s{\"mac\":\"%s\",\"ip\":\"%s\",\"name\":\"%s\"}", s, tolower($2), $3, n; s = "," }' /tmp/dhcp.leases printf ']}\n' } _ab_cron_sync() { local m line if _ab_on && [ "$(_ab_get auto 1)" = 1 ]; then m="$(_ab_get minute "")" case "$m" in ''|*[!0-9]*) m="$(awk 'BEGIN { srand(); print int(rand() * 60) }')"; _ab_put minute "$m" ;; esac line="$m 4 * * * /opt/zapret-manager-luci/backend.sh adblock_cron >/dev/null 2>&1 $AB_CRON_TAG" grep -qxF "$line" "$CRON_FILE" 2>/dev/null && return 0 mkdir -p "$(dirname "$CRON_FILE")"; touch "$CRON_FILE" grep -vF "$AB_CRON_TAG" "$CRON_FILE" > "$CRON_FILE.zm" 2>/dev/null; cat "$CRON_FILE.zm" > "$CRON_FILE"; rm -f "$CRON_FILE.zm" echo "$line" >> "$CRON_FILE" /etc/init.d/cron restart >/dev/null 2>&1 else grep -qF "$AB_CRON_TAG" "$CRON_FILE" 2>/dev/null || return 0 grep -vF "$AB_CRON_TAG" "$CRON_FILE" > "$CRON_FILE.zm" 2>/dev/null; cat "$CRON_FILE.zm" > "$CRON_FILE"; rm -f "$CRON_FILE.zm" /etc/init.d/cron restart >/dev/null 2>&1 fi return 0 } _ab_rebuild() { local tmp="$JOBS_DIR/adblock.conf.new" n mkdir -p "$JOBS_DIR" n="$(_ab_build "$tmp" 2>&1)" || { rm -f "$tmp"; echo "${n:-не удалось собрать список}"; return 1; } if ! _ab_install "$tmp" >/dev/null 2>&1; then rm -f "$tmp"; echo "dnsmasq не запустился с этим списком"; return 1; fi rm -f "$tmp" _ab_free_sync >/dev/null 2>&1 _ab_put count "$n" err "" echo "$n" } do_ab_apply() { local mode="$1" ids id f tmp="$JOBS_DIR/adblock.conf.new" n avail age now fails=0 got=0 keep ids="$(_ab_ids)" _ab_say "Блокировка рекламы: $(_ab_title "$(_ab_get list pro)")$([ "$(_ab_get tif 0)" = 1 ] && echo ' + защита от фишинга')$([ "$(_ab_get doh 0)" = 1 ] && echo ' + запрет чужих DoH')" avail="$(awk '/^MemAvailable:/ { print $2 }' /proc/meminfo 2>/dev/null)" if [ -n "$avail" ] && [ "$avail" -lt 60000 ]; then echo "!! Свободной памяти $((avail / 1024)) МБ, продолжаем на свой риск" fi _ab_say "Скачиваем списки" _ab_tg_fetch >/dev/null 2>&1 now="$(date +%s)" for id in $ids; do age=$(( now - $(_ab_get "t_$id" 0) )) if [ "$mode" != force ] && _ab_src "$id" >/dev/null && [ "$age" -ge 0 ] && [ "$age" -lt 43200 ]; then echo " ✓ $(_ab_title "$id"): свежий, скачан $((age / 60)) мин назад" continue fi if _ab_dl "$id"; then got=1 elif _ab_src "$id" >/dev/null; then echo " ! $(_ab_title "$id"): обновить не удалось — оставляем прежнюю копию"; fails=1 else echo "ОШИБКА: $(_ab_title "$id") не скачался ни с GitHub, ни с jsDelivr — проверьте интернет на роутере" _ab_put err "список не скачался" errat "$(date +%s)" return 1 fi done for f in "$AB_DIR"/src/*; do [ -f "$f" ] || continue keep=0; id="$(basename "$f")"; id="${id%.*}" for n in $ids; do [ "$n" = "$id" ] && keep=1; done [ "$keep" = 1 ] || rm -f "$f" done _ab_say "Собираем список для dnsmasq" n="$(_ab_build "$tmp")" || { rm -f "$tmp"; echo "ОШИБКА: не удалось собрать список"; _ab_put err "не удалось собрать список" errat "$(date +%s)"; return 1; } echo " ✓ К блокировке: $(_ab_dom "$n") (белый список учтён)" _ab_say "Подключаем к dnsmasq" if ! _ab_install "$tmp"; then rm -f "$tmp" _ab_put err "dnsmasq не запустился со списком" errat "$(date +%s)" return 1 fi rm -f "$tmp" echo " ✓ dnsmasq перезапущен и отвечает (занимает $(( $(_ab_dns_rss) / 1024 )) МБ памяти)" if [ -n "$(_ab_free_macs)" ]; then if _ab_free_sync; then echo " ✓ Устройства без блокировки ($(_ab_free_macs | wc -l | tr -d ' ')) получают DNS без списка" else echo " ! DNS без блокировки для выбранных устройств не запустился — они пока получают общий"; fi fi _ab_put count "$n" updated "$(date +%s)" err "" _ab_cron_sync _zm_inet_ok || echo "!! Сайты сейчас не открываются с роутера — проверьте интернет; список тут ни при чём, его можно выключить кнопкой «Выключить»" [ "$fails" = 1 ] && echo "!! Часть списков осталась прежней версии — попробуйте «Обновить сейчас» позже" _ab_say "Готово: реклама блокируется для всех устройств в сети" } adblock_status() { local on=false applied=false auto=false tif=false doh=false job=false ram avail have="" sep="" id err errat _ab_on && on=true _ab_applied && applied=true [ "$(_ab_get auto 1)" = 1 ] && auto=true [ "$(_ab_get tif 0)" = 1 ] && tif=true [ "$(_ab_get doh 0)" = 1 ] && doh=true _job_alive adblock && job=true ram="$(awk '/^MemTotal:/ { print $2 }' /proc/meminfo 2>/dev/null)" avail="$(awk '/^MemAvailable:/ { print $2 }' /proc/meminfo 2>/dev/null)" for id in light multi pro proplus ultimate tif doh; do _ab_src "$id" >/dev/null || continue have="$have$sep\"$id\":{\"n\":$(_ab_get "n_$id" 0),\"v\":\"$(esc "$(_ab_get "v_$id" "")")\",\"t\":$(_ab_get "t_$id" 0)}"; sep="," done err="$(_ab_get err "")"; errat="$(_ab_get errat 0)" printf '{"on":%s,"applied":%s,"list":"%s","tif":%s,"doh":%s,"auto":%s,"minute":"%s","count":%s,"updated":%s,"now":%s,"err":"%s","errat":%s,"job":%s,"dns_kb":%s,"ram_kb":%s,"avail_kb":%s,"doh_proxy":%s,"stats":%s,"src":{%s},"allow":"%s","allow_default":"%s","block":"%s"}\n' \ "$on" "$applied" "$(esc "$(_ab_get list pro)")" "$tif" "$doh" "$auto" "$(esc "$(_ab_get minute "")")" \ "$(_ab_get count 0)" "$(_ab_get updated 0)" "$(date +%s)" "$(esc "$err")" "${errat:-0}" "$job" \ "$(_ab_dns_rss)" "${ram:-0}" "${avail:-0}" "$(pidof https-dns-proxy >/dev/null 2>&1 && echo true || echo false)" "$(_ab_stats_on && echo true || echo false)" \ "$have" "$(esc_ml "$(_ab_allow_text)")" "$(esc_ml "$AB_ALLOW_DEFAULT")" "$(esc_ml "$(_ab_block_text)")" } _ab_check() { local q id hit allow q="$(printf '%s\n' "$1" | _ab_allow_norm | head -n1)" [ -n "$q" ] || { echo '{"error":"введите домен, например doubleclick.net"}'; return 1; } if ! _ab_on; then printf '{"domain":"%s","state":"off"}\n' "$(esc "$q")"; return 0; fi hit="$(_ab_block_text | _ab_allow_norm | awk -v q="$q" '{ B[$0] = 1 } END { x = q; while (1) { if (x in B) { print x; exit } i = index(x, "."); if (!i) break; x = substr(x, i + 1) } }')" if [ -n "$hit" ]; then printf '{"domain":"%s","state":"blocked","mine":true,"source":"чёрный список","by":"%s"}\n' "$(esc "$q")" "$(esc "$hit")"; return 0; fi allow="$( { _ab_allow_text | _ab_allow_norm; _ab_own_hosts | _ab_allow_norm; } | awk -v q="$q" ' { A[$0] = 1 } END { x = q; while (1) { if (x in A) { print x; exit } i = index(x, "."); if (!i) break; x = substr(x, i + 1) } }')" for id in $(_ab_ids); do hit="$(_ab_cat "$id" 2>/dev/null | awk -v q="$q" ' BEGIN { x = q; while (1) { C[x] = 1; i = index(x, "."); if (!i) break; x = substr(x, i + 1) } } ($0 in C) { print; exit }')" [ -n "$hit" ] && break done if [ -n "$hit" ] && [ -n "$allow" ]; then printf '{"domain":"%s","state":"allowed","source":"%s","by":"%s","allow":"%s"}\n' "$(esc "$q")" "$(esc "$(_ab_title "$id")")" "$(esc "$hit")" "$(esc "$allow")" elif [ -n "$hit" ]; then printf '{"domain":"%s","state":"blocked","source":"%s","by":"%s"}\n' "$(esc "$q")" "$(esc "$(_ab_title "$id")")" "$(esc "$hit")" else printf '{"domain":"%s","state":"clean"}\n' "$(esc "$q")" fi } adblock_action() { local a="$1" m="$2" j k v kv list tif doh auto n case "$a" in apply|update) _job_alive adblock && { echo '{"error":"список уже обновляется — дождитесь окончания"}'; return 1; } if j="$(_zm_busy_job)"; then printf '{"error":"%s"}\n' "$(esc "Сейчас идёт операция: $j — дождитесь её окончания")"; return 1; fi if [ "$a" = apply ]; then list="$(_ab_get list pro)"; tif="$(_ab_get tif 0)"; doh="$(_ab_get doh 0)"; auto="$(_ab_get auto 1)" for kv in $m; do k="${kv%%=*}"; v="${kv#*=}" case "$k" in list) _ab_file "$v" >/dev/null && case "$v" in tif|doh) ;; *) list="$v" ;; esac ;; tif) [ "$v" = 1 ] && tif=1 || tif=0 ;; doh) [ "$v" = 1 ] && doh=1 || doh=0 ;; auto) [ "$v" = 1 ] && auto=1 || auto=0 ;; esac done _ab_put on 1 list "$list" tif "$tif" doh "$doh" auto "$auto" job_start adblock do_ab_apply else _ab_on || { echo '{"error":"блокировка выключена"}'; return 1; } job_start adblock do_ab_apply force fi ;; off) _job_alive adblock && { echo '{"error":"дождитесь окончания операции"}'; return 1; } _ab_put on 0 err "" stats 0 _ab_free_stop _ab_unplug _ab_stats_unplug _ab_stats_cron off _ab_stats_wipe rm -rf "$AB_DIR/src" rm -f "$AB_TG_LIST" "$AB_DNS_STAMP" "$JOBS_DIR"/adblock.watch "$JOBS_DIR"/adblock.old "$JOBS_DIR"/adblock.allow "$JOBS_DIR"/adblock.conf.new* \ "$JOBS_DIR"/adblock.*.dl "$JOBS_DIR"/adblock.*.dl.dec "$JOBS_DIR"/adblock.*.dom "$JOBS_DIR"/ab.exempt.* [ -f "$AB_SET" ] && sed -i -e '/^[nvt]_[a-z]*=/d' -e '/^count=/d' -e '/^updated=/d' -e '/^errat=/d' "$AB_SET" _ab_cron_sync /etc/init.d/dnsmasq restart >/dev/null 2>&1 sleep 2 if _ab_leftover; then _ab_unplug; /etc/init.d/dnsmasq restart >/dev/null 2>&1; fi printf '{"ok":true}\n' ;; free_get) _ab_free_json ;; stats) if ! _ab_stats_on; then printf '{"on":false}\n'; return 0; fi adblock_stats_tick _ab_stats_json "$m" ;; stats_set) _job_alive adblock && { echo '{"error":"список сейчас обновляется — включите чуть позже"}'; return 1; } if [ "$m" = on ]; then _ab_on && _ab_applied || { echo '{"error":"сначала включите блокировку рекламы"}'; return 1; } _ab_put stats 1 stats_since "$(date +%s)" mkdir -p "$AB_STATS_DIR" _ab_stats_plug _ab_stats_cron on else _ab_put stats 0 _ab_stats_unplug _ab_stats_cron off fi /etc/init.d/dnsmasq restart >/dev/null 2>&1 [ "$m" = on ] || _ab_stats_wipe _ab_free_sync >/dev/null 2>&1 if _ab_stats_on; then _ab_stats_json 24; else printf '{"on":false}\n'; fi ;; free_set) _job_alive adblock && { echo '{"error":"список сейчас обновляется — сохраните чуть позже"}'; return 1; } mkdir -p "$AB_DIR" printf '%s\n' "$m" | tr -d '\r' | awk -F'|' '{ mac = tolower($1); gsub(/-/, ":", mac); if (mac !~ /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/ || s[mac]++) next; n = $2; gsub(/[|"\\\t]/, " ", n); print mac "|" substr(n, 1, 48) }' | head -n 64 > "$AB_FREE.zm-new" if [ -s "$AB_FREE.zm-new" ]; then mv -f "$AB_FREE.zm-new" "$AB_FREE"; else rm -f "$AB_FREE.zm-new" "$AB_FREE"; fi if _ab_free_sync; then _ab_free_json else printf '{"error":"%s"}\n' "$(esc "выбор сохранён, но DNS без блокировки не запустился — устройства пока получают общий DNS")"; return 1; fi ;; auto) [ "$m" = 1 ] && v=1 || v=0 _ab_put auto "$v" _ab_cron_sync printf '{"ok":true,"auto":%s,"minute":"%s"}\n' "$([ "$v" = 1 ] && echo true || echo false)" "$(_ab_get minute "")" ;; exempt) _ab_exempt_json ;; allow_set) _job_alive adblock && { echo '{"error":"список сейчас обновляется — сохраните чуть позже"}'; return 1; } mkdir -p "$AB_DIR" if [ "$m" = default ]; then rm -f "$AB_ALLOW" else printf '%s\n' "$m" | tr -d '\r' | head -c 65536 > "$AB_ALLOW.zm-new" && mv -f "$AB_ALLOW.zm-new" "$AB_ALLOW"; fi n="" if _ab_on && _ab_applied; then n="$(_ab_rebuild)" || { printf '{"error":"%s"}\n' "$(esc "белый список сохранён, но применить не удалось: $n")"; return 1; } fi printf '{"ok":true,"count":%s,"entries":%s}\n' "${n:-0}" "$(_ab_allow_text | _ab_allow_norm | wc -l | tr -d ' ')" ;; block_set) _job_alive adblock && { echo '{"error":"список сейчас обновляется — сохраните чуть позже"}'; return 1; } mkdir -p "$AB_DIR" printf '%s\n' "$m" | tr -d '\r' | head -c 65536 > "$AB_BLOCK.zm-new" if grep -q '[^[:space:]]' "$AB_BLOCK.zm-new"; then mv -f "$AB_BLOCK.zm-new" "$AB_BLOCK"; else rm -f "$AB_BLOCK.zm-new" "$AB_BLOCK"; fi n="" if _ab_on && _ab_applied; then n="$(_ab_rebuild)" || { printf '{"error":"%s"}\n' "$(esc "чёрный список сохранён, но применить не удалось: $n")"; return 1; } fi printf '{"ok":true,"count":%s,"entries":%s}\n' "${n:-0}" "$(_ab_block_text | _ab_allow_norm | wc -l | tr -d ' ')" ;; check) _ab_check "$m" ;; *) echo '{"error":"неизвестное действие"}'; return 1 ;; esac } adblock_cron() { _ab_on && [ "$(_ab_get auto 1)" = 1 ] || { _ab_cron_sync; return 0; } _job_alive adblock && return 0 _zm_busy_job >/dev/null && return 0 job_start adblock do_ab_apply force >/dev/null } _ab_watch() { local stamp="$JOBS_DIR/adblock.watch" r if ! _ab_on; then { _ab_free_alive || nft list table inet "$AB_FREE_NFT" >/dev/null 2>&1; } && _ab_free_stop return 0 fi _job_alive adblock || _ab_free_sync >/dev/null 2>&1 if _ab_stats_on && _ab_applied && ! _ab_stats_applied; then _ab_stats_plug; _ab_stats_cron on /etc/init.d/dnsmasq restart >/dev/null 2>&1 fi _ab_applied && return 0 _job_alive adblock && return 0 [ -n "$(find "$stamp" -mmin -10 2>/dev/null)" ] && return 0 touch "$stamp" if r="$(_ab_rebuild)"; then logger -t zapret-manager "Блокировка рекламы: список снова подключён к dnsmasq ($(_ab_dom "$r"))" return 0 fi _zm_busy_job >/dev/null && return 0 logger -t zapret-manager "Блокировка рекламы: нет готового списка ($r) — скачиваем заново" job_start adblock do_ab_apply >/dev/null } ZM_MULTI_OK=' status system_info health versions zm_update_status lan_ip bytetube_installed ' _zm_multi_ok() { case "$1" in *[!a-z0-9_]*|'') return 1 ;; multi) return 1 ;; *_status|*_get|strategy_list_*|mixomo_proxies) return 0 ;; esac case "$ZM_MULTI_OK" in *" $1 "*) return 0 ;; esac return 1 } _zm_multi() { local d="$JOBS_DIR/multi.$$" i=0 n line m a f sep="" mkdir -p "$d" || { echo '{"error":"нет места в /tmp"}'; return 1; } while IFS= read -r line; do [ -n "$line" ] || continue [ "$i" -ge 16 ] && break m="${line%% *}"; a="" case "$line" in *" "*) a="${line#* }" ;; esac if _zm_multi_ok "$m" && { [ -z "$a" ] || _zm_re "$a" '^[A-Za-z0-9_.:@-]{1,64}$'; }; then if [ -n "$a" ]; then ( cmd="$m"; _zm_run_cmd "$a" ) < /dev/null > "$d/$i" 2>/dev/null & else ( cmd="$m"; _zm_run_cmd ) < /dev/null > "$d/$i" 2>/dev/null & fi else : > "$d/$i" fi i=$((i + 1)) done << ZM_MULTI_EOF $1 ZM_MULTI_EOF wait printf '{"r":[' n=0 while [ "$n" -lt "$i" ]; do f="$d/$n" if [ -s "$f" ] && [ "$(head -c 1 "$f")" = "{" ] && case "$(tail -c 8 "$f" | tr -d '\n\r\t ')" in *"}") true ;; *) false ;; esac; then printf '%s' "$sep"; cat "$f" else printf '%s{"__fail":true}' "$sep" fi sep="," n=$((n + 1)) done printf ']}\n' rm -rf "$d" } cmd="$1"; shift _zm_in() { ZM_IN="$(cat; echo .)" if [ "$ZM_IN" = "@zmupload." ] && [ "$cmd" != upload_part ] && [ -f "$ZM_UP_FILE" ]; then ZM_IN="$(cat "$ZM_UP_FILE"; echo .)" rm -f "$ZM_UP_FILE" fi } if [ "$2" = @stdin ]; then _zm_in; set -- "$1" "${ZM_IN%.}" elif [ "$1" = @stdin ]; then _zm_in; 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; } [ "$1" = auto_run ] && [ -f "$CONF" ] && { _nochange_guard || exit 1; } ;; zapret_opt_set) case "$1" in rkn:*|wssize:*|ipid:*) [ -f "$CONF" ] && { _nochange_guard || exit 1; } ;; esac ;; esac 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|zapret_opt_set) _zc_lift ;; esac _zm_run_cmd() { case "$cmd" in status) status ;; system_info) system_info ;; job_status) job_status "$1" ;; log_tail) log_tail "$1" ;; zapret_action) zapret_action "$1" ;; 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 "$1" ;; purge_scan) purge_scan ;; purge_run) purge_run "$1" ;; backup_list) backup_list ;; backup_action) backup_action "$1" "$2" ;; zm_cleanup) zm_cleanup "$1" ;; zm_refs_heal) zm_refs_heal "$1" ;; 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" ;; upload_part) upload_part "$1" "$2" ;; doh_install) doh_install ;; doh_remove) doh_remove ;; doh_status) doh_status ;; doh_set) doh_set "$1" ;; doh_probe) doh_probe "$1" ;; doh_check) doh_check ;; doh_bootstrap_set) doh_bootstrap_set "$1" ;; doh_force_set) doh_force_set "$1" ;; doh_failover_set) doh_failover_set "$1" ;; doh_own_set) doh_own_set "$1" ;; doh_watch) doh_watch ;; dns_net_set) dns_net_set "$1" ;; dns_net_gone) dns_net_gone ;; test_status) test_status ;; test_action) test_action "$1" "$2" ;; test_results) test_results "$1" ;; zapret_opt_set) zapret_opt_set "$1" ;; system_time_set) system_time_set "$1" ;; zm_update_status) zm_update_status ;; zm_update_action) zm_update_action ;; zm_changelog) case "$1" in local|remote|all) zm_changelog "$1" ;; *) echo '{"error":"неизвестный режим"}' ;; esac ;; zm_changelog_seen) zm_changelog seen ;; zm_changelog_sync) _zm_cl_sync ;; 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" ;; versions_mode) versions_mode_set "$1" ;; versions_cron) versions_cron ;; versions_cron_sync) _ver_cron_sync ;; awg_status) awg_status ;; awg_action) awg_action "$1" "$2" ;; term_status) term_status ;; term_action) term_action "$1" "$2" ;; tsc_status) tsc_status ;; tsc_action) tsc_action "$1" "$2" ;; tsc_watch) tsc_watch ;; tsc_lan_ip) _zm_lan_ip ;; monitor) monitor_snapshot ;; monitor_procs) monitor_procs ;; monitor_action) monitor_action "$1" "$2" ;; htop_gone) htop_gone ;; watch_status) watch_status ;; watch_set) watch_set "$1" ;; adblock_status) adblock_status ;; adblock_action) adblock_action "$1" "$2" ;; adblock_cron) adblock_cron ;; adblock_stats_tick) adblock_stats_tick ;; steer_status) steer_status ;; steer_action) steer_action "$1" "$2" ;; bytetube_action) bytetube_action "$1" ;; bytetube_sync) bytetube_sync ;; forkop_status) forkop_status ;; forkop_config_get) forkop_config_get ;; forkop_config_set) forkop_config_set "$1" ;; forkop_device_setup) _fk_device_setup "$1" ;; forkop_priority_watch) _fk_fallback_sec "$1" ;; forkop_ping_burst) _fk_ping_burst "$1" ;; forkop_action) forkop_action "$1" "$2" ;; lan_ip) _zm_lan_ip ;; jobs_cancel) jobs_cancel "$1" ;; system_reboot) system_reboot ;; reboot_hint_clear) reboot_hint_clear ;; zm_watch) zm_watch ;; ui_theme_set) ui_theme_set "$1" ;; ui_nav_set) ui_nav_set "$1" ;; ui_flags_get) ui_flags_get ;; ui_flag_set) ui_flag_set "$1" ;; adblock_dns_sync) _ab_dns_sync ;; multi) _zm_multi "$1" ;; *) echo '{"error":"неизвестная команда"}'; exit 1 ;; esac } _zm_run_cmd "$@" zm_rc=$? [ -n "$ZC_LIFTED" ] && _zc_drop exit $zm_rc ZM_INSTALLER_EOF sed -i "s/@ZM_VERSION@/$ZM_NEW_VER/" '/opt/zapret-manager-luci/backend.sh.zm-new' 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, itdoginfo_meta: true }; const PKG_LOCAL_DIR = "/usr/share/zm-redbtn/svc/"; const V2F_RAW = "https://raw.githubusercontent.com/v2fly/domain-list-community/master/data"; const PKG_LISTS = { "svc_v2meta.lst": V2F_RAW + "/meta", "svc_threads.lst": V2F_RAW + "/threads", "svc_oculus.lst": V2F_RAW + "/oculus", "svc_instagram.lst": V2F_RAW + "/instagram", "svc_instagram_extra.lst": PKG_LOCAL_DIR + "instagram_extra.lst", "svc_facebook.lst": V2F_RAW + "/facebook", "svc_messenger.lst": V2F_RAW + "/messenger", "svc_whatsapp.lst": V2F_RAW + "/whatsapp", "svc_whatsapp_extra.lst": PKG_LOCAL_DIR + "whatsapp_extra.lst" }; const SCHEMES = /^(vless|vmess|trojan|ss|socks4|socks4a|socks5|hysteria2|hy2):\/\/[^ \t\r\n]+$/i; const SCHEMES_NAIVE = /^naive\+(https|quic):\/\/[^ \t\r\n@]+@[^ \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 pkg_local(v) { v = s(v); return index(v, PKG_LOCAL_DIR) == 0 && !!match(v, /^\/[A-Za-z0-9_\/.-]+\.lst$/) && index(v, "..") < 0; } function ml(v) { return type(v) == "string" && (index(v, "\n") >= 0 || index(v, "\r") >= 0); } 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; } 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 if (s(f[7]) == "") { for (let fl in [ ...split(s(f[2]), ","), ...split(s(f[3]), ",") ]) if (PKG_LISTS[fl]) push(t, "l:" + PKG_LISTS[fl]); } 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]) }; } let covered = {}; for (let it in items) for (let tk in it.targets) covered[tk] = true; 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 = [], cat = false; 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 (idx[k].kind == "s") cat = true; } if (!length(t)) continue; let itd = index(id, "c_itdoginfo_") == 0 || index(lc(grp), "itdoginfo") == 0; if (itd) { let own = true; for (let tk in t) if (!covered[tk]) own = false; if (own) continue; } seen[id] = true; push(items, { id, name: s(f[1]) || id, group: itd ? "" : grp, cat: itd && cat, 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; } } 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[2] + (m[4] ? ":" + m[4] : "") : ""; } 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 ]); } 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 own_off(m) { return s(m.zm_own_off) == "1"; } function own_view(m, typed) { if (!own_off(m)) return { on: true, domains: domain_lines(m), subnets: subnet_lines(m), lists: typed }; return { on: false, domains: uniq(tokens(m.zm_own_domains)), subnets: uniq(tokens(m.zm_own_subnets)), lists: uniq([ ...typed, ...rawlist(m.zm_own_lists) ]) }; } function device_ips(m) { return uniq(words(m.zm_rule_source_ips)); } function all_ips(m) { return uniq(words(m.fully_routed_ips)); } 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)), own_off: own_off(m), full: device_ips(m), all: all_ips(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 legacy_bypass_info(c) { let r = []; c.foreach(CFG, "section", (m) => { if (m[".name"] == BYPASS || s(m.connection_type) != "exclusion") return; push(r, { name: m[".name"], label: sec_label(m, false), enabled: s(m.disabled) != "1", domains: domain_lines(m), subnets: subnet_lines(m), community: rawlist(m.community_lists), lists: uniq([ ...rawlist(m.local_domain_lists), ...rawlist(m.local_subnet_lists), ...rawlist(m.remote_domain_lists), ...rawlist(m.remote_subnet_lists) ]) }); }); return r; } 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, "."), (v) => int(v)), y = map(split(b.ip, "."), (v) => int(v)); 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 valid_ip(v) { if (ml(v)) return false; 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 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 device_ip_overlap(a, b) { return cidr_hit(split(a, "/")[0], [ b ]) || cidr_hit(split(b, "/")[0], [ a ]); } function device_scopes_overlap(a, b) { if (!length(a) || !length(b)) return true; for (let x in a) for (let y in b) if (device_ip_overlap(x, y)) return true; return false; } function dns_value_ok(t, v) { if (v == "" || ml(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 index(v, "/") >= 0 ? "https://" + v : "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; } let PFX = null; function prefix_ok() { if (PFX == null) PFX = index(s(fs.readfile(NS_LIB + "/helpers.sh")), "domain_rule_normalize() {") >= 0; return PFX; } function norm_domain(v) { v = trim(s(v)); if (prefix_ok()) { let pm = match(v, /^(full|keyword|regexp?):(.*)$/i); if (pm) { let k = lc(pm[1]) == "regexp" ? "regex" : lc(pm[1]); return k + ":" + (k == "regex" ? trim(pm[2]) : replace(lc(trim(pm[2])), /^\*\./, "")); } } v = lc(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), pm = prefix_ok() ? match(v, /^(full|keyword|regex):(.*)$/) : null; if (pm && pm[1] == "keyword") return match(pm[2], /^[a-z0-9._-]+$/) ? null : "«" + raw + "»: после keyword: нужна часть имени — латинские буквы, цифры, точки и дефисы"; if (pm && pm[1] == "regex") return pm[2] != "" && length(pm[2]) <= 256 && !match(pm[2], /[ \t,;]/) ? null : "«" + raw + "»: после regex: нужно регулярное выражение без пробелов, запятых и точек с запятой"; if (pm) v = pm[2]; else if (match(lc(s(raw)), /^(keyword|regex|regexp|domain_keyword|domain_regex):/)) return "«" + raw + "»: " + (prefix_ok() ? "пишите keyword: или regex:" : "keyword: и regex: понимает движок Forkozz 0.9.11 и новее — обновите Forkozz или впишите домен целиком, например 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 !ml(v) && !!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 write_device_lists(c, sec, full, excl, all) { set_list(c, sec, "zm_rule_source_ips", full); set_list(c, sec, "fully_routed_ips", all || []); if (!c.get(CFG, "settings")) c.set(CFG, "settings", "settings"); set_list(c, "settings", "routing_excluded_ips", excl); } function patch_device_engine() { let script = s(fs.readfile(BIN)), marker = "# ZM_SECTION_DEVICE_RULES"; let original = script; let start = index(script, "configure_routing_for_section_lists() {\n"); let end = start >= 0 ? index(substr(script, start), "\n}\n") : -1; if (start < 0 || end < 0) fail("эта версия движка не поддерживает фильтр устройств — настройки не изменены"); let body = substr(script, start, end + 2); let anchor = ' if [ -n "$community_lists" ]; then\n'; if (length(split(body, anchor)) != 2) fail("не удалось найти правило секции в движке — настройки не изменены"); let patch = ' # ZM_SECTION_DEVICE_RULES\n' + ' local zm_rule_source_ips\n' + ' config_get zm_rule_source_ips "$section" "zm_rule_source_ips"\n' + ' if [ -n "$zm_rule_source_ips" ]; then\n' + ' config_list_foreach "$section" "zm_rule_source_ips" include_source_ip_in_routing_handler "$route_rule_tag"\n' + ' fi\n'; let updated = index(body, marker) >= 0 ? body : replace(body, anchor, patch + anchor); if (index(updated, "# ZM_SECTION_DIRECT_FALLBACK") < 0) { let unavailable = ' if subscription_outbound_is_unavailable "$section"; then\n'; let route = ' outbound_tag=$(get_outbound_tag_by_section "$section")\n'; if (length(split(updated, unavailable)) != 2 || length(split(updated, route)) != 2) fail("эта версия движка не поддерживает резервный прямой выход — настройки не изменены"); updated = replace(updated, ' local section="$1"\n', ' local section="$1"\n' + ' # ZM_SECTION_DIRECT_FALLBACK\n' + ' local zm_direct_fallback\n' + ' config_get_bool zm_direct_fallback "$section" "zm_direct_fallback" 0\n'); updated = replace(updated, unavailable, ' if subscription_outbound_is_unavailable "$section" && [ "$zm_direct_fallback" -ne 1 ]; then\n'); updated = replace(updated, route, route + ' [ "$zm_direct_fallback" -eq 1 ] && [ "$section_connection_type" = proxy ] && outbound_tag="$section-zm-fallback-out"\n'); } script = substr(script, 0, start) + updated + substr(script, start + length(body)); if (index(script, "# ZM_SERVICE_EXTRA") < 0) { let hs = index(script, "configure_community_list_handler() {\n"), he = hs >= 0 ? index(substr(script, hs), "\n}\n") : -1; let tail = ' patch_section_dns_rule_set "$ruleset_tag"\n'; if (hs >= 0 && he > 0 && length(split(substr(script, hs, he + 1), tail)) == 2) { let hb = substr(script, hs, he + 1); script = substr(script, 0, hs) + replace(hb, tail, tail + ' # ZM_SERVICE_EXTRA\n' + ' local zm_extra="/usr/share/zm-redbtn/svc/${tag}_extra.lst"\n' + ' if [ -s "$zm_extra" ]; then\n' + ' rm -f "$TMP_RULESET_FOLDER/$(get_ruleset_tag "$section" "zm_$tag" "domains").json"\n' + ' prepare_source_ruleset "$section" "zm_$tag" "domains" "$route_rule_tag" &&\n' + ' import_plain_domain_list_to_local_source_ruleset_chunked "$zm_extra" "$ruleset_filepath"\n' + ' fi\n') + substr(script, hs + he + 1); } } if (index(script, "# ZM_DIRECT_FALLBACK_CONFIG") < 0) { let save = "sing_box_save_config() {\n"; if (length(split(script, save)) != 2) fail("не удалось найти сборку конфига движка — настройки не изменены"); let uc = "/opt/zapret-manager-luci/netshift.uc"; script = replace(script, save, save + ' # ZM_DIRECT_FALLBACK_CONFIG\n' + ' if uci -q show netshift | grep -q "\\.zm_direct_fallback=\'1\'$"; then\n' + ' config="$(printf \'%s\' "$config" | jq -c --argjson feeds "${ZM_PRIORITY_FEEDS_JSON:-null}" \'._zm_priority_feeds = $feeds\' | ucode ' + q(uc) + ' fallback_config)" || {\n' + ' log "Failed to configure section fallback. Aborted." "fatal"\n' + ' exit 1\n' + ' }\n' + ' fi\n'); } let old_guard = ' if uci -q show netshift | grep -q "\\.zm_direct_fallback=\'1\'$"; then\n'; let new_guard = ' if uci -q show netshift | grep -qE "\\.(zm_direct_fallback|zm_sub_priority)=\'1\'$"; then\n'; script = replace(script, old_guard, new_guard); let old_call = ' config="$(printf \'%s\' "$config" | ucode \'/opt/zapret-manager-luci/netshift.uc\' fallback_config)" || {\n'; let new_call = ' config="$(printf \'%s\' "$config" | jq -c --argjson feeds "${ZM_PRIORITY_FEEDS_JSON:-null}" \'._zm_priority_feeds = $feeds\' | ucode \'/opt/zapret-manager-luci/netshift.uc\' fallback_config)" || {\n'; script = replace(script, old_call, new_call); script = replace(script, new_guard, ' if uci -q show netshift | grep -qE "\\.(zm_direct_fallback|zm_sub_priority|zm_auto_ping)=\'1\'$"; then\n'); script = replace(script, ' if uci -q show netshift | grep -qE "\\.(zm_direct_fallback|zm_sub_priority|zm_auto_ping)=\'1\'$"; then\n', ' if uci -q show netshift | grep -qE "\\.(zm_direct_fallback|zm_sub_priority|zm_auto_ping|zm_bt_src)=\'1\'$"; then\n'); if (index(script, "# ZM_SUBSCRIPTION_PRIORITY_FEEDS") < 0) { let feed_anchor = ' subscription_outbound_feeds_json="$SUBSCRIPTION_OUTBOUND_FEEDS_JSON"\n'; let reset_anchor = "sing_box_configure_outbounds() {\n"; if (length(split(script, feed_anchor)) != 2 || length(split(script, reset_anchor)) != 2) fail("эта версия движка не поддерживает порядок подписок — настройки не изменены"); script = replace(script, reset_anchor, reset_anchor + ' ZM_PRIORITY_FEEDS_JSON="{}"\n'); script = replace(script, feed_anchor, feed_anchor + ' # ZM_SUBSCRIPTION_PRIORITY_FEEDS\n' + ' local zm_sub_priority zm_priority_feeds\n' + ' config_get_bool zm_sub_priority "$section" "zm_sub_priority" 0\n' + ' if [ "$zm_sub_priority" -eq 1 ]; then\n' + ' zm_priority_feeds="$(sing_box_build_subscription_feed_groups "$subscription_outbound_tags_json" "${subscription_outbound_feeds_json:-[]}" "${subscription_feed_names_json:-[]}")"\n' + ' ZM_PRIORITY_FEEDS_JSON="$(printf \'%s\' "$ZM_PRIORITY_FEEDS_JSON" | jq -c --arg sec "$section" --argjson feeds "$zm_priority_feeds" \' .[$sec] = $feeds\')" || exit 1\n' + ' fi\n'); } if (script == original) return false; let tmp = BIN + ".zm-devices"; if (fs.writefile(tmp, script) == null) fail("не удалось записать правила секций в движок"); if (system("sh -n " + q(tmp) + " && chmod 0755 " + q(tmp)) != 0) { fs.unlink(tmp); fail("правка движка не прошла проверку синтаксиса — настройки не изменены"); } if (!fs.rename(tmp, BIN)) fail("не удалось заменить файл движка"); return true; } function cmd_device_setup() { out({ ok: true, changed: patch_device_engine() }); } function del_keys(c, sec, keys) { for (let k in keys) c.delete(CFG, sec, k); } 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); let rl = filter(rf.l, (u) => !pkg_local(u)), ll = filter(rf.l, (u) => pkg_local(u)); set_list(c, sec, "remote_domain_lists", uniq([ ...rf.s, ...rf.r, ...rl ])); set_list(c, sec, "remote_subnet_lists", uniq([ ...rf.r, ...rl ])); del_keys(c, sec, [ "local_domain_lists", "local_subnet_lists", "user_domains", "user_subnets" ]); set_list(c, sec, "local_domain_lists", uniq(ll)); 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); } const SUB_FMTS = [ "auto", "xray", "singbox" ]; const SUB_GROUPS = [ "off", "country", "prefix" ]; const UT_IVS = [ "30s", "1m", "3m", "5m" ]; const UT_URL_DEF = "https://www.gstatic.com/generate_204"; const LOG_LEVELS = [ "trace", "debug", "info", "warn", "error", "fatal", "panic" ]; const SUB_MAX = 10; function flag(v) { return s(v) == "1"; } function num_in(v, lo, hi) { v = trim(s(v)); if (ml(v) || !match(v, /^[0-9]{1,6}$/)) return null; v = int(v); return v >= lo && v <= hi ? v : null; } function kw_list(v) { let r = []; for (let x in arr(v)) for (let w in split(s(x), /[|\r\n]+/)) { w = trim(w); if (w != "" && index(r, w) < 0) push(r, w); } return r; } function sec_ext(m) { return { sub_fmt: index(SUB_FMTS, s(m.subscription_format_preference)) >= 0 ? s(m.subscription_format_preference) : "auto", sub_priority: flag(m.zm_sub_priority), ping_auto: flag(m.zm_auto_ping) && !flag(m.zm_sub_priority), sub_group: index(SUB_GROUPS, s(m.subscription_group_mode)) >= 0 ? s(m.subscription_group_mode) : (flag(m.group_by_countries) ? "country" : "off"), sub_plen: s(num_in(m.subscription_group_prefix_len, 1, 16) || 2), sub_incl: join(" | ", kw_list(m.subscription_filter_include_keywords)), sub_insecure: flag(m.subscription_insecure), sub_ua: s(m.subscription_user_agent), ut_iv: index(UT_IVS, s(m.urltest_check_interval)) >= 0 ? s(m.urltest_check_interval) : "3m", ut_tol: s(num_in(m.urltest_tolerance, 0, 10000) ?? 50), ut_url: s(m.urltest_testing_url) || UT_URL_DEF, ut_guard: flag(m.zm_ut_guard), direct_fallback: flag(m.zm_direct_fallback), uot: flag(m.enable_udp_over_tcp), mixed: flag(m.mixed_proxy_enabled), mixed_port: s(num_in(m.mixed_proxy_port, 1, 65535) || 2080), real_ip: flag(m.resolve_real_ip_for_routing), dres_on: flag(m.domain_resolver_enabled), dres_type: index([ "udp", "dot", "doh" ], s(m.domain_resolver_dns_type)) >= 0 ? s(m.domain_resolver_dns_type) : "udp", dres_server: s(m.domain_resolver_dns_server) }; } function fwd_view(v) { let m = match(s(v), /^\/([^\/]+)\/(.+)$/); return m ? m[1] + " " + m[2] : s(v); } function set_ext(st) { let src = uniq(words(st.source_network_interfaces)); return { dns_ttl: s(num_in(st.dns_rewrite_ttl, 0, 86400) ?? 60), dns_ecs: s(st.dns_client_subnet), src_ifs: length(src) ? src : [ "br-lan" ], out_if: flag(st.enable_output_network_interface) ? s(st.output_network_interface) : "", badwan: flag(st.enable_badwan_interface_monitoring), badwan_ifs: uniq(words(st.badwan_monitored_interfaces)), badwan_delay: s(num_in(st.badwan_reload_delay, 0, 600000) ?? 2000), ipv6: flag(st.enable_ipv6), no_dhcp: flag(st.dont_touch_dhcp), excl_ntp: flag(st.exclude_ntp), excl_bt: flag(st.exclude_bittorrent), bt_ports: s(st.zm_bt_ports), block_doh: flag(st.block_doh), kill: flag(st.block_leaks), hijack: flag(st.dns_hijack), quic_all: s(st.disable_quic) == "all", ecs_auto: flag(st.dns_client_subnet_auto), fwd: join("\n", map(rawlist(st.dns_forward), fwd_view)), p_dt: s(st.direct_tcp_ports), p_du: s(st.direct_udp_ports), p_pt: s(st.proxy_only_tcp_ports), p_pu: s(st.proxy_only_udp_ports), log_level: s(st.zm_log) == "1" && index(LOG_LEVELS, s(st.log_level)) >= 0 ? s(st.log_level) : "panic", yacd: flag(st.enable_yacd), yacd_wan: flag(st.enable_yacd_wan_access), yacd_secret: s(st.yacd_secret_key) }; } function first_addr(v) { for (let x in arr(v)) { let m = match(s(x), /^([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)/); if (m) return m[1]; } return ""; } function net_info(c) { let nets = [], devnet = {}, netproto = {}; try { c.load("network"); } catch (e) {} try { c.foreach("network", "interface", (x) => { let n = x[".name"], proto = s(x.proto), dev = s(x.device || x.ifname); if (dev != "" && !match(dev, /^@/) && !devnet[dev]) devnet[dev] = n; if (proto == "pppoe" || proto == "pppoa") dev = proto + "-" + n; if (match(dev, /^@/)) dev = ""; if (dev != "" && !devnet[dev]) devnet[dev] = n; netproto[n] = proto; push(nets, { name: n, proto, dev }); }); } catch (e) {} let lan = ""; try { lan = first_addr(c.get("network", "lan", "ipaddr")); } catch (e) {} let devs = []; for (let n in (fs.lsdir(NETDIR) || [])) { if (n == "lo" || match(n, /^(sit|ip6tnl|ip6gre|gre|gretap|erspan|teql|ifb|dummy|bonding_masters|wlan-|mon)/)) continue; let p = NETDIR + "/" + n, t = trim(s(fs.readfile(p + "/type"))); let kind = fs.stat(p + "/wireless") || fs.stat(p + "/phy80211") ? "wifi" : fs.stat(p + "/bridge") ? "bridge" : t == "512" ? "ppp" : t == "65534" || t == "776" ? "tun" : t == "1" ? "eth" : ""; if (kind == "") continue; let st = trim(s(fs.readfile(p + "/operstate"))); let dn = devnet[n] || (netproto[n] != null ? n : ""); push(devs, { name: n, kind, up: st != "down", net: dn, proto: dn != "" ? s(netproto[dn]) : "" }); } devs = sort(devs, (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); return { lan_ip: lan, nets, devs }; } function valid_ip6(v) { if (ml(v)) return false; let m = match(v, /^([0-9A-Fa-f:]+)(\/([0-9]+))?$/); return !!(m && index(m[1], ":") >= 0 && (m[3] == null || int(m[3]) <= 128)); } function valid_dev(v) { return !ml(v) && !!match(v, /^[A-Za-z0-9_.@-]{1,15}$/); } function set_opt(c, sec, key, v, def) { if (v == null || v == def) c.delete(CFG, sec, key); else c.set(CFG, sec, key, type(v) == "array" ? v : s(v)); } function set_flag(c, sec, key, on) { if (on) c.set(CFG, sec, key, "1"); else c.delete(CFG, sec, key); } function write_sec_ext(c, sec, mode, x) { const SUB_KEYS = [ "subscription_format_preference", "subscription_insecure", "subscription_user_agent", "subscription_group_mode", "subscription_group_prefix_len", "group_by_countries", "subscription_filter_include_keywords", "zm_sub_priority" ]; const UT_KEYS = [ "urltest_check_interval", "urltest_tolerance", "urltest_testing_url" ]; const DRES_KEYS = [ "domain_resolver_dns_type", "domain_resolver_dns_server" ]; let cur = sec_ext(c.get_all(CFG, sec) || {}); if (type(x) != "object") x = cur; if (mode == "sub") { let fmt = s(x.sub_fmt), grp = s(x.sub_group), ua = trim(s(x.sub_ua)), inc = kw_list(x.sub_incl); if (index(SUB_FMTS, fmt) < 0) fmt = "auto"; if (index(SUB_GROUPS, grp) < 0) grp = "off"; if (length(ua) > 200 || match(ua, /[\r\n\t'"\\`$]/) || non_ascii(ua)) fail("User-Agent — одна строка латиницей до 200 символов, без кавычек"); if (length(inc) > 40) fail("слов для отбора серверов можно указать не больше 40"); let plen = num_in(x.sub_plen, 1, 16); if (grp == "prefix" && plen == null) fail("длина префикса для групп — число от 1 до 16"); set_opt(c, sec, "subscription_format_preference", fmt, "auto"); set_flag(c, sec, "zm_sub_priority", !!x.sub_priority); set_flag(c, sec, "subscription_insecure", !!x.sub_insecure); set_opt(c, sec, "subscription_user_agent", ua, ""); c.delete(CFG, sec, "group_by_countries"); set_opt(c, sec, "subscription_group_mode", grp, "off"); set_opt(c, sec, "subscription_group_prefix_len", grp == "prefix" ? s(plen) : null, "2"); set_list(c, sec, "subscription_filter_include_keywords", inc); } else del_keys(c, sec, SUB_KEYS); if (mode != "iface") { let iv = s(x.ut_iv), url = trim(s(x.ut_url)), tol = num_in(x.ut_tol, 0, 10000); if (index(UT_IVS, iv) < 0) iv = "3m"; if (tol == null) fail("допуск автовыбора — число миллисекунд от 0 до 10000"); if (url == "") url = UT_URL_DEF; if (!valid_url(url) || match(url, /['"\\`$]/)) fail("адрес для проверки серверов должен быть ссылкой http(s)://…"); set_opt(c, sec, "urltest_check_interval", iv, "3m"); set_opt(c, sec, "urltest_tolerance", s(tol), "50"); set_opt(c, sec, "urltest_testing_url", url, UT_URL_DEF); c.set(CFG, sec, "enable_udp_over_tcp", x.uot ? "1" : "0"); set_flag(c, sec, "zm_direct_fallback", !!x.direct_fallback); set_flag(c, sec, "zm_auto_ping", !!x.ping_auto && !(mode == "sub" && x.sub_priority)); set_flag(c, sec, "zm_ut_guard", !!x.ut_guard && !x.ping_auto); del_keys(c, sec, [ "domain_resolver_enabled", ...DRES_KEYS ]); } else { del_keys(c, sec, [ ...UT_KEYS, "enable_udp_over_tcp", "zm_direct_fallback", "zm_ut_guard", "zm_auto_ping" ]); if (x.dres_on) { let t = s(x.dres_type), v = trim(s(x.dres_server)); if (index([ "udp", "dot", "doh" ], t) < 0) fail("неизвестный тип DNS для туннеля"); if (!dns_value_ok(t, v)) fail(t == "udp" ? "DNS туннеля: нужен IP-адрес, например 1.1.1.1" : t == "dot" ? "DNS туннеля: нужен адрес без https://, например one.one.one.one" : "DNS туннеля: нужен адрес сервера или ссылка https://…"); c.set(CFG, sec, "domain_resolver_enabled", "1"); c.set(CFG, sec, "domain_resolver_dns_type", t); c.set(CFG, sec, "domain_resolver_dns_server", dns_norm(t, v)); } else { c.set(CFG, sec, "domain_resolver_enabled", "0"); del_keys(c, sec, DRES_KEYS); } } if (x.mixed) { let port = num_in(x.mixed_port, 1024, 65535); if (port == null) fail("порт локального прокси — число от 1024 до 65535"); if (port == 9090 || port == 5353) fail("порт " + port + " занят службами роутера — выберите другой, например 2080"); for (let o in sections(c, "section")) { let n = o[".name"]; if (n == sec || !is_conn(o) || s(o.disabled) == "1" || !flag(o.mixed_proxy_enabled)) continue; if ((num_in(o.mixed_proxy_port, 1, 65535) || 2080) == port) fail("порт " + port + " уже занят локальным прокси секции «" + sec_label(o, false) + "» — выберите другой"); } c.set(CFG, sec, "mixed_proxy_enabled", "1"); c.set(CFG, sec, "mixed_proxy_port", s(port)); } else del_keys(c, sec, [ "mixed_proxy_enabled", "mixed_proxy_port" ]); set_flag(c, sec, "resolve_real_ip_for_routing", !!x.real_ip); } function fwd_list(v) { let r = [], seen = {}; for (let l in lines(v)) { let zone, srv, m = match(l, /^\/([^\/]+)\/(.+)$/); if (m) { zone = m[1]; srv = m[2]; } else { let f = split(l, /[ \t]+/); if (length(f) != 2) fail("пересылка DNS по зонам: строка «" + substr(l, 0, 60) + "» — нужно «зона сервер», например «ru 77.88.8.8»"); zone = f[0]; srv = f[1]; } zone = lc(replace(trim(zone), /^\./, "")); srv = trim(srv); if (ml(zone) || length(zone) > 253 || !match(zone, /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/)) fail("пересылка DNS по зонам: «" + substr(zone, 0, 60) + "» не похоже на зону, например ru или example.com"); let pm = match(srv, /^(.+)#([0-9]{1,5})$/), addr = pm ? pm[1] : srv; if (pm && (int(pm[2]) < 1 || int(pm[2]) > 65535)) fail("пересылка DNS по зонам: неверный порт в «" + srv + "»"); if ((!valid_ip(addr) && !valid_ip6(addr)) || index(addr, "/") >= 0) fail("пересылка DNS по зонам: «" + substr(srv, 0, 60) + "» — нужен IP-адрес DNS-сервера, например 77.88.8.8"); if (match(addr, /^127\.0\.0\.4[0-9]$/)) fail("пересылка DNS по зонам: адрес " + addr + " занят самим Forkozz"); let e = "/" + zone + "/" + srv; if (seen[zone]) fail("пересылка DNS по зонам: зона " + zone + " указана дважды"); seen[zone] = true; push(r, e); } if (length(r) > 32) fail("пересылка DNS по зонам: не больше 32 строк"); return r; } function port_list(v, what) { let r = []; for (let p in split(trim(s(v)), /[ \t,;]+/)) { if (p == "") continue; let m = match(p, /^([0-9]{1,5})(-([0-9]{1,5}))?$/), a = m ? int(m[1]) : 0, b = m && m[3] != null ? int(m[3]) : a; if (!m || a < 1 || b > 65535 || b < a) fail(what + ": «" + substr(p, 0, 20) + "» — нужен порт от 1 до 65535 или диапазон, например 22 или 6881-6889"); push(r, a == b ? s(a) : a + "-" + b); } r = uniq(r); if (length(r) > 24) fail(what + ": не больше 24 портов и диапазонов"); return join(",", r); } function engine_caps() { let b = s(fs.readfile(BIN)); return { naive: !!fs.access(NS_LIB + "/naive.sh"), kill: !!fs.access(NS_LIB + "/kill_switch.sh"), fwd: !!fs.access(NS_LIB + "/dnsforward.sh"), ecs_auto: !!fs.access(NS_LIB + "/ecs.sh"), hijack: index(b, "nft_add_dns_hijack() {") >= 0, ports: index(b, "sing_box_configure_port_rules() {") >= 0, quic_all: index(b, "disable_quic_mode() {") >= 0, prefixes: prefix_ok() }; } function write_set_ext(c, g) { if (type(g) != "object") return; const S = "settings"; let ttl = num_in(g.dns_ttl, 0, 86400); if (ttl == null) fail("время жизни DNS-записей — число секунд от 0 до 86400"); let ecs = trim(s(g.dns_ecs)); if (ecs != "" && !valid_ip(ecs) && !valid_ip6(ecs)) fail("EDNS Client Subnet — IP-адрес или подсеть, например 203.0.113.0/24"); c.set(CFG, S, "dns_rewrite_ttl", s(ttl)); set_opt(c, S, "dns_client_subnet", ecs, ""); let src = filter(uniq(words(g.src_ifs)), (v) => v != ""); for (let v in src) if (!valid_dev(v)) fail("неверное имя интерфейса: " + v); if (!length(src)) fail("выберите хотя бы одну сеть, из которой Forkozz принимает трафик"); if (length(src) > 8) fail("сетей-источников можно выбрать не больше 8"); c.set(CFG, S, "source_network_interfaces", src); let oi = trim(s(g.out_if)); if (oi != "") { if (!valid_dev(oi)) fail("неверное имя интерфейса для выхода: " + oi); if (index(src, oi) >= 0) fail("один и тот же интерфейс не может быть и источником, и выходом: " + oi); c.set(CFG, S, "enable_output_network_interface", "1"); c.set(CFG, S, "output_network_interface", oi); } else { c.set(CFG, S, "enable_output_network_interface", "0"); c.delete(CFG, S, "output_network_interface"); } if (g.badwan) { let bw = filter(uniq(words(g.badwan_ifs)), (v) => v != ""); for (let v in bw) if (ml(v) || !match(v, /^[A-Za-z0-9_]{1,32}$/) || v == "lan" || v == "loopback") fail("эту сеть следить нельзя: " + v); if (!length(bw)) fail("выберите, за какими подключениями к интернету следить"); let dl = num_in(g.badwan_delay, 0, 600000); if (dl == null) fail("задержка перезапуска — число миллисекунд, например 2000"); c.set(CFG, S, "enable_badwan_interface_monitoring", "1"); c.set(CFG, S, "badwan_monitored_interfaces", bw); c.set(CFG, S, "badwan_reload_delay", s(dl)); } else { c.set(CFG, S, "enable_badwan_interface_monitoring", "0"); del_keys(c, S, [ "badwan_monitored_interfaces", "badwan_reload_delay" ]); } for (let kv in [ [ "ipv6", "enable_ipv6" ], [ "no_dhcp", "dont_touch_dhcp" ], [ "excl_ntp", "exclude_ntp" ], [ "excl_bt", "exclude_bittorrent" ], [ "block_doh", "block_doh" ] ]) c.set(CFG, S, kv[1], g[kv[0]] ? "1" : "0"); let btl = []; for (let p in split(replace(trim(s(g.bt_ports)), /[ \t;]+/g, ","), ",")) { if (p == "") continue; let m = match(p, /^([0-9]{1,5})(-([0-9]{1,5}))?$/), a = m ? +m[1] : 0, b = m && m[3] != null ? +m[3] : a; if (!m || a < 1024 || b > 65535 || b < a) fail("порт торрент-клиента — число от 1024 до 65535 или диапазон, например 51413 или 6881-6889"); push(btl, a == b ? s(a) : a + "-" + b); } btl = uniq(btl); if (length(btl) > 8) fail("портов торрент-клиента — не больше 8"); set_opt(c, S, "zm_bt_ports", join(",", btl), ""); set_opt(c, S, "zm_bt_src", g.excl_bt && length(btl) ? "1" : "", ""); if (g.kill != null) set_flag(c, S, "block_leaks", !!g.kill); if (g.hijack != null) set_flag(c, S, "dns_hijack", !!g.hijack); if (g.quic_all) c.set(CFG, S, "disable_quic", "all"); if (g.ecs_auto != null) set_flag(c, S, "dns_client_subnet_auto", !!g.ecs_auto); if (g.fwd != null) { let fl = fwd_list(g.fwd); if (length(fl)) c.set(CFG, S, "dns_forward", fl); else c.delete(CFG, S, "dns_forward"); } for (let kv in [ [ "p_dt", "direct_tcp_ports", "Порты TCP напрямую" ], [ "p_du", "direct_udp_ports", "Порты UDP напрямую" ], [ "p_pt", "proxy_only_tcp_ports", "Порты TCP через VPN" ], [ "p_pu", "proxy_only_udp_ports", "Порты UDP через VPN" ] ]) { if (g[kv[0]] == null) continue; let pv = port_list(g[kv[0]], kv[2]); if (pv != "") c.set(CFG, S, kv[1], pv); else c.delete(CFG, S, kv[1]); } let lv = s(g.log_level); c.set(CFG, S, "log_level", index(LOG_LEVELS, lv) >= 0 ? lv : "panic"); c.set(CFG, S, "zm_log", "1"); if (g.yacd) { c.set(CFG, S, "enable_yacd", "1"); if (g.yacd_wan) { let k = trim(s(g.yacd_secret)); if (length(k) < 8 || length(k) > 64 || ml(k) || !match(k, /^[A-Za-z0-9._~+=-]+$/)) fail("для доступа из интернета задайте ключ панели: 8–64 латинских букв, цифр или ._-~+="); c.set(CFG, S, "enable_yacd_wan_access", "1"); c.set(CFG, S, "yacd_secret_key", k); } else { c.set(CFG, S, "enable_yacd_wan_access", "0"); c.delete(CFG, S, "yacd_secret_key"); } } else { c.set(CFG, S, "enable_yacd", "0"); c.set(CFG, S, "enable_yacd_wan_access", "0"); c.delete(CFG, S, "yacd_secret_key"); } } 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), legacy_bypass: legacy_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() }, own_on: !own_off(m), domains: own_view(m, rf.l).domains, subnets: own_view(m, rf.l).subnets, lists: own_view(m, rf.l).lists, full: device_ips(m), all: all_ips(m), excl: uniq(words(st.routing_excluded_ips)), extra: p.extra, dns: dns_info(st), quic_off: index([ "1", "all", "tunneled", "true", "yes", "on" ], s(st.disable_quic)) >= 0, caps: engine_caps(), list_interval: s(st.update_interval || "1d"), list_update: true, lists_via: s(st.download_lists_via_proxy) == "1", devices: devices(), tunnels: tunnels(), x: sec_ext(m), g: set_ext(st), net: net_info(c) }); } 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 (ml(sec) || !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); let naive_ok = !!fs.access(NS_LIB + "/naive.sh"); for (let x in links) if (!match(x, SCHEMES) && !(naive_ok && match(x, SCHEMES_NAIVE))) fail(match(x, /^naive\+/i) ? (naive_ok ? "ссылке NaiveProxy нужны логин и пароль: naive+https://логин:пароль@сервер" : "NaiveProxy работает с движком Forkozz 0.9.11 и новее — обновите Forkozz") : match(x, /^(tuic|http|https|anytls|wireguard|hysteria):\/\//i) ? "Forkozz не поддерживает такие ссылки: " + substr(x, 0, 40) + " — подойдут vless, vmess, trojan, ss, socks5, hysteria2" + (naive_ok ? ", naive" : "") : "ссылка не поддерживается: " + substr(x, 0, 40)); let subs = uniq(lines(type(d.subs) == "array" && length(d.subs) ? d.subs : d.sub)), iface = trim(s(d.iface)); if (mode == "links" && !length(links)) fail("добавьте хотя бы одну ссылку на сервер"); if (mode == "sub") { if (!length(subs)) fail("вставьте ссылку на подписку"); if (length(subs) > SUB_MAX) fail("подписок в одной секции можно указать не больше " + SUB_MAX); for (let u in subs) if (!valid_url(u) || match(u, /['"\\`]/)) fail("ссылка на подписку должна начинаться с https:// — ошибка в «" + substr(u, 0, 60) + "»"); } if (mode == "iface" && !valid_dev(iface)) 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) && !pkg_local(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) && !pkg_local(x)) fail("внешний список должен быть ссылкой https://…: " + x); let own_on = d.own_on == null ? !own_off(c.get_all(CFG, sec) || {}) : !!d.own_on; let keep = { domains, subnets, lists }; if (!own_on) { domains = []; subnets = []; lists = []; } lists = uniq([ ...lists, ...rplain ]); let full = d.full == null ? device_ips(c.get_all(CFG, sec) || {}) : uniq(tokens(d.full)); let excl = d.excl == null ? uniq(words(c.get(CFG, "settings", "routing_excluded_ips"))) : uniq(tokens(d.excl)); let all = d.all == null ? all_ips(c.get_all(CFG, sec) || {}) : uniq(tokens(d.all)); for (let x in [ ...full, ...excl, ...all ]) if (!valid_ip(x)) fail("неверный адрес устройства: " + x); if (length(all) > 64) fail("устройств с полным трафиком через VPN можно выбрать не больше 64"); if (!length(all) && !length(services) && !length(rsets) && !length(rsubs) && !length(domains) && !length(subnets) && !length(lists) && (length(full) || created || !sec_configured(c.get_all(CFG, sec) || {}))) fail(!own_on && (length(keep.domains) || length(keep.subnets) || length(keep.lists)) ? "«Свой список» выключен, а других сервисов в секции нет — включите его или выберите сервис" : "выберите для секции хотя бы один сервис, домен или адрес — устройства только ограничивают её правила"); 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; for (let v in all) for (let w in all_ips(x)) if (device_ip_overlap(v, w)) fail("устройство " + v + " уже целиком идёт через секцию «" + sec_label(x, n == p0.sec) + "» — весь трафик устройства можно отдать только одной секции"); if (!device_scopes_overlap(full, device_ips(x))) 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); del_keys(c, sec, [ "zm_links", "zm_exclude", "zm_hide", "subscription_update_interval" ]); } else { c.set(CFG, sec, "connection_type", "proxy"); 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", subs); 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); } write_sec_ext(c, sec, mode, d.x); set_lists(c, sec, { s: rsets, r: rsubs, l: lists }, services, domains, subnets); if (own_on || !(length(keep.domains) || length(keep.subnets) || length(keep.lists))) { del_keys(c, sec, [ "zm_own_off", "zm_own_domains", "zm_own_subnets", "zm_own_lists" ]); } else { c.set(CFG, sec, "zm_own_off", "1"); if (length(keep.domains)) c.set(CFG, sec, "zm_own_domains", join("\n", keep.domains)); else c.delete(CFG, sec, "zm_own_domains"); if (length(keep.subnets)) c.set(CFG, sec, "zm_own_subnets", join("\n", keep.subnets)); else c.delete(CFG, sec, "zm_own_subnets"); set_list(c, sec, "zm_own_lists", keep.lists); } write_device_lists(c, sec, full, excl, all); 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://… (ошибка в «" + 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)); write_set_ext(c, d.g); c.save(CFG); c.commit(CFG); out({ ok: true, sec, created }); } function group_tag(sec) { return sec + "-out"; } function fallback_tag(sec) { return sec + "-zm-fallback-out"; } function priority_tag(sec) { return sec + "-zm-priority-out"; } function priority_manual_file(sec) { return "/tmp/zm-fk-priority-manual." + sec; } function ping_tag(sec) { return sec + "-zm-ping-out"; } function ping_manual_file(sec) { return "/tmp/zm-fk-ping-manual." + sec; } function ping_state_file(sec) { return "/tmp/zm-fk-ping-state." + sec; } function ping_on(m) { return flag(m.zm_auto_ping) && !flag(m.zm_sub_priority) && s(m.connection_type) == "proxy" && !flag(m.disabled); } function cmd_fallback_config() { let conf = null; try { conf = json(s(fs.stdin.read("all"))); } catch (e) {} if (type(conf) != "object" || type(conf.outbounds) != "array") fail("не удалось прочитать конфиг sing-box"); let c = cursor(), obs = {}; for (let ob in conf.outbounds) obs[s(ob.tag)] = ob; let meta = conf._zm_priority_feeds || {}; delete conf._zm_priority_feeds; for (let m in sections(c, "section")) { if (!flag(m.zm_sub_priority) || sec_mode(m) != "sub" || flag(m.disabled)) continue; let sec = m[".name"], original = obs[group_tag(sec)], feeds = meta[sec], groups = []; if (!original) continue; if (s(original.type) != "selector" || type(feeds) != "array" || !length(feeds)) fail("не удалось определить принадлежность серверов к подпискам секции " + sec); for (let i = 0; i < length(feeds); i++) { let feed = feeds[i], tags = uniq(arr(feed.tags)); if (!length(tags)) continue; for (let t in tags) if (!obs[t] || type(obs[t].outbounds) == "array") fail("не найден сервер подписки: " + t); let tag = sec + "-zm-priority-feed-" + (i + 1) + ": " + s(feed.name); if (obs[tag]) fail("имя группы подписки уже занято: " + tag); let ob = { type: "selector", tag, outbounds: tags, default: tags[0], interrupt_exist_connections: true }; push(conf.outbounds, ob); obs[tag] = ob; push(groups, tag); } let tag = priority_tag(sec); if (!length(groups) || obs[tag]) fail("не удалось собрать порядок подписок секции " + sec); let ob = { type: "selector", tag, outbounds: groups, default: groups[0], interrupt_exist_connections: true }; push(conf.outbounds, ob); obs[tag] = ob; original.outbounds = [ tag, ...original.outbounds ]; original.default = tag; } for (let m in sections(c, "section")) { if (!ping_on(m)) continue; let sec = m[".name"], original = obs[group_tag(sec)], tag = ping_tag(sec); if (!original || s(original.type) != "selector") continue; let todo = [ group_tag(sec) ], seen = {}, leaves = []; for (let i = 0; i < length(todo); i++) { let t = todo[i], ob = obs[t]; if (seen[t] || !ob) continue; seen[t] = true; if (type(ob.outbounds) == "array") { for (let child in ob.outbounds) push(todo, child); } else if (index([ "direct", "reject", "block", "dns" ], s(ob.type)) < 0) push(leaves, t); } leaves = uniq(leaves); if (length(leaves) < 2) continue; if (obs[tag]) fail("имя группы автовыбора по пингу уже занято: " + tag); let ob = { type: "selector", tag, outbounds: leaves, default: leaves[0], interrupt_exist_connections: true }; push(conf.outbounds, ob); obs[tag] = ob; original.outbounds = [ tag, ...original.outbounds ]; original.default = tag; } for (let m in sections(c, "section")) { if (!flag(m.zm_direct_fallback) || s(m.connection_type) != "proxy" || flag(m.disabled)) continue; let sec = m[".name"], original = group_tag(sec), tag = fallback_tag(sec); if (!obs["direct-out"] || s(obs["direct-out"].type) != "direct") fail("в конфиге нет прямого выхода"); if (obs[tag]) fail("имя резервного выхода уже занято: " + tag); let todo = obs[original] ? [ original ] : [], seen = {}, leaves = []; for (let i = 0; i < length(todo); i++) { let t = todo[i], ob = obs[t]; if (seen[t] || !ob) continue; seen[t] = true; if (type(ob.outbounds) == "array") { for (let child in ob.outbounds) push(todo, child); } else if (index([ "direct", "reject", "block", "dns" ], s(ob.type)) < 0) push(leaves, t); } push(conf.outbounds, { type: "selector", tag, outbounds: obs[original] ? uniq([ original, ...leaves, "direct-out" ]) : [ "direct-out" ], default: obs[original] ? original : "direct-out", interrupt_exist_connections: true }); for (let dns in (conf.dns && type(conf.dns.servers) == "array" ? conf.dns.servers : [])) if (s(dns.detour) == original) dns.detour = tag; } let st = c.get_all(CFG, "settings") || {}; if (flag(st.exclude_bittorrent) && s(st.zm_bt_src) == "1" && type(conf.route) == "object" && type(conf.route.rules) == "array") { let rules = conf.route.rules, at = -1, ports = [], ranges = []; for (let i = 0; i < length(rules) && at < 0; i++) { let p = rules[i].protocol; if (p == "bittorrent" || (type(p) == "array" && index(p, "bittorrent") >= 0)) at = i; } for (let p in split(s(st.zm_bt_ports), ",")) { if (match(p, /^[0-9]+-[0-9]+$/)) push(ranges, replace(p, "-", ":")); else if (match(p, /^[0-9]+$/)) push(ports, +p); } if (at >= 0 && length(ports) + length(ranges)) { let bt = rules[at], mk = (pk, rk) => { let r = { inbound: bt.inbound, network: [ "tcp", "udp" ] }; if (length(ports)) r[pk] = ports; if (length(ranges)) r[rk] = ranges; if (bt.action != null) r.action = bt.action; if (bt.outbound != null) r.outbound = bt.outbound; return r; }; splice(rules, at + 1, 0, mk("source_port", "source_port_range"), mk("port", "port_range")); } } out(conf); } function fallback_info(sec, px) { let g = px ? px[fallback_tag(sec)] : null; let now = g ? s(g.now) : ""; return { enabled: !!g, direct: now == "direct-out", override: now != "" && now != "direct-out" && now != group_tag(sec) ? now : "" }; } function auto_tag(sec, px, g) { let tags = g && type(g.all) == "array" ? g.all : []; let priority = priority_tag(sec); if (index(tags, priority) >= 0 && px[priority] && lc(s(px[priority].type)) == "selector") return priority; let pingt = ping_tag(sec); if (index(tags, pingt) >= 0 && px[pingt] && lc(s(px[pingt].type)) == "selector") return pingt; let tag = sec + "-urltest-out"; let valid = (t) => index(tags, t) >= 0 && px[t] && lc(s(px[t].type)) == "urltest"; if (valid(tag)) return tag; let conf = null; try { conf = json(s(fs.readfile(SB_CONF))); } catch (e) {} for (let ob in (conf && type(conf.outbounds) == "array" ? conf.outbounds : [])) if (ob.tag == group_tag(sec) && valid(s(ob.default))) return s(ob.default); for (let t in tags) if (valid(t)) return t; return null; } function proxy_now(px, tag) { let seen = {}; while (px[tag] && index([ "urltest", "selector" ], lc(s(px[tag].type))) >= 0 && !seen[tag]) { seen[tag] = true; let next = s(px[tag].now); if (next == "") break; tag = next; } return tag; } 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"] = "Авто"; names[priority_tag(sec)] = "Авто · порядок подписок"; names[ping_tag(sec)] = "Авто · по пингу"; 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; let feed = match(t, /-zm-priority-feed-[0-9]+: (.*)$/); return { name: s(names[t] || (feed ? "Подписка: " + feed[1] : t)), country: "", type: s(p.type), now: s(p.now || ""), delay: h ? int(h.delay || 0) : -1, members: (lc(s(p.type)) == "urltest" || index(t, "-zm-priority-") >= 0 || index(t, "-zm-ping-") >= 0) && 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"; } 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 }); } const NS_SUBS = "/etc/netshift/subscriptions"; function sub_feed(sec) { let upd = 0, ua = ""; for (let f in (fs.lsdir(NS_SUBS) || [])) { if (f != sec + ".json" && index(f, sec + ".") != 0) continue; if (match(f, /\.json$/)) { let st = fs.stat(NS_SUBS + "/" + f); if (st && st.mtime > upd) upd = st.mtime; } else if (match(f, /\.user_agent$/) && ua == "") { try { ua = trim(s(fs.readfile(NS_SUBS + "/" + f))); } catch (e) { ua = ""; } } } let fmt = ua == "" ? "" : match(ua, /^sing-?box/i) ? "sing-box" : match(ua, /^(happ|v2ray)/i) ? "Xray (Happ)" : match(ua, /clash/i) ? "Clash" : match(ua, /hiddify/i) ? "Hiddify" : ua; return { updated: upd > 0 ? upd : null, fmt }; } 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 &)"); let fd = sub_feed(sec); if (type(j) != "object") j = {}; return { title: s(j.title), used: j.used, total: j.total, unlimited: !!j.unlimited, expire: j.expire, updated: fd.updated, fmt: fd.fmt }; } 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 lat_path(sec) { return STATE + "/fk.lat." + sec + ".json"; } const LAT_MODE_FILE = STATE + "/fk.latmode"; function lat_mode() { return trim(s(fs.readfile(LAT_MODE_FILE))) == "real" ? "real" : "fast"; } function cmd_latmode(v) { if (v == "real") { try { fs.mkdir(STATE); } catch (e) {} if (fs.writefile(LAT_MODE_FILE, "real\n") == null) fail("не удалось сохранить настройку"); } else if (v == "fast") { try { fs.unlink(LAT_MODE_FILE); } catch (e) {} } else if (v != "") fail("неизвестный способ проверки"); out({ ok: true, mode: lat_mode() }); } const NODE_POL_FILE = STATE + "/fk.nodepol"; function node_pol() { let v = trim(s(fs.readfile(NODE_POL_FILE))); return v == "switch" || v == "hold" ? v : "back"; } function home_path(sec) { return STATE + "/fk.home." + sec; } function home_read(sec) { let j = null; try { j = json(s(fs.readfile(home_path(sec)))); } catch (e) { j = null; } return type(j) == "object" && s(j.tag) != "" ? j : null; } function home_drop(sec) { try { fs.unlink(home_path(sec)); } catch (e) {} } function cmd_nodepol(v) { if (v == "switch" || v == "hold") { try { fs.mkdir(STATE); } catch (e) {} if (fs.writefile(NODE_POL_FILE, v + "\n") == null) fail("не удалось сохранить настройку"); for (let f in (fs.lsdir(STATE) || [])) if (substr(f, 0, 8) == "fk.home.") try { fs.unlink(STATE + "/" + f); } catch (e) {} } else if (v == "back") { try { fs.unlink(NODE_POL_FILE); } catch (e) {} } else if (v != "") fail("неизвестная настройка"); out({ ok: true, pol: node_pol() }); } function lat_read(sec) { let j = null; try { j = json(s(fs.readfile(lat_path(sec)))); } catch (e) { j = null; } return type(j) == "object" ? j : null; } function lat_running(l) { return !!(l && l.running && time() - int(l.at || 0) < 300); } 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)]; let fallback = fallback_info(sec, px); if ((!g || type(g.all) != "array") && !fallback.direct) fail("список серверов ещё не готов — примените настройки"); if (!g || type(g.all) != "array") g = { all: [], now: "" }; let c = cursor(), xm = c.get_all(CFG, sec) || {}, xv = excl_vars(xm), names = sec_names(c, sec); let nodes = {}; let pending = slice(g.all, 0); for (let i = 0; i < length(pending); i++) { let t = pending[i]; if (nodes[t]) continue; nodes[t] = node_info(px, names, t); for (let m in (nodes[t].members || [])) if (!nodes[m]) push(pending, m); } let fell = ""; try { fell = trim(s(fs.readfile(fell_file(sec)))); } catch (e) { fell = ""; } let lat = lat_read(sec), lrun = lat_running(lat); if (lat && (lrun || time() - int(lat.at || 0) < 900)) { let dl = type(lat.delays) == "object" ? lat.delays : {}, rl = type(lat.real) == "object" ? lat.real : {}; let bl = {}, rp = {}; for (let t in (type(lat.blocked) == "array" ? lat.blocked : [])) bl[t] = true; for (let t in (type(lat.rpending) == "array" ? lat.rpending : [])) rp[t] = true; for (let t in (type(lat.tested) == "array" ? lat.tested : [])) if (nodes[t]) { nodes[t].delay = int(dl[t] || 0); if (rl[t] != null) { nodes[t].tcp = nodes[t].delay; nodes[t].delay = int(rl[t] || 0); } if (bl[t]) { nodes[t].tcp = int(dl[t] || 0); nodes[t].delay = 0; nodes[t].blocked = true; } if (lrun && rp[t]) { nodes[t].tcp = int(dl[t] || 0); nodes[t].rpend = true; } } if (lrun) for (let t in (type(lat.pending) == "array" ? lat.pending : [])) if (nodes[t]) nodes[t].delay = -2; } let home = node_pol() == "back" ? home_read(sec) : null; out({ group: group_tag(sec), auto_tag: auto_tag(sec, px, g), now: fallback.override || 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, fallback, priority: flag(xm.zm_sub_priority), ping: ping_on(xm), lat_mode: lat_mode(), lat: lat ? { running: lrun, at: int(lat.at || 0), error: s(lat.error), mode: s(lat.mode) } : null, home: home ? s(home.name || home.tag) : "", home_tag: home ? s(home.tag) : "", node_pol: node_pol() }); } const LAT_URLS = [ "https://www.gstatic.com/generate_204", "http://cp.cloudflare.com/generate_204", "https://www.google.com/generate_204", "http://connectivitycheck.gstatic.com/generate_204" ]; const LAT_PAR = 4; let LAT_SEQ = 0; function clash_ep() { let j = null; try { j = json(s(fs.readfile(SB_CONF))); } catch (e) { j = null; } let ca = j && type(j.experimental) == "object" && type(j.experimental.clash_api) == "object" ? j.experimental.clash_api : null; let ec = ca ? s(ca.external_controller) : ""; if (ec == "") return null; let m = match(ec, /^(.*):([0-9]+)$/); let host = m ? m[1] : "127.0.0.1", port = m ? m[2] : "9090"; if (host == "" || host == "0.0.0.0" || host == "::" || host == "[::]") host = "127.0.0.1"; return { base: "http://" + host + ":" + port, secret: s(ca.secret) }; } function urlenc(v) { let o = "", str = s(v); for (let i = 0; i < length(str); i++) { let b = ord(str, i), c = chr(b); o += match(c, /^[A-Za-z0-9_.~-]$/) ? c : sprintf("%%%02X", b); } return o; } function lat_cmd(ep, tag, url, ms) { return "curl -s --noproxy '*' --max-time " + (int(ms / 1000) + 3) + (ep.secret != "" ? " -H " + q("Authorization: Bearer " + ep.secret) : "") + " -G --data-urlencode " + q("url=" + url) + " --data-urlencode " + q("timeout=" + ms) + " " + q(ep.base + "/proxies/" + urlenc(tag) + "/delay"); } function lat_parse(raw) { let j = null; try { j = json(trim(s(raw))); } catch (e) { j = null; } return type(j) == "object" ? int(j.delay || 0) : 0; } function lat_batch(ep, tags, url, ms) { let res = {}; let dir = "/tmp/zm-fk-lat." + s(fs.readlink("/proc/self")) + "." + (++LAT_SEQ); if (fs.mkdir(dir) == null) fail("не удалось создать временную папку для проверки серверов"); for (let i = 0; i < length(tags); i += LAT_PAR) { let part = slice(tags, i, i + LAT_PAR), cmd = ""; for (let k = 0; k < length(part); k++) cmd += "(" + lat_cmd(ep, part[k], url, ms) + " > " + q(dir + "/" + k) + " 2>/dev/null) & "; sh(cmd + "wait"); for (let k = 0; k < length(part); k++) { let d = 0; try { d = lat_parse(fs.readfile(dir + "/" + k)); } catch (e) { d = 0; } if (d > 0) res[part[k]] = d; } } sh("rm -rf " + q(dir)); return res; } const UDP_TYPES = { hysteria: true, hysteria2: true, tuic: true, wireguard: true, awg: true, quic: true }; function node_addrs() { let j = null, r = {}; try { j = json(s(fs.readfile(SB_CONF))); } catch (e) { j = null; } if (!j || type(j.outbounds) != "array") return r; for (let o in j.outbounds) { if (type(o) != "object" || s(o.tag) == "" || s(o.server) == "" || int(o.server_port || 0) <= 0) continue; if (UDP_TYPES[lc(s(o.type))]) continue; r[s(o.tag)] = { host: s(o.server), port: int(o.server_port) }; } return r; } function tcp_cmd(a, to) { let h = index(a.host, ":") >= 0 ? "[" + a.host + "]" : a.host; return "curl -s -o /dev/null --noproxy '*' --connect-timeout " + to + " --max-time " + to + " -w '%{time_connect} %{time_namelookup}' " + q("http://" + h + ":" + a.port + "/"); } function tcp_parse(raw) { let f = split(trim(s(raw)), " "); if (length(f) < 2) return 0; let c = +f[0], n = +f[1]; if (!(c > 0)) return 0; let ms = int((c - (n > 0 && n < c ? n : 0)) * 1000 + 0.5); return ms < 1 ? 1 : ms; } const TCP_PAR = 10; let TCP_SEQ = 0; function tcp_chunk(addrs, part, to) { let res = {}, dir = STATE + "/tcp.tmp." + s(fs.readlink("/proc/self")) + "." + (++TCP_SEQ), cmd = ""; try { fs.mkdir(STATE); } catch (e) {} try { fs.mkdir(dir); } catch (e) {} for (let k = 0; k < length(part); k++) { let c = tcp_cmd(addrs[part[k]], to), f = q(dir + "/" + k); cmd += "(" + c + " > " + f + ".a) 2>/dev/null & (" + c + " > " + f + ".b) 2>/dev/null & "; } sh(cmd + "wait"); for (let k = 0; k < length(part); k++) { let a = 0, b = 0; try { a = tcp_parse(fs.readfile(dir + "/" + k + ".a")); } catch (e) { a = 0; } try { b = tcp_parse(fs.readfile(dir + "/" + k + ".b")); } catch (e) { b = 0; } let best = (a > 0 && b > 0) ? (a < b ? a : b) : (a > 0 ? a : b); if (best > 0) res[part[k]] = best; } sh("rm -rf " + q(dir)); return res; } function probe_ms(tag, ms, passes) { let ep = clash_ep(); if (ep && sh("command -v curl") != "") { for (let i = 0; i < (passes || 3); i++) { let d = lat_parse(sh(lat_cmd(ep, tag, LAT_URLS[i % length(LAT_URLS)], ms + i * 1500))); if (d > 0) return d; } return 0; } let l = jcmd(BIN + " clash_api get_proxy_latency " + q(tag) + " " + ms); return type(l) == "object" ? int(l.delay || 0) : 0; } function proxy_alive(tag) { return probe_ms(tag, 6000, 3) > 0; } function vpn_members(px, tag) { let todo = [ tag ], seen = {}, leaves = []; for (let i = 0; i < length(todo); i++) { let t = todo[i], p = px[t]; if (seen[t] || !p) continue; seen[t] = true; if (type(p.all) == "array") { for (let child in p.all) push(todo, child); } else if (index([ "direct", "reject", "block", "dns" ], lc(s(p.type))) < 0) push(leaves, t); } return leaves; } function select_proxy(group, tag) { let r = jcmd(BIN + " clash_api set_group_proxy " + q(group) + " " + q(tag)); if (type(r) != "object" || r.error || r.success === false) return false; let px = proxies(); return !!(px && px[group] && s(px[group].now) == tag); } function priority_active(sec, m, px) { let g = px ? px[group_tag(sec)] : null; if (!flag(m.zm_sub_priority) || sec_mode(m) != "sub" || !g || !px[priority_tag(sec)]) return false; let manual = trim(s(fs.readfile(priority_manual_file(sec)))); return s(g.now) == priority_tag(sec) || manual == "" || manual != s(g.now); } function ping_active(sec, m, px) { let g = px ? px[group_tag(sec)] : null; if (!ping_on(m) || !g || !px[ping_tag(sec)]) return false; let manual = trim(s(fs.readfile(ping_manual_file(sec)))); return s(g.now) == ping_tag(sec) || manual == "" || manual != s(g.now); } function node_alive(ep, tag, urls) { for (let url in urls) if (lat_batch(ep, [ tag ], url, 5000)[tag] > 0) return true; return false; } function ping_pick(sec, px, ep, urls, snapshot, tol) { let pt = ping_tag(sec), p = px[pt]; if (!p || type(p.all) != "array" || !length(p.all)) return { ok: true, changed: false }; let cur = s(p.now), st = null; try { st = json(s(fs.readfile(ping_state_file(sec)))); } catch (e) { st = null; } let cur_ok = cur != "" && node_alive(ep, cur, urls); if (cur_ok && type(st) == "object" && s(st.tag) == cur && time() - int(st.at || 0) < 900) return { ok: true, changed: false }; let addrs = node_addrs(), delays = {}, tcp = filter(p.all, (t) => !!addrs[t]), other = filter(p.all, (t) => !addrs[t]); for (let i = 0; i < length(tcp); i += TCP_PAR) { if (snapshot != s(fs.readfile(SB_CONF))) return null; let r = tcp_chunk(addrs, slice(tcp, i, i + TCP_PAR), 3); for (let t in keys(r)) delays[t] = r[t]; } if (length(other)) { let r = lat_batch(ep, other, urls[0], 5000); for (let t in keys(r)) delays[t] = r[t]; } let real = {}; if (lat_mode() == "real") { let cand = slice(sort(filter(tcp, (t) => delays[t] > 0), (a, b) => delays[a] - delays[b]), 0, 12); if (cur != "" && delays[cur] > 0 && index(cand, cur) < 0 && addrs[cur]) push(cand, cur); if (length(cand)) { if (snapshot != s(fs.readfile(SB_CONF))) return null; let r = lat_batch(ep, cand, urls[0], 5000); for (let t in cand) real[t] = r[t] ? r[t] : 0; } } let score = (t) => real[t] != null ? (real[t] > 0 ? real[t] : 0) : (delays[t] > 0 ? (length(keys(real)) && addrs[t] ? 100000 + delays[t] : delays[t]) : 0); let ranked = sort(filter(keys(delays), (t) => score(t) > 0), (a, b) => score(a) - score(b)), best = ""; for (let t in keys(real)) if (real[t] == 0 && delays[t] > 0) push(ranked, t); for (let i = 0; i < length(ranked) && i < 6; i++) { if (snapshot != s(fs.readfile(SB_CONF))) return null; if (ranked[i] == cur ? cur_ok : (real[ranked[i]] > 0 || node_alive(ep, ranked[i], urls))) { best = ranked[i]; break; } } let target = cur; if (!cur_ok) target = best; else if (best != "" && best != cur && score(cur) > 0 && score(best) > 0 && score(cur) > score(best) + tol) target = best; if (target == "") return { ok: true, changed: false, dead: true }; if (target != cur) { if (snapshot != s(fs.readfile(SB_CONF)) || !select_proxy(pt, target)) return null; let names = sec_names(cursor(), sec); system("logger -t zapret-manager " + q("Forkozz: авто по пингу — " + (cur_ok ? "нашёлся сервер быстрее" : "сервер не отвечает") + ", секция " + sec + " переключена на самый быстрый из рабочих")); if (!cur_ok) try { fs.writefile(fell_file(sec), s(names[cur] || cur) + "\n"); } catch (e) {} } fs.writefile(ping_state_file(sec), sprintf("%J", { tag: target, at: time() })); return { ok: true, changed: false, switched: target != cur }; } function priority_probe(sec, px, ep, urls, snapshot, tolerance) { let p = px[priority_tag(sec)]; if (!p || type(p.all) != "array") return null; for (let feed in p.all) { let group = px[feed]; if (!group || type(group.all) != "array" || !length(group.all)) return null; let delays = {}; for (let url in urls) { for (let i = 0; i < length(group.all); i += LAT_PAR) { if (snapshot != s(fs.readfile(SB_CONF))) return null; let r = lat_batch(ep, slice(group.all, i, i + LAT_PAR), url, 5000); for (let t in keys(r)) delays[t] = r[t]; } if (length(keys(delays))) break; } let best = ""; for (let t in group.all) if (delays[t] > 0 && (best == "" || delays[t] < delays[best])) best = t; if (best == "") continue; let current = s(group.now); if (delays[current] > 0 && delays[current] <= delays[best] + tolerance) best = current; return { feed, node: best }; } return { feed: "", node: "" }; } function cmd_fallwatch(sec) { let lock = "/tmp/zm-fk-fallback." + sec + ".lock"; if (fs.access(lock)) fs.writefile(lock + "/pid", s(fs.readlink("/proc/self"))); let c = cursor(), m = c.get_all(CFG, sec) || {}; let allow_direct = flag(m.zm_direct_fallback), pingm = ping_on(m); if ((!allow_direct && !flag(m.zm_sub_priority) && !pingm) || flag(m.disabled) || s(m.connection_type) != "proxy") { out({ ok: true }); return; } let snapshot = s(fs.readfile(SB_CONF)), px = proxies(), wrapper = px ? px[fallback_tag(sec)] : null; let g = px ? px[group_tag(sec)] : null; let priority = priority_active(sec, m, px), pingact = !priority && pingm && ping_active(sec, m, px); if (!priority && !pingact && !allow_direct) { out({ ok: true, manual: !!g }); return; } if ((priority && s(g.now) != priority_tag(sec)) || (pingact && s(g.now) != ping_tag(sec))) { if (!select_proxy(group_tag(sec), priority ? priority_tag(sec) : ping_tag(sec))) { out({ ok: false }); return; } px = proxies(); g = px ? px[group_tag(sec)] : null; if (!g) { out({ ok: false }); return; } } if (allow_direct && (!wrapper || type(wrapper.all) != "array")) { out({ ok: false }); return; } let tags = vpn_members(px, group_tag(sec)), ep = clash_ep(); if (!ep || sh("command -v curl") == "") { out({ ok: false }); return; } if (!length(tags)) { out({ ok: true, direct: !!(wrapper && s(wrapper.now) == "direct-out"), empty: true }); return; } let current = fallback_info(sec, px).override || proxy_now(px, s(g.now)), order = index(tags, current) >= 0 ? uniq([ current, ...tags ]) : tags; let urls = uniq([ sec_ext(m).ut_url, "https://www.cloudflare.com/cdn-cgi/trace" ]), healthy = ""; if (pingact) { let r = ping_pick(sec, px, ep, urls, snapshot, int(sec_ext(m).ut_tol)); if (r == null) { out({ ok: false }); return; } if (!allow_direct) { out({ ok: true, ping: true, changed: false }); return; } px = proxies(); g = px ? px[group_tag(sec)] : null; if (!g) { out({ ok: false }); return; } } if (priority) { let chosen = priority_probe(sec, px, ep, urls, snapshot, int(sec_ext(m).ut_tol)); c = cursor(); m = c.get_all(CFG, sec) || {}; px = proxies(); if (!chosen || flag(m.disabled) || flag(m.zm_direct_fallback) != allow_direct || snapshot != s(fs.readfile(SB_CONF)) || !priority_active(sec, m, px)) { out({ ok: false }); return; } let before = proxy_now(px, group_tag(sec)), direct = fallback_info(sec, px).direct; let ensure = (group, tag) => s(px[group] && px[group].now) == tag || select_proxy(group, tag); if (chosen.feed != "") { if (!ensure(chosen.feed, chosen.node) || !ensure(priority_tag(sec), chosen.feed) || !ensure(group_tag(sec), priority_tag(sec))) { out({ ok: false }); return; } if (allow_direct && !ensure(fallback_tag(sec), group_tag(sec))) { out({ ok: false }); return; } out({ ok: true, priority: true, direct: false, changed: direct || before != chosen.node }); } else { if (!ensure(group_tag(sec), priority_tag(sec))) { out({ ok: false }); return; } let changed = allow_direct && !direct && select_proxy(fallback_tag(sec), "direct-out"); out({ ok: !allow_direct || direct || changed, priority: true, direct: allow_direct, changed }); } return; } for (let url in urls) { for (let i = 0; i < length(order); i += LAT_PAR) { if (snapshot != s(fs.readfile(SB_CONF))) { out({ ok: false }); return; } let part = slice(order, i, i + LAT_PAR), delays = lat_batch(ep, part, url, 5000); for (let t in part) if (delays[t] > 0) { healthy = t; break; } if (healthy != "") break; } if (healthy != "") break; } c = cursor(); m = c.get_all(CFG, sec) || {}; px = proxies(); wrapper = px ? px[fallback_tag(sec)] : null; g = px ? px[group_tag(sec)] : null; if (!flag(m.zm_direct_fallback) || flag(m.disabled) || snapshot != s(fs.readfile(SB_CONF)) || !wrapper || !g || priority_active(sec, m, px)) { out({ ok: false }); return; } if (healthy == "") { if (s(wrapper.now) == "direct-out") { out({ ok: true, direct: true }); return; } let changed = select_proxy(fallback_tag(sec), "direct-out"); out({ ok: changed, changed, direct: true }); return; } let selected = s(g.now), target = group_tag(sec), was_direct = s(wrapper.now) == "direct-out"; if (proxy_now(px, selected) != healthy && !proxy_alive(selected)) { let auto = auto_tag(sec, px, g); let pickt = auto && proxy_alive(auto) ? auto : index(g.all, healthy) >= 0 ? healthy : ""; if (pickt != "") { if (!select_proxy(group_tag(sec), pickt)) { out({ ok: false }); return; } } else target = healthy; } if (s(wrapper.now) == target) { out({ ok: true, direct: false }); return; } if (snapshot != s(fs.readfile(SB_CONF))) { out({ ok: false }); return; } let changed = select_proxy(fallback_tag(sec), target); out({ ok: changed, changed: changed && was_direct, direct: false }); } function api_get(ep, path, tmo, params) { let cmd = "curl -s --noproxy '*' --max-time " + int(tmo) + (ep.secret != "" ? " -H " + q("Authorization: Bearer " + ep.secret) : ""); if (params) { cmd += " -G"; for (let k in params) cmd += " --data-urlencode " + q(k + "=" + params[k]); } let j = null; try { j = json(trim(sh(cmd + " " + q(ep.base + path)))); } catch (e) { j = null; } return type(j) == "object" ? j : null; } function api_proxies(ep) { let j = api_get(ep, "/proxies", 6); return j && type(j.proxies) == "object" ? j.proxies : null; } function guard_alive(ep, tag, url) { let tries = [ [ url, 3000 ], [ "http://cp.cloudflare.com/generate_204", 4000 ] ]; for (let t in tries) { let j = api_get(ep, "/proxies/" + urlenc(tag) + "/delay", int(t[1] / 1000) + 2, { url: t[0], timeout: t[1] }); if (j && int(j.delay || 0) > 0) return true; } return false; } function cmd_guard() { let c = cursor(), ep = null, px = null, events = []; for (let x in sections(c, "section")) { let sec = x[".name"]; if (!flag(x.zm_ut_guard) || flag(x.disabled) || flag(x.zm_sub_priority) || s(x.connection_type) != "proxy") continue; if (!ep) { ep = clash_ep(); if (!ep || sh("command -v curl") == "") { out({ ok: false }); return; } px = api_proxies(ep); if (!px) { out({ ok: false }); return; } } let g = px[group_tag(sec)], auto = g ? s(g.now) : ""; if (auto == "" || !px[auto] || lc(s(px[auto].type)) != "urltest") continue; let leaf = proxy_now(px, auto); if (leaf == "" || leaf == auto) continue; let url = sec_ext(x).ut_url; if (guard_alive(ep, leaf, url)) continue; api_get(ep, "/group/" + urlenc(auto) + "/delay", 14, { url, timeout: 5000 }); let px2 = api_proxies(ep), to = px2 ? proxy_now(px2, auto) : leaf, names = sec_names(c, sec); let nm = (t) => s(names[t] || (px[t] && px[t].name) || t), label = trim(s(x.zm_label)) || sec; let ev = { sec, label, from: nm(leaf), to: to != leaf && to != auto ? nm(to) : "" }; push(events, ev); sh("logger -t zapret-manager " + q("Forkozz" + (trim(s(x.zm_label)) != "" ? " («" + label + "»)" : "") + ": сервер «" + ev.from + "» перестал отвечать — " + (ev.to != "" ? "переключились на «" + ev.to + "»" : "другие серверы тоже не ответили, ждём"))); } out({ ok: true, events }); } 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)) { 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), hold: node_pol() == "hold" }); return; } if (node_pol() == "hold") { out({ ok: true, manual: true, dead: true, changed: false, hold: true, name: nm(now) }); return; } let pickt = auto_tag(sec, px, g); 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) {} if (node_pol() == "back") { try { fs.mkdir(STATE); } catch (e) {} try { fs.writefile(home_path(sec), sprintf("%J", { tag: now, name: nm(now), next: time() + 600 })); } catch (e) {} } else home_drop(sec); out({ ok: true, manual: true, dead: true, changed: true, back: node_pol() == "back", name: nm(now), to: px[pickt] && lc(s(px[pickt].type)) == "urltest" ? "" : nm(pickt) }); } function cmd_latency(sec) { if (!proxies()) fail("sing-box не отвечает — Forkozz выключен или ещё запускается"); if (!lat_running(lat_read(sec))) { try { fs.mkdir(STATE); } catch (e) {} fs.writefile(lat_path(sec), sprintf("%J", { at: time(), running: true })); system("(ucode " + q(sourcepath()) + " latwork " + q(sec) + " >/dev/null 2>&1 &)"); } out({ ok: true, started: true }); } function latwork(sec) { let px = proxies(), g = px ? px[group_tag(sec)] : null, seen = {}, order = [], groups = []; function add(t) { if (seen[t]) return; seen[t] = true; let p = px[t]; if (p && type(p.all) == "array") { for (let m in p.all) add(m); push(groups, t); } else push(order, t); } if (g && type(g.all) == "array") for (let t in g.all) add(t); let mode = lat_mode(); let delays = {}, done = {}, err = g ? "" : "группа серверов секции ещё не готова — примените настройки"; let real = {}, blocked = [], rpending = []; let eff = (m) => real[m] != null ? real[m] : (index(blocked, m) >= 0 ? 0 : delays[m]); let save = (running) => { for (let t in groups) { let best = 0, all = true; for (let m in px[t].all) { if (!done[m]) all = false; else { let d = eff(m); if (d > 0 && (!best || d < best)) best = d; } } if (best > 0) delays[t] = best; if (all || best > 0) done[t] = true; } fs.writefile(lat_path(sec), sprintf("%J", { at: time(), running, mode, tested: keys(done), pending: filter([ ...order, ...groups ], (t) => !done[t]), delays, real, blocked, rpending, error: err })); }; if (!g) { save(false); return; } if (sh("command -v curl") == "") { err = "на роутере нет curl — задержку проверить нечем"; save(false); return; } save(true); let addrs = node_addrs(), tcp = filter(order, (t) => !!addrs[t]), other = filter(order, (t) => !addrs[t]); let fails = []; for (let i = 0; i < length(tcp); i += TCP_PAR) { let part = slice(tcp, i, i + TCP_PAR), r = tcp_chunk(addrs, part, 3); for (let t in part) { if (r[t]) { delays[t] = r[t]; done[t] = true; } else push(fails, t); } save(true); } for (let i = 0; i < length(fails); i += TCP_PAR) { let part = slice(fails, i, i + TCP_PAR), r = tcp_chunk(addrs, part, 6); for (let t in part) { if (r[t]) delays[t] = r[t]; done[t] = true; } save(true); } let ep = clash_ep(); if (length(other) && ep) { let r = lat_batch(ep, other, LAT_URLS[0], 5000); for (let t in other) { if (r[t]) delays[t] = r[t]; done[t] = true; } } for (let t in other) done[t] = true; if (mode == "real" && ep) { let cand = sort(filter(tcp, (t) => delays[t] > 0), (a, b) => delays[a] - delays[b]); rpending = slice(cand, 0); save(true); let again = []; for (let i = 0; i < length(cand); i += LAT_PAR) { let part = slice(cand, i, i + LAT_PAR), r = lat_batch(ep, part, LAT_URLS[0], 5000); for (let t in part) { if (r[t]) real[t] = r[t]; else push(again, t); } rpending = filter(rpending, (t) => index(part, t) < 0 || index(again, t) >= 0); save(true); } for (let i = 0; i < length(again); i += LAT_PAR) { let part = slice(again, i, i + LAT_PAR), r = lat_batch(ep, part, LAT_URLS[1], 7000); for (let t in part) { if (r[t]) real[t] = r[t]; else push(blocked, t); } rpending = filter(rpending, (t) => index(part, t) < 0); save(true); } rpending = []; } let alive = 0; for (let t in order) if (eff(t) > 0) alive++; if (!alive && length(order)) err = length(blocked) ? "порты серверов открываются, но трафик ни через один не идёт — похоже, их блокирует провайдер" : "ни один сервер не ответил — проверьте интернет на роутере"; save(false); } function cmd_latwork(sec) { try { latwork(sec); } catch (e) { try { fs.writefile(lat_path(sec), sprintf("%J", { at: time(), running: false, tested: [], delays: {}, error: "проверка прервалась: " + s(e.message || e) })); } catch (x) {} } } function do_select(sec, tag) { if (s(tag) == "") return "не выбран сервер"; let sm = cursor().get_all(CFG, sec) || {}, priority = flag(sm.zm_sub_priority), pingm = ping_on(sm); if (priority || pingm) { let px = proxies(), g = px ? px[group_tag(sec)] : null; if (!g || type(g.all) != "array" || index(g.all, tag) < 0) return "этого сервера нет в секции"; let auto = priority ? priority_tag(sec) : ping_tag(sec), mf = priority ? priority_manual_file(sec) : ping_manual_file(sec); if (tag == auto) { try { fs.unlink(mf); } catch (e) {} } else if (fs.writefile(mf, s(tag)) == null) return "не удалось сохранить ручной выбор сервера"; } let j = jcmd(BIN + " clash_api set_group_proxy " + q(group_tag(sec)) + " " + q(tag)); if (type(j) != "object" || j.error || j.success === false) return s((j && (j.message || j.error)) || "сервер не переключился"); let px = proxies(); let fallback = fallback_info(sec, px); if ((fallback.direct || fallback.override != "") && !select_proxy(fallback_tag(sec), group_tag(sec))) return "сервер выбран, но резервный выход секции не переключился"; if ((priority && tag == priority_tag(sec)) || (pingm && tag == ping_tag(sec))) system("(sh /opt/zapret-manager-luci/backend.sh forkop_priority_watch " + q(sec) + " >/dev/null 2>&1 &)"); try { fs.unlink(fell_file(sec)); } catch (e) {} home_drop(sec); return ""; } function cmd_select(sec, tag) { let e = do_select(sec, tag); if (e != "") fail(e); out({ ok: true }); } function cmd_probe(sec, tag) { if (s(tag) == "") fail("не выбран сервер"); let px = proxies(); if (!px) fail("sing-box не отвечает — Forkozz выключен или ещё запускается"); if (!px[tag]) fail("такого сервера нет — обновите список"); let d = probe_ms(tag, 5000, 2), tcp = 0; if (d <= 0) { let addrs = node_addrs(); if (addrs[tag] && sh("command -v curl") != "") tcp = int(tcp_chunk(addrs, [ tag ], 3)[tag] || 0); } out({ ok: true, alive: d > 0, delay: d, tcp }); } function cmd_home(sec) { let h = home_read(sec); if (!h) { out({ ok: true, home: false }); return; } if (node_pol() != "back") { home_drop(sec); out({ ok: true, home: false }); return; } let px = proxies(), g = px ? px[group_tag(sec)] : null; if (!g || type(g.all) != "array") { out({ ok: false }); return; } if (index(g.all, h.tag) < 0) { home_drop(sec); out({ ok: true, home: false, gone: true }); return; } if (s(g.now) == h.tag) { home_drop(sec); out({ ok: true, home: false }); return; } if (time() < int(h.next || 0)) { out({ ok: true, home: true, wait: true }); return; } h.next = time() + 600; if (!proxy_alive(h.tag)) { try { fs.writefile(home_path(sec), sprintf("%J", h)); } catch (e) {} out({ ok: true, home: true, alive: false }); return; } let e = do_select(sec, h.tag); if (e != "") { try { fs.writefile(home_path(sec), sprintf("%J", h)); } catch (x) {} out({ ok: false, home: true, error: e }); return; } let label = trim(s((cursor().get_all(CFG, sec) || {}).zm_label)); system("logger -t zapret-manager " + q("Forkozz" + (label != "" ? " («" + label + "»)" : "") + ": сервер «" + s(h.name || h.tag) + "» снова отвечает — вернулись на него")); out({ ok: true, home: false, back: true, name: s(h.name || h.tag) }); } 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 = vpn_members(px, group_tag(sec)); 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 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 mlkem_link(l) { return !!match(s(l), /[?&]security=reality([&#]|$)/) && !!match(s(l), /[?&]support-x25519mlkem768=(true|1)([&#]|$)/); } function mlkem_core_ok() { let m = match(sh("sing-box version 2>/dev/null"), /-extended-([0-9]+)\.([0-9]+)\.([0-9]+)/); if (!m) return false; let a = int(m[1]), b = int(m[2]), d = int(m[3]); return a > 2 || (a == 2 && (b > 7 || (b == 7 && d >= 2))); } 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 ms = probe_ms(group_tag(x.name), 5000, 3), what = many ? "Секция «" + x.label + "»" : "Подключение к серверу"; let dead = x.mode == "iface" ? "туннель " + x.iface + " не отвечает — проверьте его на вкладке AmneziaWG" : "сервер не отвечает — проверьте ссылку или выберите другой сервер"; if (ms <= 0 && x.mode == "links" && length(filter(sec_links(c.get_all(CFG, x.name) || {}), mlkem_link)) && !mlkem_core_ok()) dead = "сервер на Xray 26.9.8+ пускает только с ключом X25519MLKEM768, а его отправляет лишь расширенный sing-box 2.7.2 или новее"; 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, "строка", "строки", "строк") + " — идут напрямую, мимо всех секций"); } let ks = cursor().get_all(CFG, "settings") || {}; if (flag(ks.block_leaks) && fs.access(NS_LIB + "/kill_switch.sh")) { let gd = sh("nft list table inet NetShiftGuard >/dev/null 2>&1 && echo y") != ""; add(gd ? "ok" : "warn", "Защита от утечек", gd ? "включена — без VPN трафик выбранных сервисов не уйдёт напрямую" : "включена в настройках, но ещё не работает — перезапустите Forkozz"); } out({ checks }); } 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 = fallback_info(x.name, px).override || s(g.now), cur = now; if (px[now] && (lc(s(px[now].type)) == "urltest" || now == priority_tag(x.name) || now == ping_tag(x.name))) { e.auto = true; cur = proxy_now(px, now); } e.count = length(vpn_members(px, group_tag(x.name))); 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 = s(si.title); } } if (fallback_info(x.name, px).direct) { e.state = "direct"; e.server = "Напрямую"; e.delay = -1; e.fallback_direct = true; } } if (probe && px && e.state != "off" && e.state != "wait") { let ms = probe_ms(fallback_info(x.name, px).enabled ? fallback_tag(x.name) : group_tag(x.name), 4000, 2); e.probed = true; e.state = e.fallback_direct ? "direct" : ms > 0 ? "ok" : "bad"; if (ms > 0) e.delay = ms; } push(r, e); } out({ running: !!px, sections: r }); } 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, source) { if (r.protocol != null || r.port != null || r.inbound == null && r.outbound == null) return false; if (r.type == "logical") return false; if (r.source_ip_cidr != null && source != "" && !cidr_hit(source, as_arr(r.source_ip_cidr))) 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 !has && r.source_ip_cidr != null; } let WHERE_T0 = 0; function where_time() { return time() - WHERE_T0 < 15; } function dom_under(h, d) { return d != "" && (h == d || (length(h) > length(d) && substr(h, length(h) - length(d) - 1) == "." + d)); } function ref_base(u) { let b = replace(replace(s(u), /[?#].*$/, ""), /^.*\//, ""); return b != "" ? b : s(u); } function cache_tag(u) { let n = replace(replace(s(u), /^https?:\/\//, ""), /[^A-Za-z0-9_.-]/g, "_"); return length(n) > 120 ? substr(n, length(n) - 120) : n; } function plain_cache(u, fetch) { if (substr(s(u), 0, 1) == "/") return fs.access(u) ? u : null; let name = RS_CACHE + "/l_" + cache_tag(u), st = fs.stat(name); if (fetch && where_time() && (!st || time() - st.mtime > 86400)) { try { fs.mkdir(RS_CACHE); } catch (e) {} sh("curl -sSL -m 8 -o " + q(name + ".tmp") + " " + q(u) + " && mv -f " + q(name + ".tmp") + " " + q(name)); st = fs.stat(name); } return st ? name : null; } function plain_hit(path, host, ip) { let best = null, bw = -1; for (let l in split(s(fs.readfile(path)), "\n")) { l = lc(trim(replace(l, /[#;].*$/, ""))); if (l == "" || match(l, /[ \t]/) || substr(l, 0, 8) == "include:") continue; if (match(l, /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(\/[0-9]+)?$/)) { if (ip != "" && cidr_hit(ip, l)) { let b = index(l, "/") >= 0 ? int(split(l, "/")[1]) : 32; if (100 + b > bw) { best = l; bw = 100 + b; } } continue; } if (host == "") continue; let w = l, hit = false; if (substr(l, 0, 5) == "full:") { w = substr(l, 5); hit = host == w; } else if (substr(l, 0, 8) == "keyword:") { w = substr(l, 8); hit = w != "" && index(host, w) >= 0; } else if (match(l, /^(regexp|regex|re):/)) { w = replace(l, /^[a-z]+:/, ""); try { hit = !!match(host, regexp(w)); } catch (e) { hit = false; } w = ""; } else { w = replace(replace(l, /^domain:/, ""), /^(\*\.|\.)/, ""); hit = dom_under(host, w); } if (hit && length(w) > bw) { best = l; bw = length(w); } } return best; } function srs_hit(rs, host, ip, fetch) { 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 (fetch && where_time() && s(rs.url) != "" && (!st || time() - st.mtime > 86400)) { try { fs.mkdir(RS_CACHE); } catch (e) {} sh("curl -sSL -m 8 -o " + q(name + ".tmp") + " " + q(rs.url) + " && mv -f " + q(name + ".tmp") + " " + q(name)); st = fs.stat(name); } let f = st ? { path: name, fmt: s(rs.format || "binary") } : null; if (!f) return false; for (let t in [ host, ip ]) { if (t == "") continue; let r = sh("sing-box rule-set match -f " + q(f.fmt) + " " + q(f.path) + " " + q(t) + " 2>&1 | cat"); if (match(r, /(^|\n)[^\n]*match rules/)) return true; } return false; } function url_rs(u) { return { type: "remote", tag: "u_" + cache_tag(u), url: u, format: url_ext(u) == "json" ? "source" : "binary" }; } function where_scan(host, ip, win) { let out = []; if (host == "" && ip == "") return out; if (!WHERE_T0) WHERE_T0 = time(); let c = cursor(), p = pick(c), used = {}, names = {}, items = []; try { items = catalog_items(services_list()); } catch (e) { items = []; } for (let it in items) for (let t in it.targets) if (names[t] == null) names[t] = it.name; let won = false; for (let x in sections(c, "section")) { let n = x[".name"], byp = n == BYPASS; if (!byp && !is_conn(x)) continue; let on = byp || n == p.sec || s(x.disabled) != "1", rf = sec_refs(x); let st = byp ? "x" : on ? "on" : "dis", label = byp ? "" : sec_label(x, n == p.sec); let add = (name, list, entry) => { let e = { state: st, sec: label, name, list, entry: entry ?? "", win: false }; if (!won && on && n == win) { e.win = true; won = true; } push(out, e); }; let own = byp ? "Исключения" : "Свой список"; if (host != "") for (let d in domain_lines(x)) if (dom_under(host, lc(replace(s(d), /^(\*\.|\.)/, "")))) { add(own, "свои домены", d); break; } if (ip != "") for (let sn in subnet_lines(x)) if (cidr_hit(ip, sn)) { add(own, "свои IP и подсети", sn); break; } for (let cn in rf.c) { used["c:" + cn] = true; if (srs_hit({ type: "remote", tag: n + "-" + cn + "-community-ruleset", url: SRS_MAIN + "/" + cn + ".srs", format: "binary" }, host, ip, on)) add(names["c:" + cn] ?? cn, cn + ".srs", ""); else { let xf = plain_cache(PKG_LOCAL_DIR + cn + "_extra.lst", false), xe = xf ? plain_hit(xf, host, ip) : null; if (xe != null) add(names["c:" + cn] ?? cn, cn + "_extra.lst", xe); } } for (let k in [ "s", "r" ]) for (let u in rf[k]) { used[k + ":" + u] = true; if (srs_hit(url_rs(u), host, ip, on)) add(names[k + ":" + u] ?? ref_base(u), ref_base(u), ""); } for (let u in rf.l) { used["l:" + u] = true; let f = plain_cache(u, on), e = f ? plain_hit(f, host, ip) : null; if (e != null) add(names["l:" + u] ?? ref_base(u), ref_base(u), e); } } for (let it in items) { let hit = null; for (let t in it.targets) { if (used[t]) continue; let k = substr(t, 0, 1), v = substr(t, 2); if (k == "l") { let f = plain_cache(v, false), e = f ? plain_hit(f, host, ip) : null; if (e != null) { hit = { list: ref_base(v), entry: e }; break; } } else if (k == "c") { if (srs_hit({ type: "remote", tag: "zmw-" + v + "-community-ruleset", url: SRS_MAIN + "/" + v + ".srs", format: "binary" }, host, ip, false)) { hit = { list: v + ".srs", entry: "" }; break; } } else if (k == "s" || k == "r") { if (srs_hit(url_rs(v), host, ip, false)) { hit = { list: ref_base(v), entry: "" }; break; } } } if (hit) push(out, { state: "off", sec: "", name: it.name, list: hit.list, entry: hit.entry, win: false }); if (length(out) >= 40) break; } return out; } function cmd_route(target, addr, source) { WHERE_T0 = time(); source = trim(s(source)); if (source != "" && ip4n(source) == null) fail("укажите IPv4-адрес устройства, например 192.168.1.100"); let cfgj = null; try { cfgj = json(s(fs.readfile(SB_CONF))); } catch (e) { cfgj = null; } if (type(cfgj) != "object" || type(cfgj.route) != "object") { let t = lc(s(target)), v4 = !!match(t, /^[0-9.]+$/); out({ action: "", outbound: "", section: "", rule: "", where: where_scan(v4 || index(t, ":") >= 0 ? "" : t, v4 ? t : (ip4n(addr) != null && !match(s(addr), /^198\.1[89]\./) ? s(addr) : ""), "") }); 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) : ""); if (isip && match(target, /^198\.1[89]\./)) ip = ""; let fin = (o) => { o.where = where_scan(host, ip, o.action == "bypass" ? BYPASS : s(o.section)); out(o); }; 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; labels[fallback_tag(x.name)] = x; } let px = proxies(); 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, source)) continue; if (r.source_ip_cidr != null && source == "") { fin({ action: "source_required", outbound: "", section: "", rule: "" }); return; } 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; } fin({ action: "block", outbound: "", section: bs ? bs.name : "", rule: bs ? bs.label : "" }); return; } let ob = s(r.outbound); if (ob == "direct-out") { fin({ action: "bypass", outbound: ob, section: BYPASS, rule: "Исключения" }); return; } let x = labels[ob]; if (x && ob == fallback_tag(x.name) && fallback_info(x.name, px).direct) { fin({ action: "fallback_direct", outbound: "direct-out", section: x.name, rule: x.label }); return; } fin({ action: x ? "connection" : "outbound", outbound: ob, section: x ? x.name : "", rule: x ? x.label : ob }); return; } fin({ action: "direct", outbound: "direct-out", section: "", rule: "" }); } function sb_rss() { let pid = trim(split(trim(sh("pidof sing-box")), " ")[0] || ""); if (!match(pid, /^[0-9]+$/)) return null; let m = match(s(fs.readfile("/proc/" + pid + "/status")), /VmRSS:[ \t]*([0-9]+)/); return m ? int(m[1]) * 1024 : null; } function cmd_stats() { let ep = clash_ep(); if (!ep) { out({ ok: false }); return; } let auth = ep.secret != "" ? " -H " + q("Authorization: Bearer " + ep.secret) : ""; let j = jcmd("curl -s --noproxy '*' -m 3" + auth + " " + q(ep.base + "/connections")); if (type(j) != "object" || j.downloadTotal == null) { out({ ok: false }); return; } out({ ok: true, at: time(), down: int(j.downloadTotal || 0), up: int(j.uploadTotal || 0), conns: type(j.connections) == "array" ? length(j.connections) : 0, mem: int(j.memory || 0) > 0 ? int(j.memory) : sb_rss() }); } function cmd_conns() { let ep = clash_ep(); if (!ep) { out({ error: "у sing-box не включён API соединений — перезапустите Forkozz" }); return; } let auth = ep.secret != "" ? " -H " + q("Authorization: Bearer " + ep.secret) : ""; let j = jcmd("curl -s --noproxy '*' -m 4" + auth + " " + q(ep.base + "/connections")); if (type(j) != "object") { out({ error: "sing-box не отдал список соединений" }); return; } let c = cursor(), labels = {}, names = {}; for (let x in sec_list(c, pick(c))) labels[x.name] = x.label; for (let d in devices()) if (d.name != "") names[d.ip] = d.name; let list = []; for (let k in (type(j.connections) == "array" ? j.connections : [])) { let m = type(k.metadata) == "object" ? k.metadata : {}, ch = type(k.chains) == "array" ? k.chains : []; let tag = s(ch[length(ch) - 1]), leaf = s(ch[0]), out_l = ""; if (tag == "direct-out" || tag == "direct" || tag == "") out_l = ""; else { let sn = replace(replace(replace(replace(tag, /-zm-fallback-out$/, ""), /-zm-priority-out$/, ""), /-zm-ping-out$/, ""), /-out$/, ""); out_l = labels[sn] != null ? labels[sn] : sn; } push(list, { src: s(m.sourceIP), dev: names[s(m.sourceIP)] || "", dst: s(m.destinationIP), dport: s(m.destinationPort), host: s(m.host), proto: s(m.network), out: out_l, node: leaf != tag ? leaf : "", rule: s(k.rule), up: int(k.upload || 0), down: int(k.download || 0), start: s(k.start) }); } list = sort(list, (a, b) => a.start > b.start ? -1 : (a.start < b.start ? 1 : 0)); let total = length(list); if (total > 400) list = slice(list, 0, 400); out({ conns: list, total, truncated: total > 400 }); } let mode = ARGV[0] || ""; let sec = ARGV[1] || ""; if (mode != "route" && sec != "" && (ml(sec) || !match(sec, /^[A-Za-z0-9_]+$/))) fail("неверное имя секции"); if (sec == "" && index([ "get", "set", "secdel", "route", "diag", "secnames", "secstate", "stats", "conns", "fallback_config", "guard", "latmode", "nodepol" ], mode) < 0) sec = pick(cursor()).sec; if (mode == "device_setup") cmd_device_setup(); else if (mode == "fallback_config") cmd_fallback_config(); else if (mode == "fallwatch") cmd_fallwatch(sec); else 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 == "latwork") cmd_latwork(sec); else if (mode == "latmode") cmd_latmode(sec); else if (mode == "nodepol") cmd_nodepol(sec); else if (mode == "probe") cmd_probe(sec, ARGV[2]); else if (mode == "home") cmd_home(sec); else if (mode == "select") cmd_select(sec, ARGV[2]); else if (mode == "heal") cmd_heal(sec, ARGV[2] || ""); else if (mode == "guard") cmd_guard(); 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 == "stats") cmd_stats(); else if (mode == "conns") cmd_conns(); else if (mode == "route") cmd_route(ARGV[1], ARGV[2], ARGV[3]); else fail("неизвестная команда"); ZM_INSTALLER_EOF chmod 0644 '/opt/zapret-manager-luci/netshift.uc' cat > '/opt/zapret-manager-luci/b64.uc' << 'ZM_INSTALLER_EOF' import { open } from 'fs'; let a = ARGV; if (a[0] == 'enc') { let f = open(a[1], 'r'); if (!f) exit(1); if (+a[2] > 0) f.seek(+a[2]); let d = f.read(+a[3]); f.close(); print(d ? b64enc(d) : ''); } else if (a[0] == 'dec') { let f = open(a[1], 'r'); if (!f) exit(1); let s = f.read('all'); f.close(); let d = b64dec(replace(s ?? '', /[\r\n ]/g, '')); if (d == null) exit(1); let o = open(a[2], 'w'); if (!o) exit(1); o.write(d); o.close(); } else exit(2); ZM_INSTALLER_EOF chmod 0644 '/opt/zapret-manager-luci/b64.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 "multi"; json_add_string "calls" "string"; 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 "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_add_string "mode" "string"; json_close_object json_add_object "purge_scan"; json_close_object json_add_object "purge_run"; json_add_string "value" "string"; json_close_object json_add_object "backup_list"; json_close_object json_add_object "backup_action"; json_add_string "action" "string"; json_add_string "value" "string"; json_close_object json_add_object "backup_up"; json_add_string "part" "string"; json_add_string "data" "string"; json_close_object json_add_object "backup_down"; json_add_string "id" "string"; json_add_string "off" "string"; json_close_object json_add_object "zm_cleanup"; 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 "upload_part"; json_add_string "content" "string"; json_add_string "part" "string"; json_close_object json_add_object "doh_status"; json_close_object json_add_object "doh_check"; json_close_object json_add_object "doh_probe"; json_add_string "provider" "string"; 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 "doh_failover_set"; json_add_string "value" "string"; json_close_object json_add_object "doh_own_set"; json_add_string "value" "string"; json_close_object json_add_object "dns_net_set"; json_add_string "value" "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 "zapret_opt_set"; json_add_string "opt" "string"; json_close_object json_add_object "system_time_set"; json_add_string "value" "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 "zm_changelog"; json_add_string "mode" "string"; json_close_object json_add_object "zm_changelog_seen"; 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 "reboot_hint_clear"; json_close_object json_add_object "versions"; json_add_string "action" "string"; json_close_object json_add_object "versions_mode"; json_add_string "mode" "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 "term_status"; json_close_object json_add_object "term_action"; json_add_string "action" "string"; json_add_string "mode" "string"; json_close_object json_add_object "tsc_status"; json_close_object json_add_object "tsc_action"; json_add_string "action" "string"; json_add_string "mode" "string"; json_close_object json_add_object "adblock_status"; json_close_object json_add_object "adblock_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_set"; json_add_string "theme" "string"; json_close_object json_add_object "ui_nav_set"; json_add_string "ids" "string"; json_close_object json_add_object "ui_flags_get"; json_close_object json_add_object "ui_flag_set"; json_add_string "name" "string"; json_close_object json_add_object "monitor"; json_close_object json_add_object "monitor_procs"; json_close_object json_add_object "monitor_action"; json_add_string "action" "string"; json_add_string "mode" "string"; json_close_object json_add_object "watch_status"; json_close_object json_add_object "watch_set"; json_add_string "value" "string"; json_close_object json_dump } ZM_BK_DIR="/opt/zapret-manager-luci/backups" ZM_BK_UP="/tmp/zapret-manager-luci/bk.upload" ZM_BK_UC="/opt/zapret-manager-luci/b64.uc" zm_bk_up() { mkdir -p /tmp/zapret-manager-luci case "$1" in first) : > "$ZM_BK_UP" ;; next) [ -f "$ZM_BK_UP" ] || { echo '{"error":"загрузка прервалась — выберите файл ещё раз"}'; return; } ;; *) echo '{"error":"неверная часть файла"}'; return ;; esac case "$2" in *[!A-Za-z0-9+/=]*) rm -f "$ZM_BK_UP"; echo '{"error":"файл передался с ошибками — выберите его ещё раз"}'; return ;; esac printf '%s' "$2" >> "$ZM_BK_UP" [ "$(wc -c < "$ZM_BK_UP")" -gt 33554432 ] && { rm -f "$ZM_BK_UP"; echo '{"error":"файл слишком большой"}'; return; } echo '{"ok":true}' } zm_bk_down() { local f="$ZM_BK_DIR/$1.zmb" off="$2" sz case "$1" in ''|*[!0-9a-z-]*) echo '{"error":"копия не найдена"}'; return ;; esac [ -f "$f" ] || { echo '{"error":"копия не найдена"}'; return; } case "$off" in ''|*[!0-9]*) off=0 ;; esac sz="$(wc -c < "$f" | tr -d ' ')" if command -v ucode >/dev/null 2>&1 && [ -f "$ZM_BK_UC" ]; then printf '{"size":%s,"off":%s,"data":"%s"}\n' "$sz" "$off" "$(ucode "$ZM_BK_UC" enc "$f" "$off" 196608)" elif command -v base64 >/dev/null 2>&1; then printf '{"size":%s,"off":%s,"data":"%s"}\n' "$sz" "$off" "$(dd if="$f" bs=4096 skip=$((off / 4096)) count=48 2>/dev/null | base64 | tr -d '\n')" else echo '{"error":"на роутере нет ucode и base64 — скачать файл не получится"}' fi } 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 ;; multi) json_get_var calls calls; printf '%s' "$calls" | "$BACKEND" multi @stdin ;; 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" ;; 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) json_get_var mode mode; "$BACKEND" system_uninstall_panel "$mode" ;; purge_scan) "$BACKEND" purge_scan ;; purge_run) json_get_var value value; "$BACKEND" purge_run "$value" ;; backup_list) "$BACKEND" backup_list ;; backup_action) json_get_var action action; json_get_var value value; printf '%s' "$value" | "$BACKEND" backup_action "$action" @stdin ;; backup_up) json_get_var part part; json_get_var data data; zm_bk_up "$part" "$data" ;; backup_down) json_get_var id id; json_get_var off off; zm_bk_down "$id" "$off" ;; zm_cleanup) "$BACKEND" zm_cleanup ;; 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 ;; upload_part) json_get_var content content; json_get_var part part; printf '%s' "$content" | "$BACKEND" upload_part "$part" @stdin ;; doh_status) "$BACKEND" doh_status ;; doh_check) "$BACKEND" doh_check ;; doh_probe) json_get_var provider provider; "$BACKEND" doh_probe "$provider" ;; 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" ;; doh_failover_set) json_get_var value value; "$BACKEND" doh_failover_set "$value" ;; doh_own_set) json_get_var value value; "$BACKEND" doh_own_set "$value" ;; dns_net_set) json_get_var value value; "$BACKEND" dns_net_set "$value" ;; test_status) "$BACKEND" test_status ;; test_action) json_get_var action action; json_get_var mode mode; printf '%s' "$mode" | "$BACKEND" test_action "$action" @stdin ;; zapret_opt_set) json_get_var opt opt; "$BACKEND" zapret_opt_set "$opt" ;; system_time_set) json_get_var value value; "$BACKEND" system_time_set "$value" ;; 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 ;; zm_changelog) json_get_var mode mode; "$BACKEND" zm_changelog "$mode" ;; zm_changelog_seen) "$BACKEND" zm_changelog_seen ;; 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 ;; reboot_hint_clear) "$BACKEND" reboot_hint_clear ;; versions) json_get_var action action; "$BACKEND" versions "$action" ;; versions_mode) json_get_var mode mode; "$BACKEND" versions_mode "$mode" ;; 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 ;; term_status) "$BACKEND" term_status ;; term_action) json_get_var action action; json_get_var mode mode; printf '%s' "$mode" | "$BACKEND" term_action "$action" @stdin ;; tsc_status) "$BACKEND" tsc_status ;; tsc_action) json_get_var action action; json_get_var mode mode; printf '%s' "$mode" | "$BACKEND" tsc_action "$action" @stdin ;; monitor) "$BACKEND" monitor ;; monitor_procs) "$BACKEND" monitor_procs ;; monitor_action) json_get_var action action; json_get_var mode mode; printf '%s' "$mode" | "$BACKEND" monitor_action "$action" @stdin ;; watch_status) "$BACKEND" watch_status ;; watch_set) json_get_var value value; "$BACKEND" watch_set "$value" ;; adblock_status) "$BACKEND" adblock_status ;; adblock_action) json_get_var action action; json_get_var mode mode; printf '%s' "$mode" | "$BACKEND" adblock_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_set) json_get_var theme theme; "$BACKEND" ui_theme_set "$theme" ;; ui_nav_set) json_get_var ids ids; "$BACKEND" ui_nav_set "$ids" ;; ui_flags_get) "$BACKEND" ui_flags_get ;; ui_flag_set) json_get_var name name; "$BACKEND" ui_flag_set "$name" ;; *) 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", "multi", "job_status", "log_tail", "system_info", "strategy_list_v", "strategy_list_flowseal", "strategy_list_youtube", "discord_status", "hosts_status", "hosts_file_get", "doh_status", "doh_check", "doh_probe", "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", "zm_changelog", "purge_scan", "backup_list", "mixomo_status", "mixomo_proxies", "mixomo_config_get", "mixomo_warp_status", "bytetube_installed", "health", "versions", "awg_status", "monitor", "monitor_procs", "watch_status", "term_status", "tsc_status", "adblock_status", "steer_status", "forkop_status", "forkop_config_get", "sysinfo_get", "ui_flags_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", "doh_failover_set", "doh_own_set", "dns_net_set", "hosts_replace_geohide", "hosts_reset", "hosts_file_set", "upload_part", "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", "zm_cleanup", "purge_run", "backup_action", "backup_up", "backup_down", "mirror_set", "exclusions_toggle", "exclusions_clear", "nfqws_opt_set", "zapret_list_set", "zapret_list_restore", "tg_action", "tg_restart_all", "tgws_action", "test_action", "zapret_opt_set", "system_time_set", "zm_update_action", "zm_changelog_seen", "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", "monitor_action", "watch_set", "term_action", "tsc_action", "adblock_action", "steer_action", "forkop_config_set", "forkop_action", "sysinfo_run", "jobs_cancel", "ui_theme_set", "ui_nav_set", "ui_flag_set", "versions_mode", "system_reboot", "reboot_hint_clear" ] }, "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/adblock": { "title": "Блокировка рекламы", "order": 45, "action": { "type": "view", "path": "zapret-manager/adblock" } }, "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/tailscale": { "title": "Удалённый доступ", "order": 54, "action": { "type": "view", "path": "zapret-manager/tailscale" } }, "admin/services/zapret-manager/terminal": { "title": "Терминал", "order": 53, "action": { "type": "view", "path": "zapret-manager/terminal" } }, "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" } }, "admin/services/zapret-manager/monitor": { "title": "Монитор ресурсов", "order": 62, "action": { "type": "view", "path": "zapret-manager/monitor" } } } 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 E = (function(raw) { return function() { var a = Array.prototype.slice.call(arguments), i = a.length - 1; if (i >= 1 && (typeof a[i] === 'string' || typeof a[i] === 'number')) a[i] = [ String(a[i]) ]; return raw.apply(null, a); }; })(window.E); var UBUS_TEXT = { 1: 'неверная команда', 2: 'неверный параметр', 3: 'метод не найден', 4: 'не найдено', 5: 'нет данных', 6: 'нет доступа — выйдите и войдите заново', 7: 'роутер не ответил вовремя', 8: 'не поддерживается', 9: 'ошибка', 10: 'нет соединения' }; var MULTI_RX = /^(status|system_info|health|versions|zm_update_status|lan_ip|bytetube_installed|[a-z0-9_]+_status|[a-z0-9_]+_get|strategy_list_[a-z0-9_]+|mixomo_proxies)$/; var MULTI_ARG = /^[A-Za-z0-9_.:@-]{1,64}$/; var MULTI_NO = { system_info: 1, job_status: 1, log_tail: 1 }; var _mq = null, _multiOff = false, _multiFails = 0, _callMulti = null, _inflight = {}; function multiRun(q) { if (!_callMulti) _callMulti = rpc.declare({ object: 'zapret-manager', method: 'multi', params: [ 'calls' ], expect: {} }); var direct = function(it) { it.direct().then(it.ok, it.bad); }; _callMulti(q.map(function(it) { return it.m + (it.a != null ? '\t' + it.a : ''); }).join('\n')).then(function(res) { if (typeof res === 'number') { _multiOff = true; throw new Error('multi: ubus ' + res); } var r = res && res.r; if (!Array.isArray(r) || r.length !== q.length) throw new Error('multi: bad reply'); _multiFails = 0; q.forEach(function(it, i) { var v = r[i]; if (v && typeof v === 'object' && !v.__fail) it.ok(v); else direct(it); }); }).catch(function(e) { var t = String((e && e.message) || e); if (/Method not found|Access denied|-32601|-32002/i.test(t) || ++_multiFails >= 2) _multiOff = true; q.forEach(direct); }); } function multiFlush() { var q = _mq; _mq = null; _inflight = {}; if (!q) return; if (q.length < 2 || _multiOff) { q.forEach(function(it) { it.direct().then(it.ok, it.bad); }); return; } for (var i = 0; i < q.length; i += 12) multiRun(q.slice(i, i + 12)); } 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); var canBatch = MULTI_RX.test(o.method || '') && !MULTI_NO[o.method] && (o.params || []).length <= 1; function single(self, args) { var 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(); } return function() { var self = this, args = arguments, a = args.length ? args[0] : null; if (!canBatch || args.length > 1 || (a != null && a !== '' && !MULTI_ARG.test(String(a)))) return single(self, args); if (_multiOff) return single(self, args); var key = o.method + '\t' + (a != null ? String(a) : ''); var p = _inflight[key]; if (!p) { p = _inflight[key] = new Promise(function(ok, bad) { if (!_mq) { _mq = []; setTimeout(multiFlush, 0); } _mq.push({ m: o.method, a: a != null && a !== '' ? String(a) : null, ok: ok, bad: bad, direct: function() { return single(self, args); } }); }); } return p.then(function(v) { return v && typeof v === 'object' ? JSON.parse(JSON.stringify(v)) : v; }); }; } 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 callTermStatus = zmDeclare({ object: 'zapret-manager', method: 'term_status', expect: {} }); var callTermAction = zmDeclare({ object: 'zapret-manager', method: 'term_action', params: ['action', 'mode'], expect: {} }); var callTscStatus = zmDeclare({ object: 'zapret-manager', method: 'tsc_status', expect: {} }); var callTscAction = zmDeclare({ object: 'zapret-manager', method: 'tsc_action', params: ['action', 'mode'], expect: {} }); var callMonitor = zmDeclare({ object: 'zapret-manager', method: 'monitor', expect: {} }); var callMonitorProcs = zmDeclare({ object: 'zapret-manager', method: 'monitor_procs', expect: {} }); var callMonitorAction = zmDeclare({ object: 'zapret-manager', method: 'monitor_action', params: ['action', 'mode'], expect: {} }); var callWatchStatus = zmDeclare({ object: 'zapret-manager', method: 'watch_status', expect: {} }); var callWatchSet = zmDeclare({ object: 'zapret-manager', method: 'watch_set', params: ['value'], expect: {} }); var callUiFlagsGet = zmDeclare({ object: 'zapret-manager', method: 'ui_flags_get', expect: {} }); var callUiFlagSet = zmDeclare({ object: 'zapret-manager', method: 'ui_flag_set', params: ['name'], expect: {} }); var callAdblockStatus = zmDeclare({ object: 'zapret-manager', method: 'adblock_status', expect: {} }); var callAdblockAction = zmDeclare({ object: 'zapret-manager', method: 'adblock_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 callVersionsMode = zmDeclare({ object: 'zapret-manager', method: 'versions_mode', params: ['mode'], 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 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', params: ['mode'], expect: {} }); var callZmCleanup = zmDeclare({ object: 'zapret-manager', method: 'zm_cleanup', expect: {} }); var callPurgeScan = zmDeclare({ object: 'zapret-manager', method: 'purge_scan', expect: {} }); var callPurgeRun = zmDeclare({ object: 'zapret-manager', method: 'purge_run', params: ['value'], expect: {} }); var callBackupList = zmDeclare({ object: 'zapret-manager', method: 'backup_list', expect: {} }); var callBackupAction = zmDeclare({ object: 'zapret-manager', method: 'backup_action', params: ['action', 'value'], expect: {} }); var callBackupUp = zmDeclare({ object: 'zapret-manager', method: 'backup_up', params: ['part', 'data'], expect: {} }); var callBackupDown = zmDeclare({ object: 'zapret-manager', method: 'backup_down', params: ['id', 'off'], 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 callUploadPart = zmDeclare({ object: 'zapret-manager', method: 'upload_part', params: ['content', 'part'], expect: {} }); function bigSend(text, fn) { if (typeof text !== 'string' || text.length <= 16000) return fn(text); var CH = 16000, parts = [], i = 0; while (i < text.length) { var e = Math.min(i + CH, text.length), c = text.charCodeAt(e - 1); if (e < text.length && c >= 0xD800 && c <= 0xDBFF) e--; parts.push(text.slice(i, e)); i = e; } return parts.reduce(function(p, chunk, k) { return p.then(function(r) { return r && r.error ? r : callUploadPart(chunk, k ? 'next' : 'first'); }); }, Promise.resolve({})).then(function(r) { return r && r.error ? r : fn('@zmupload'); }); } function bigText(call) { return function(text) { return bigSend(text, function(v) { return call(v); }); }; } function bigArg2(call) { return function(a, text) { return bigSend(text, function(v) { return call(a, v); }); }; } var callDohStatus = zmDeclare({ object: 'zapret-manager', method: 'doh_status', expect: {} }); var callDohCheck = zmDeclare({ object: 'zapret-manager', method: 'doh_check', expect: {} }); var callDohProbe = zmDeclare({ object: 'zapret-manager', method: 'doh_probe', params: ['provider'], 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 callDohFailoverSet = zmDeclare({ object: 'zapret-manager', method: 'doh_failover_set', params: ['value'], expect: {} }); var callDohOwnSet = zmDeclare({ object: 'zapret-manager', method: 'doh_own_set', params: ['value'], expect: {} }); var callDnsNetSet = zmDeclare({ object: 'zapret-manager', method: 'dns_net_set', params: ['value'], 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 callZapretOptSet = zmDeclare({ object: 'zapret-manager', method: 'zapret_opt_set', params: ['opt'], expect: {} }); var callSystemTimeSet = zmDeclare({ object: 'zapret-manager', method: 'system_time_set', params: ['value'], expect: {} }); var callZmUpdateStatus = zmDeclare({ object: 'zapret-manager', method: 'zm_update_status', expect: {} }); var callZmUpdateAction = zmDeclare({ object: 'zapret-manager', method: 'zm_update_action', expect: {} }); var callZmChangelog = zmDeclare({ object: 'zapret-manager', method: 'zm_changelog', params: ['mode'], expect: {} }); var callZmChangelogSeen = zmDeclare({ object: 'zapret-manager', method: 'zm_changelog_seen', expect: {} }); function zmUpdateNotify(u) { if (!u || u.error || !u.current) return; try { window.dispatchEvent(new CustomEvent('zm:update', { detail: { current: u.current, latest: u.latest || '', newer: u.newer } })); } catch (e) {} } function zmUpdateFromVersions(d) { var it = ((d && d.items) || []).filter(function(x) { return x && x.name === 'Zapret Manager'; })[0]; if (!it || !it.installed) return null; return { current: it.installed, latest: it.latest || it.installed, newer: !!it.latest && it.latest !== it.installed }; } 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 callRebootHintClear = zmDeclare({ object: 'zapret-manager', method: 'reboot_hint_clear', 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 hd = document.documentElement; if (hd.getAttribute('data-darkmode') === 'true') { hd.classList.add('zm-theme-dark'); 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 lum = function(c) { var m = String(c || '').match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); return m ? (0.299 * (+m[1]) + 0.587 * (+m[2]) + 0.114 * (+m[3])) / 255 : -1; }; var lb = lum(bg), lt = lum(getComputedStyle(document.body).color); if ((lb >= 0 && lb < 0.5) || lt > 0.7) document.documentElement.classList.add('zm-theme-dark'); } catch (e) { } } var ZM_BUILD = '__ZM_BUILD__'; function cssUrl(path) { return L.resource(path) + (/^[0-9]{6,}$/.test(ZM_BUILD) ? '?v=' + ZM_BUILD : ''); } var cacheGuardDone = false; function cacheGuard() { if (cacheGuardDone) return; cacheGuardDone = true; try { if (document.body.classList.contains('zmw-body') || !window.fetch) return; var base = String(L.env.base_url || L.resource('x').replace(/\/x$/, '')), ver = L.env.resource_version; var ls = function(k, v) { try { if (v === undefined) return localStorage.getItem(k); localStorage.setItem(k, v); } catch (e) {} return null; }; var ss = function(k, v) { try { if (v === undefined) return sessionStorage.getItem(k); sessionStorage.setItem(k, v); } catch (e) {} return null; }; var grab = function(url, mode) { return fetch(url, { cache: mode }).then(function(r) { return r.ok ? r.text() : null; }).catch(function() { return null; }); }; var pool = function(list, fn) { var i = 0, run = function() { if (i >= list.length) return Promise.resolve(); var x = list[i++]; return fn(x).then(run, run); }; return Promise.all([ run(), run(), run(), run() ]); }; fetch(base + '/zapret-manager/build.txt?t=' + Date.now(), { cache: 'no-store' }).then(function(r) { return r.ok ? r.text() : ''; }).then(function(txt) { var lines = String(txt || '').split(/\r?\n/).map(function(x) { return x.trim(); }).filter(Boolean); var build = lines.shift(); if (!/^[0-9]{6,}$/.test(build || '')) return; var files = lines.filter(function(p) { return /^[A-Za-z0-9_\/.-]+\.(js|css)$/.test(p) && p.indexOf('..') < 0; }); var urls = []; files.forEach(function(p) { urls.push(base + '/' + p); if (/\.js$/.test(p) && ver != null) urls.push(base + '/' + p + '?v=' + ver); if (/\.css$/.test(p)) urls.push(base + '/' + p + '?v=' + build); }); var reload = function() { var n = +(ss('zm.reload.' + build) || 0); if (n >= 2) return; ss('zm.reload.' + build, String(n + 1)); toast('Панель обновлена — загружаем новую версию', 'info'); setTimeout(function() { location.reload(); }, 600); }; if (/^[0-9]{6,}$/.test(ZM_BUILD) && ZM_BUILD !== build) { return pool(urls, function(u) { return grab(u, 'reload'); }).then(function() { ls('zm.build', build); reload(); }); } if (ls('zm.build') === build) return; var stale = false; return pool(urls, function(u) { return grab(u, 'force-cache').then(function(a) { return grab(u, 'reload').then(function(b) { if (a != null && b != null && a !== b) stale = true; }); }); }).then(function() { ls('zm.build', build); if (stale) reload(); }); }).catch(function() {}); } catch (e) {} } var FLAG_FONT = 'https://cdn.jsdelivr.net/npm/country-flag-emoji-polyfill@0.1/dist/TwemojiCountryFlags.woff2'; function flagsOk() { try { var c = document.createElement('canvas'); c.width = c.height = 24; var x = c.getContext('2d', { willReadFrequently: true }); if (!x) return true; x.textBaseline = 'top'; x.font = '20px sans-serif'; x.fillStyle = '#000'; x.fillText('\uD83C\uDDE8\uD83C\uDDED', 0, 0); var d = x.getImageData(0, 0, 24, 24).data; for (var i = 0; i < d.length; i += 4) if (d[i + 3] && (d[i] !== d[i + 1] || d[i + 1] !== d[i + 2])) return true; return false; } catch (e) { return true; } } function flagFont() { if (document.getElementById('zm-flags') || flagsOk()) return; var fam = ''; try { fam = getComputedStyle(document.body).fontFamily || ''; } catch (e) {} var s = document.createElement('style'); s.id = 'zm-flags'; s.textContent = '@font-face{font-family:"Twemoji Country Flags";unicode-range:U+1F1E6-1F1FF,U+1F3F4,U+E0062-E0063,U+E0065,U+E0067,U+E006C,U+E006E,U+E0073-E0074,U+E0077,U+E007F;src:url("' + FLAG_FONT + '") format("woff2");font-display:swap}' + 'body,.zm-wrap{font-family:"Twemoji Country Flags",' + (fam || 'system-ui,sans-serif') + '}'; document.head.appendChild(s); } var CL_HISTORY = 'https://github.com/StressOzz/Zapret-Manager/blob/main/CHANGELOG.md'; var CL_KINDS = { '➕': [ 'new', 'Новое' ], '⚡': [ 'imp', 'Улучшено' ], '🐞': [ 'fix', 'Исправлено' ], '🗑': [ 'del', 'Убрано' ] }; function clParse(text) { var secs = [], cur = null; function sec(title) { cur = { title: title, items: [] }; secs.push(cur); } String(text || '').split('\n').forEach(function(line, i) { var l = line.replace(/\*\*|`/g, '').replace(/\s+$/, ''), kind = null, k, m; if (!l.trim() || /^-{3,}$/.test(l.trim()) || /^##\s/.test(l)) return; if ((m = /^###\s+(.+)$/.exec(l))) { sec(m[1].trim()); return; } var item = /^\s*[-*+]\s+/.test(l); l = l.replace(/^\s*[-*+]\s+/, '').trim(); for (k in CL_KINDS) if (l.indexOf(k) === 0) { kind = CL_KINDS[k]; l = l.slice(k.length).replace(/^[️\s]+/, ''); break; } if (!item && !kind && l.length <= 40 && i > 0 && (!cur || cur.items.length || !cur.title)) { sec(l); return; } if (!item && !kind && i === 0) return; if (!cur) sec(''); cur.items.push({ kind: kind, text: l }); }); return secs.filter(function(s) { return s.items.length; }); } function changelogDialog(o) { return new Promise(function(resolve) { var wrap, prevFocus = document.activeElement, id = 'zm-cl-' + Date.now(); var blocks = o.blocks || [ { version: o.version, date: o.date, text: o.text } ]; function close(go) { dlgDone(close); document.removeEventListener('keydown', onKey, true); if (wrap && wrap.parentNode) wrap.parentNode.removeChild(wrap); document.documentElement.classList.remove('zm-wn-lock'); try { if (prevFocus && prevFocus.focus) prevFocus.focus({ preventScroll: true }); } catch (e) {} resolve(!!go); } function onKey(ev) { if (ev.key === 'Escape') { ev.preventDefault(); close(false); } } var body = []; blocks.forEach(function(b) { var secs = clParse(b.text); if (!secs.length) return; if (b.version) body.push(E('div', { 'class': 'zm-cl-ver' }, [ b.version, b.date ? E('small', {}, b.date) : '', o.current && b.version === o.current && blocks.length > 1 ? E('span', { 'class': 'zm-cl-cur' }, 'установлена') : '' ])); secs.forEach(function(s) { if (s.title) body.push(E('div', { 'class': 'zm-cl-sec' }, s.title)); body.push(E('ul', { 'class': 'zm-wn-list' }, s.items.map(function(it) { return E('li', { 'class': it.kind ? '' : 'zm-cl-plain' }, [ it.kind ? E('span', { 'class': 'zm-cl-tag zm-cl-' + it.kind[0] }, it.kind[1]) : '', E('div', {}, it.text) ]); }))); }); }); var btns = o.onUpdate ? [ E('button', { 'class': 'cbi-button', 'click': function() { close(false); } }, 'Позже'), E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { close(true); o.onUpdate(); } }, 'Обновить панель') ] : [ E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { close(true); } }, o.okText || 'Понятно') ]; wrap = E('div', { 'class': 'zm-dlg-wrap zm-wn-wrap', 'role': 'dialog', 'aria-modal': 'true', 'aria-labelledby': id, 'click': function(ev) { if (ev.target === wrap) close(false); } }, [ E('div', { 'class': 'zm-card zm-dlg zmw-modal zm-wn zm-wn-info zm-cl', 'tabindex': '-1' }, [ E('div', { 'class': 'zm-wn-body' }, [ E('div', { 'class': 'zm-wn-head' }, [ E('h3', { 'id': id }, o.title) ]), o.lead ? E('p', { 'class': 'zm-wn-lead' }, o.lead) : '' ].concat(body)), E('div', { 'class': 'zm-actions zm-dlg-actions zm-wn-foot zm-cl-foot' + (o.noLink ? ' zm-cl-foot-solo' : '') }, [ o.noLink ? '' : E('a', { 'class': 'zm-cl-all', 'href': CL_HISTORY, 'target': '_blank', 'rel': 'noreferrer' }, 'Вся история изменений'), E('div', { 'class': 'zm-cl-btns' }, btns) ]) ]) ]); document.documentElement.classList.add('zm-wn-lock'); document.body.appendChild(wrap); dlgTrack(close, false); document.addEventListener('keydown', onKey, true); setTimeout(function() { try { wrap.querySelector('.zm-wn').focus({ preventScroll: true }); } catch (e) {} }, 0); }); } function clBlocks(text) { var out = [], cur = null; String(text || '').split('\n').forEach(function(l) { if (/^##\s/.test(l)) { cur = { version: (/\d+\.\d+(?:\.\d+)?/.exec(l) || [ '' ])[0], date: (/\d{2}\.\d{2}\.\d{4}/.exec(l) || [ '' ])[0], lines: [ l ] }; out.push(cur); } else { if (!cur) { cur = { version: '', date: '', lines: [] }; out.push(cur); } cur.lines.push(l); } }); return out.map(function(b) { if (!b.version) { var f = b.lines.filter(function(x) { return x.trim(); })[0] || ''; b.version = (/\d+\.\d+(?:\.\d+)?/.exec(f) || [ '' ])[0]; } return { version: b.version, date: b.date, text: b.lines.join('\n') }; }); } function changelogShow(mode, o) { o = o || {}; return callZmChangelog(mode).then(function(r) { if (mode === 'all') { if (!r || r.error || !r.text) { toast(r && r.error ? r.error : 'Не удалось получить список изменений', 'warning'); return false; } return changelogDialog({ title: 'Список изменений', blocks: clBlocks(r.text), current: r.current, okText: 'Закрыть', noLink: true, lead: r.source === 'local' ? 'GitHub недоступен — показана копия, сохранённая при установке.' : '' }); } if (!r || r.error) { if (mode === 'remote') toast(r && r.error ? r.error : 'Не удалось получить список изменений', 'warning'); return false; } if (mode === 'remote') { if (!r.text || (o.want && r.version !== o.want)) { toast('Список изменений для версии ' + (o.want || '') + ' ещё не опубликован', 'info'); return false; } return changelogDialog({ title: 'Что изменится в ' + r.version, lead: 'Сейчас установлена ' + (o.current || r.current) + '. Настройки после обновления сохранятся.', version: r.version, date: r.date, text: r.text, onUpdate: o.onUpdate }); } if (!r.pending) return false; var match = r.text && r.version === r.current; return changelogDialog({ title: 'Что нового в Zapret Manager ' + r.current, lead: r.from ? 'Панель обновлена: ' + r.from + ' → ' + r.current + (match ? '' : '. Список изменений не загрузился — его можно открыть на GitHub.') : '', version: r.version, date: r.date, text: match ? r.text : '' }).then(function() { callZmChangelogSeen().catch(function() {}); }); }).catch(function() { return false; }); } var _clAuto = false; function changelogAuto() { if (_clAuto) return; _clAuto = true; setTimeout(function() { changelogShow('local'); }, 700); } function injectCss() { cacheGuard(); try { flagFont(); } catch (e) {} detectMissingThemeVar(); changelogAuto(); if (document.getElementById('zm-css')) return; var l = document.createElement('link'); l.id = 'zm-css'; l.rel = 'stylesheet'; l.href = cssUrl('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) && !/(^|[\s(«])не\s+(примен[её]н|установлен|удал[её]н|сгенерирован|сохран[её]н|переключ[её]н|обновл[её]н|заверш[её]н)/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*(ОШИБКА|\[FAIL\])/.test(line)) { div.className = 'zm-log-msg-error'; div.textContent = line; } else if (/^\s*\[ OK \]/.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-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' : ''; var padEl = document.querySelector('.zmw-main') || document.body; if (padEl !== document.body && document.body.style.paddingBottom) document.body.style.paddingBottom = ''; if (padEl.style.paddingBottom !== pad) padEl.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: 'смена зеркала', zm_update: 'обновление панели', htop: 'htop' }; 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; return ask('Остановить все операции?\n\nВсе их процессы будут принудительно завершены. Если прервать установку, её можно просто запустить заново.').then(function(ok) { if (!ok) 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 || '?') + ')'); else if (st.rc !== '0' && st.rc !== '130' && !/(^|\n)\s*(==>\s*)?(ОШИБКА|\[FAIL\]|!!|\s+!)/.test(txt)) renderLog(logEl, txt.replace(/\s+$/, '') + '\n==> ОШИБКА: операция не завершилась (код ' + (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 floatBanner(el) { el.classList.add('zm-float-banner'); el._zmWant = true; setTimeout(function() { if (el._zmWant) dock(el, true); }, 0); return el; } function refreshBanner(message, relogin) { return floatBanner(E('div', { 'class': 'zm-refresh-banner zm-show' }, [ E('span', {}, message || 'Список меню LuCI мог измениться — выйдите и зайдите заново, чтобы увидеть изменения.'), E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { if (relogin === false) { location.reload(); return; } fetch(L.url('admin/logout'), { credentials: 'same-origin' }).catch(function() {}).then(function() { location.reload(); }); } }, relogin === false ? 'Обновить' : 'Выйти из 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)); } var _catWatching = false; function busyToast(message, sub) { var container = toastContainer(); var textEl = E('span', { 'class': 'zm-toast-main' }), subEl = E('span', { 'class': 'zm-toast-sub' }); var el = E('div', { 'class': 'zm-toast zm-toast-busy', 'role': 'status' }, [ E('span', { 'class': 'zm-toast-spin', 'aria-hidden': 'true' }), E('span', { 'class': 'zm-toast-text' }, [ textEl, subEl ]) ]); var closed = false; function put(m, s2) { textEl.textContent = String(m == null ? '' : m); subEl.textContent = s2 ? String(s2) : ''; subEl.style.display = s2 ? '' : 'none'; } put(message, sub); container.appendChild(el); toastFit(); requestAnimationFrame(function() { el.classList.add('zm-toast-show'); }); return { set: function(m, s2) { if (closed) return; put(m, s2); if (!el.parentNode) { toastContainer().appendChild(el); toastFit(); el.classList.add('zm-toast-show'); } }, close: function() { if (closed) return; closed = true; el.classList.remove('zm-toast-show'); setTimeout(function() { el.parentNode && el.parentNode.removeChild(el); }, 250); } }; } function catalogWatch(before, done) { if (_catWatching) return; _catWatching = true; before = before || {}; var tries = 0; function about(c) { return (c.version ? 'версия ' + c.version + ', ' : '') + 'пунктов: ' + (parseInt(c.services, 10) || 0); } function finish(c) { _catWatching = false; if (!c) toast('Роутер не ответил — обновился ли каталог списков, будет видно в его блоке', 'warning'); else if (c.busy) toast('Каталог списков всё ещё обновляется — результат появится в его блоке', 'warning'); else if (c.off) toast('Каталог списков выключен — обновлять нечего', 'warning'); else if (c.error) toast(c.version ? 'Каталог списков не обновился — проверьте доступ к GitHub. Работает прежний: ' + about(c) : 'Каталог списков не скачался — проверьте доступ к GitHub', 'error'); else if (before.version && String(before.version) === String(c.version) && String(before.services) === String(c.services)) toast('Каталог списков проверен — новой версии нет: ' + about(c), 'info'); else toast('Каталог списков обновлён: ' + about(c), 'info'); if (done) done(); } (function step() { setTimeout(function() { tries++; callSteerStatus().then(function(res) { var c = (res && res.catalog) || null; if ((!c || c.busy) && tries < 30) { step(); return; } finish(c); }).catch(function() { if (tries < 30) step(); else finish(null); }); }, tries ? 2000 : 1500); })(); } 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' ], [ /^meta\b/i, '#0866ff', 'M' ], [ /whatsapp/i, '#25d366', 'WA' ], [ /instagram/i, '#e1306c', 'IG' ], [ /facebook/i, '#1877f2', 'FB' ], [ /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|h\.o\.d\.c\.a|хостинг/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; } function svcPickToggle(p, id) { if (p[id]) delete p[id]; else p[id] = true; return p; } var RI_IDS = { geoblock: 1, block: 1, news: 1, anime: 1, porn: 1, youtube: 1, discord: 1, meta: 1, instagram: 1, facebook: 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.extra || E([]), o.right || E('div', { 'class': 'zm-switch' + (on ? ' zm-switch-on' : '') + (o.inc && !o.on ? ' zm-switch-inc' : '') }, [ E('span') ]) ]); } function _smBytes(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 _smDay(sec) { var d = new Date((+sec || 0) * 1000); if (!(+sec > 0) || isNaN(d.getTime())) return ''; return ('0' + d.getDate()).slice(-2) + '.' + ('0' + (d.getMonth() + 1)).slice(-2) + '.' + d.getFullYear(); } function _smWhen(sec) { var d = new Date((+sec || 0) * 1000); if (!(+sec > 0) || isNaN(d.getTime())) return ''; var hm = ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2), now = new Date(); var days = Math.round((new Date(now.getFullYear(), now.getMonth(), now.getDate()) - new Date(d.getFullYear(), d.getMonth(), d.getDate())) / 86400000); return days === 0 ? 'сегодня, ' + hm : days === 1 ? 'вчера, ' + hm : _smDay(sec) + ', ' + hm; } function subMeta(o) { var facts = [], fact = function(k, v, extra) { facts.push(E('div', { 'class': 'zm-sub-fact' }, [ E('span', {}, k), E('b', {}, v) ].concat(extra || []))); }; if (o.used != null) { var total = +o.total || 0, pct = total > 0 ? Math.min(100, Math.round((+o.used || 0) * 100 / total)) : 0; fact('Трафик', _smBytes(o.used) + ' / ' + (total > 0 ? _smBytes(total) : '∞'), total > 0 ? [ E('div', { 'class': 'zm-quota' + (pct >= 90 ? ' zm-quota-high' : '') }, [ E('i', { 'style': 'width:' + pct + '%' }) ]) ] : []); } if (_smDay(o.expire)) { var left = Math.ceil(((+o.expire) * 1000 - Date.now()) / 86400000); fact('Действует до', _smDay(o.expire) + (left < 0 ? ' · истекла' : left <= 7 ? ' · осталось ' + left + ' дн.' : '')); } if (o.count) fact(o.count.label, String(o.count.n) + (o.count.all > o.count.n ? ' из ' + o.count.all : '')); if (_smWhen(o.updated)) fact('Обновлена', _smWhen(o.updated)); if (o.fmt) fact('Формат', String(o.fmt)); var expired = _smDay(o.expire) && (+o.expire) * 1000 < Date.now(); return E('div', { 'class': 'zm-sub-meta' + (expired ? ' zm-sub-expired' : '') }, [ E('div', { 'class': 'zm-sub-title' }, [ String(o.title || 'Подписка') ]), E('div', { 'class': 'zm-sub-facts' }, facts) ]); } 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([]) ]) ]); } var _dlgOpen = []; function _dlgRoute() { return String(location.hash || '').replace(/^#\/?/, '').split(/[/?]/)[0]; } function dlgTrack(close, val) { _dlgOpen.push({ close: close, val: val, route: _dlgRoute() }); } function dlgDone(close) { _dlgOpen = _dlgOpen.filter(function(it) { return it.close !== close; }); } window.addEventListener('hashchange', function() { if (!document.body || !document.body.classList.contains('zmw-body')) return; var r = _dlgRoute(); _dlgOpen.slice().forEach(function(it) { if (it.route !== r) { try { it.close(it.val); } catch (e) {} } }); }); function dialog(o) { o = o || {}; return new Promise(function(done) { var vals = {}, okBtn, wrap, body = E('div', { 'class': 'zm-dlg-body' }); (o.blocks || []).forEach(function(b) { if (b && b.id) vals[b.id] = b.value; }); function close(res) { dlgDone(close); document.removeEventListener('keydown', onKey); if (wrap && wrap.parentNode) wrap.parentNode.removeChild(wrap); done(res); } function onKey(ev) { if (ev.key === 'Escape') close(null); } function allowed() { return !o.canOk || !!o.canOk(vals); } function draw() { body.innerHTML = ''; (o.blocks || []).forEach(function(b) { if (!b || (b.show && !b.show(vals))) return; if (b.type === 'note') { body.appendChild(E('div', { 'class': 'zm-dlg-note' + (b.kind ? ' zm-dlg-note-' + b.kind : '') }, b.text)); } else if (b.type === 'list') { body.appendChild(E('div', { 'class': 'zm-dlg-title' }, b.title)); body.appendChild(E('ul', { 'class': 'zm-dlg-list' }, ((typeof b.items === 'function' ? b.items(vals) : b.items) || []).map(function(t) { return E('li', {}, t); }))); } else if (b.type === 'choice') { body.appendChild(E('div', { 'class': 'zm-dlg-title' }, b.title)); if (b.hint) body.appendChild(E('div', { 'class': 'zm-hint', 'style': 'margin:0 0 4px' }, b.hint)); (b.options || []).forEach(function(x) { var on = vals[b.id] === x.id; body.appendChild(E('div', { 'class': 'zm-dlg-opt' + (on ? ' zm-active' : '') + (x.disabled ? ' zm-dlg-opt-off' : ''), 'click': function() { if (x.disabled || on) return; vals[b.id] = x.id; draw(); } }, [ E('div', { 'class': 'zm-dlg-radio' }, [ E('span') ]), E('div', {}, [ E('div', { 'class': 'zm-swrow-label' }, x.label), x.hint ? E('div', { 'class': 'zm-hint' + (x.warn ? ' zm-dlg-warn' : '') }, x.hint) : E([]) ]) ])); }); } else if (b.type === 'switch') { body.appendChild(swRow(!!vals[b.id], b.label, b.hint, function() { vals[b.id] = !vals[b.id]; draw(); }, b.disabled)); } else if (b.type === 'input') { if (b.title) body.appendChild(E('div', { 'class': 'zm-dlg-title' }, b.title)); var inp = E('input', { 'type': 'text', 'class': 'cbi-input-text zm-dlg-input', 'spellcheck': 'false', 'autocapitalize': 'off', 'autocomplete': 'off', 'placeholder': b.placeholder || '', 'aria-label': b.title || b.placeholder || '' }); inp.value = vals[b.id] == null ? '' : String(vals[b.id]); inp.addEventListener('input', function() { vals[b.id] = inp.value; if (okBtn) { if (allowed()) okBtn.removeAttribute('disabled'); else okBtn.setAttribute('disabled', ''); } }); inp.addEventListener('keydown', function(ev) { if (ev.key === 'Enter') { ev.preventDefault(); if (allowed()) close(vals); } }); body.appendChild(inp); if (b.hint) body.appendChild(E('div', { 'class': 'zm-hint', 'style': 'margin:6px 0 0' }, b.hint)); if (!o._focus) o._focus = inp; } }); if (okBtn) { if (allowed()) okBtn.removeAttribute('disabled'); else okBtn.setAttribute('disabled', ''); } } okBtn = E('button', { 'class': 'cbi-button ' + (o.danger ? 'cbi-button-remove' : 'cbi-button-positive'), 'click': function() { if (allowed()) close(vals); } }, o.okText || 'Продолжить'); wrap = E('div', { 'class': 'zm-dlg-wrap', 'click': function(ev) { if (ev.target === wrap) close(null); } }, [ E('div', { 'class': 'zm-card zm-dlg zmw-modal' }, [ E('h3', {}, o.title || ''), o.intro ? E('p', { 'class': 'zm-dlg-intro' }, o.intro) : E([]), body, o.altText ? E('div', { 'class': 'zm-actions zm-dlg-actions zm-dlg-actions-3' }, [ okBtn, E('button', { 'class': 'cbi-button', 'click': function() { close('alt'); } }, o.altText), E('button', { 'class': 'cbi-button', 'click': function() { close(null); } }, o.cancelText || 'Отмена') ]) : E('div', { 'class': 'zm-actions zm-dlg-actions' }, [ o.cancelText === '' ? E([]) : E('button', { 'class': 'cbi-button', 'click': function() { close(null); } }, o.cancelText || 'Отмена'), okBtn ]) ]) ]); draw(); document.addEventListener('keydown', onKey); document.body.appendChild(wrap); dlgTrack(close, null); setTimeout(function() { try { (o._focus || okBtn).focus(); if (o._focus) o._focus.select(); } catch (e) {} }, 0); }); } function deadNodeDialog(o) { o = o || {}; var noun = o.noun || 'узел', app = o.app || 'Steer'; var Noun = noun.charAt(0).toUpperCase() + noun.slice(1); var cut = function(t) { t = String(t || ''); return t.length > 26 ? t.slice(0, 25) + '…' : t; }; var reason = o.blocked ? 'Порт ' + (noun === 'узел' ? 'узла' : 'сервера') + ' открыт, но трафик через него не идёт — похоже, его блокирует провайдер.' : Noun + ' не ответил на проверку — он выключен, перегружен или недоступен из вашей сети.'; if (o.why) reason += '\nПричина: ' + o.why; var force = o.pol === 'hold' ? 'Если выбрать его всё равно, ' + app + ' останется на нём и ' + (o.failText || 'трафик через туннель не пойдёт') + ', пока он не ответит.' : o.pol === 'switch' ? 'Если выбрать его всё равно и он так и не ответит, ' + app + ' сам перейдёт на рабочий ' + noun + '.' : 'Если выбрать его всё равно и он так и не ответит, ' + app + ' перейдёт на рабочий ' + noun + ' и вернётся на этот, когда он заработает.'; var best = o.best ? 'Выбрать «' + cut(o.best.name) + '»' + (o.best.ms > 0 ? ' · ' + o.best.ms + ' мс' : '') : (o.autoText || ''); return dialog({ title: Noun + ' «' + cut(o.name) + '» не отвечает', intro: reason, blocks: [ { type: 'note', kind: 'warn', text: force } ], okText: best || 'Всё равно выбрать', altText: best ? 'Всё равно выбрать' : '', cancelText: 'Отмена' }).then(function(v) { return v === 'alt' ? 'force' : v ? (best ? 'best' : 'force') : null; }); } var ASK_DANGER = /^(удалить|очистить|убрать|остановить|сбросить|отменить|заменить|свернуть)$/i; function askVerb(title) { var words = String(title).replace(/[«»"()?!.,:;—–]/g, ' ').split(/\s+/), i, w, found = []; for (i = 0; i < words.length; i++) { w = words[i].toLowerCase(); if (w.length > 3 && w !== 'есть' && w !== 'быть' && /(ть|ти|ться)$/.test(w)) found.push(w); } if (found.length > 1 && found[0] === 'отменить') found.shift(); return found.length ? found[0].charAt(0).toUpperCase() + found[0].slice(1) : ''; } function ask(text, o) { o = o || {}; var t = String(text || ''), cut = t.indexOf('\n\n'); var title = (cut < 0 ? t : t.slice(0, cut)).trim(), rest = cut < 0 ? '' : t.slice(cut + 2).trim(); if (!rest && title.length > 70) { var dot = title.search(/[.?!]\s/); if (dot > 0 && dot < title.length - 2) { rest = title.slice(dot + 1).trim(); title = title.slice(0, dot + 1).trim(); } } var verb = o.okText || (/\?\s*$/.test(title) ? askVerb(title) : '') || (/\?\s*$/.test(title) ? 'Да' : 'Продолжить'); var danger = o.danger != null ? !!o.danger : (ASK_DANGER.test(verb) || /несохранённ|будут удалены|пропадут/i.test(t)); return dialog({ title: title, intro: rest, okText: verb, cancelText: o.cancelText || 'Отмена', danger: danger }).then(function(v) { return !!v; }); } function askText(o) { o = o || {}; return dialog({ title: o.title || '', intro: o.intro || '', okText: o.okText || 'Сохранить', blocks: [ { type: 'input', id: 'v', value: o.value || '', placeholder: o.placeholder || '', hint: o.hint || '' } ] }) .then(function(v) { return v ? String(v.v == null ? '' : v.v) : null; }); } function saveBar(o) { o = o || {}; var el = E('div', { 'class': 'zm-savebar', 'style': 'display:none' }); var state = { dirty: false, busy: false }, ap = null, drawn = null, busyT = null; var applyWord = /примен|включ/i.test(o.saveLabel || 'Сохранить и применить'); function running() { return state.busy || !!ap; } function visible() { return state.dirty && !running(); } function busySub() { var what = ((ap && ap.text) || state.text || '').replace(/^Есть несохранённые изменения:?\s*/, '').replace(/^Изменения:\s*/, ''); if (/^\d+$/.test(what)) what = 'изменений: ' + what; return [ what, o.busyHint || '' ].filter(Boolean).join(' · '); } function paintBusy() { if (!running() || !document.body.contains(el._zmPh || el)) { if (busyT) { busyT.close(); busyT = null; } return; } var ctx = el._zmCtx ? el._zmCtx + ': ' : ''; var main = ctx + (o.busyText || (applyWord ? 'Применяем изменения…' : 'Сохраняем изменения…')); if (busyT) busyT.set(main, busySub()); else busyT = busyToast(main, busySub()); } function render() { var vis = visible(); var ctx = el._zmCtx ? el._zmCtx + ': ' : ''; paintBusy(); var key = [ vis, ctx, state.text, state.label ].join('\u0001'); if (key === drawn) return; drawn = key; el.innerHTML = ''; el.style.display = vis ? '' : 'none'; el.classList.remove('zm-savebar-run', 'zm-savebar-done'); if (!vis) return; el.appendChild(E('span', { 'class': 'zm-savebar-dot' })); el.appendChild(E('span', { 'class': 'zm-savebar-text' }, [ ctx + (state.text || o.text || 'Есть несохранённые изменения') ])); el.appendChild(E('div', { 'class': 'zm-savebar-btns' }, [ E('button', { 'class': 'cbi-button', 'click': function() { if (o.onCancel) o.onCancel(); } }, o.cancelLabel || 'Отменить'), E('button', { 'class': 'cbi-button cbi-button-positive', 'click': save }, state.label || o.saveLabel || 'Сохранить и применить') ])); } function place() { var show = visible(); el._zmWant = show; if (show) setTimeout(function() { if (visible()) dock(el, true); }, 0); else dock(el, false); } function finish() { ap = null; } function giveUp(a) { if (ap !== a) return; ap = null; render(); place(); } function save() { if (ap || state.busy) return; var a = ap = { t0: Date.now(), sawBusy: false, text: state.text, wait: false }; render(); place(); var r; try { r = o.onSave ? o.onSave() : undefined; } catch (e) { giveUp(a); throw e; } if (r === false) { giveUp(a); return; } if (r && typeof r.then === 'function') { a.wait = true; r.then(function(v) { a.wait = false; if (v === false) giveUp(a); else if (ap === a && !state.dirty && !state.busy) { finish(); render(); place(); } }, function() { a.wait = false; giveUp(a); }); } setTimeout(function() { if (ap === a && !a.wait && !a.sawBusy && !state.busy && state.dirty) giveUp(a); }, 2500); } el.set = function(dirty, busy, text, label) { state.dirty = !!dirty; state.busy = !!busy; state.text = text || ''; state.label = label || ''; if (ap) { if (state.busy) ap.sawBusy = true; else if (!state.dirty) finish(); else if (ap.sawBusy && !ap.wait) ap = null; } render(); place(); 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 count = E('span', { 'class': 'zm-reboot-count', 'role': 'timer', 'aria-live': 'off' }, '0:00'); var state = E('p', { 'class': 'zm-reboot-text' }, 'Страница переподключится сама.'); var ov = E('div', { 'class': 'zm-reboot-ov', 'role': 'dialog', 'aria-modal': 'true', 'aria-label': 'Роутер перезагружается' }, [ E('div', { 'class': 'zm-reboot-box' }, [ E('span', { 'class': 'zm-reboot-spin', 'aria-hidden': 'true' }), E('h3', { 'class': 'zm-reboot-title' }, 'Роутер перезагружается'), state, E('div', { 'class': 'zm-reboot-meta' }, [ E('span', {}, 'прошло'), count ]) ]) ]); document.body.appendChild(ov); function tick() { var sec = Math.round((Date.now() - t0) / 1000); count.textContent = Math.floor(sec / 60) + ':' + ('0' + sec % 60).slice(-2); if (sec >= 25) state.textContent = sec < 150 ? 'Ждём, когда роутер снова ответит…' : 'Роутер отвечает дольше обычного — подождите ещё немного.'; 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() { return ask('Перезагрузить роутер?\n\nИнтернет пропадёт на 1–2 минуты, потом всё поднимется само.').then(function(ok) { if (!ok) 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; }); } }, 'Перезагрузить'); var el = E('div', { 'class': 'zm-refresh-banner zm-show zm-reboot-banner' }, [ E('span', {}, String(text)), btn ]); el.appendChild(E('button', { 'type': 'button', 'class': 'zm-banner-x', 'title': 'Закрыть', 'aria-label': 'Закрыть', 'click': function() { el._zmWant = false; if (el._zmPh && el._zmPh.parentNode) el._zmPh.parentNode.removeChild(el._zmPh); if (el.parentNode) el.parentNode.removeChild(el); dockSync(); callRebootHintClear().catch(function() {}); } }, '×')); return floatBanner(el); } 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 = ''; clearTimeout(el._t); if (h && h.reboot_hint) { el.appendChild(rebootBanner(h.reboot_hint)); el._t = setTimeout(el.refresh, 125000); } }).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; if (done) done(); 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 exclHit(name, list) { var n = String(name || '').toLowerCase(); return (list || []).some(function(m) { return m && n.indexOf(m.toLowerCase()) !== -1; }); } 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() { return ask('Вернуть все ' + noun + ' в список?').then(function(ok) { if (!ok) 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().replace(/^naive\+https$/, 'naive').replace(/^naive\+quic$/, 'naive quic') : '', 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() { return ask('Убрать сервер «' + (inf.name || inf.host || 'без названия') + '» из списка?').then(function(ok) { if (!ok) 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', 'ozon.ru', 'vk.ru' ]).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))); 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 routeWhere(w, o) { o = o || {}; w = (w || []).filter(function(x) { return x && x.list; }); if (!w.length) return null; var tags = o.tags || { x: 'исключение', on: 'действует', off: 'сервис не выбран' }, max = o.max || 12; var out = w.slice(0, max).map(function(x) { var kids = [ E('b', {}, x.name || x.list) ]; if (x.sec) kids.push(' · ' + x.sec); if (x.name && x.list && x.list !== x.name) kids.push(' · ' + x.list); if (x.entry) { kids.push(' · '); kids.push(E('code', { 'class': 'zm-route-entry' }, x.entry)); } var t = x.win ? (o.winTag || 'сработало') : (tags[x.state] || ''); if (t) kids.push(E('span', { 'class': 'zm-route-tag' + (x.win ? ' zm-route-tag-win' : '') }, t)); return E('div', { 'class': 'zm-route-where' + (x.state !== 'on' && x.state !== 'x' && !x.win ? ' zm-route-where-off' : '') }, kids); }); if (w.length > max) out.push(E('div', { 'class': 'zm-route-where zm-route-where-off' }, 'и ещё списков: ' + (w.length - max))); return out; } var _uiFlags = null; function uiFlags() { if (!_uiFlags) _uiFlags = callUiFlagsGet().then(function(r) { return (r && r.flags) || {}; }).catch(function() { return {}; }); return _uiFlags; } function uiFlagLocal(key, set) { try { if (set) localStorage.setItem('zm.flag.' + key, '1'); return localStorage.getItem('zm.flag.' + key) === '1'; } catch (e) { return !!set; } } function uiFlagSet(key) { uiFlagLocal(key, true); uiFlags().then(function(f) { f[key] = 1; }); return callUiFlagSet(key).catch(function() {}); } function copyText(text, okMsg) { var done = function() { toast(okMsg || 'Скопировано', 'info'); }; var old = function() { var ta = E('textarea', { 'readonly': '', 'style': 'position:fixed;top:-1000px;left:-1000px;opacity:0' }); ta.value = text; document.body.appendChild(ta); ta.select(); var ok = false; try { ok = document.execCommand('copy'); } catch (e) {} document.body.removeChild(ta); if (ok) done(); else toast('Не удалось скопировать — выделите команду и нажмите Ctrl+C', 'warning'); }; if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(text).then(done, old); else old(); } function warnDialog(o) { o = o || {}; return new Promise(function(resolve) { var wrap, okBtn, prevFocus = document.activeElement, id = 'zm-wd-' + Date.now(); var cb = E('input', { 'type': 'checkbox', 'id': id }); function close(go) { dlgDone(close); document.removeEventListener('keydown', onKey, true); if (wrap && wrap.parentNode) wrap.parentNode.removeChild(wrap); document.documentElement.classList.remove('zm-wn-lock'); try { if (prevFocus && prevFocus.focus) prevFocus.focus({ preventScroll: true }); } catch (e) {} resolve(!!go); } function onKey(ev) { if (ev.key === 'Escape' && !o.info) { ev.preventDefault(); close(false); } } function sync() { okBtn.disabled = !cb.checked; okBtn.classList.toggle('zm-wn-btn-off', !cb.checked); } if (o.noCheck) cb.checked = true; okBtn = E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { if (cb.checked) close(true); } }, o.okText || (o.info ? 'Понятно' : 'Продолжить')); cb.addEventListener('change', sync); var ico = o.kind === 'info' ? 'i' : '!'; function li(it) { return E('li', it[3] ? { 'class': 'zm-wn-' + it[3] } : {}, [ E('i', { 'aria-hidden': 'true' }, it[2] || ico), E('div', {}, [ E('b', {}, it[0]), it[1] ? E('span', {}, ' — ' + it[1]) : '' ]) ]); } wrap = E('div', { 'class': 'zm-dlg-wrap zm-wn-wrap', 'role': 'dialog', 'aria-modal': 'true', 'aria-labelledby': id + '-t', 'click': function(ev) { if (ev.target === wrap && !o.info) close(false); } }, [ E('div', { 'class': 'zm-card zm-dlg zmw-modal zm-wn' + (o.kind === 'info' ? ' zm-wn-info' : ''), 'tabindex': '-1' }, [ E('div', { 'class': 'zm-wn-body' }, [ E('div', { 'class': 'zm-wn-head' }, [ E('div', { 'class': 'zm-wn-ico', 'aria-hidden': 'true' }, ico), E('h3', { 'id': id + '-t' }, o.title || '') ]), o.lead ? E('p', { 'class': 'zm-wn-lead' }, o.lead) : '', (o.items || []).length ? E('ul', { 'class': 'zm-wn-list' }, o.items.map(li)) : '', E('div', {}, (o.groups || []).filter(function(g) { return g && g.items && g.items.length; }).map(function(g) { return E('div', { 'class': 'zm-wn-group' }, [ E('div', { 'class': 'zm-wn-gt' }, g.title), E('ul', { 'class': 'zm-wn-list' }, g.items.map(li)) ]); })), o.tip ? E('div', { 'class': 'zm-wn-tip' }, [ o.tip.title ? E('b', {}, o.tip.title + ' ') : '', o.tip.text || '' ]) : '', o.note ? E('div', { 'class': 'zm-dlg-note zm-dlg-note-' + (o.noteKind || 'warn') }, o.note) : '', o.noCheck ? '' : E('label', { 'class': 'zm-wn-check', 'for': id }, [ cb, E('span', {}, o.check || 'Понимаю') ]) ]), E('div', { 'class': 'zm-actions zm-dlg-actions zm-wn-foot' }, o.info || o.noCheck ? [ okBtn ] : [ E('button', { 'class': 'cbi-button', 'click': function() { close(false); } }, 'Отмена'), okBtn ]) ]) ]); sync(); document.documentElement.classList.add('zm-wn-lock'); document.body.appendChild(wrap); dlgTrack(close, false); document.addEventListener('keydown', onKey, true); setTimeout(function() { try { wrap.querySelector('.zm-wn').focus({ preventScroll: true }); } catch (e) {} }, 0); }); } function warnSeen(key) { if (uiFlagLocal(key)) return Promise.resolve(true); return uiFlags().then(function(f) { if (f[key]) uiFlagLocal(key, true); return !!f[key]; }); } function warnOnce(key, open) { return warnSeen(key).then(function(seen) { if (seen) return true; return Promise.resolve(open()).then(function(ok) { if (ok) uiFlagSet(key); return !!ok; }); }); } var TS_CMD = 'netsh int tcp set global timestamps=enabled'; function tsWarn(o) { o = o || {}; return new Promise(function(resolve) { var wrap, okBtn, prevFocus = document.activeElement; var cb = E('input', { 'type': 'checkbox', 'id': 'zm-ts-agree' }); function close(go) { dlgDone(close); document.removeEventListener('keydown', onKey, true); if (wrap && wrap.parentNode) wrap.parentNode.removeChild(wrap); document.documentElement.classList.remove('zm-wn-lock'); try { if (prevFocus && prevFocus.focus) prevFocus.focus({ preventScroll: true }); } catch (e) {} resolve(!!go); } function onKey(ev) { if (ev.key === 'Escape' && !o.info) { ev.preventDefault(); close(false); } } function sync() { okBtn.disabled = !cb.checked; okBtn.classList.toggle('zm-wn-btn-off', !cb.checked); } function kbd(t) { return E('kbd', { 'class': 'zm-kbd' }, t); } var cmd = E('div', { 'class': 'zm-ts-cmd' }, [ E('code', {}, TS_CMD), E('button', { 'type': 'button', 'class': 'cbi-button', 'click': function() { copyText(TS_CMD, 'Команда скопирована'); } }, 'Скопировать') ]); var parts = (o.parts || []).filter(Boolean); okBtn = E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { if (cb.checked) close(true); } }, o.okText || (o.info ? 'Понятно' : 'Применить стратегию')); cb.addEventListener('change', sync); wrap = E('div', { 'class': 'zm-dlg-wrap zm-wn-wrap', 'role': 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'zm-ts-title', 'click': function(ev) { if (ev.target === wrap && !o.info) close(false); } }, [ E('div', { 'class': 'zm-card zm-dlg zmw-modal zm-wn zm-ts', 'tabindex': '-1' }, [ E('div', { 'class': 'zm-wn-body' }, [ E('div', { 'class': 'zm-wn-head' }, [ E('div', { 'class': 'zm-wn-ico', 'aria-hidden': 'true' }, '!'), E('h3', { 'id': 'zm-ts-title' }, o.title || (o.info ? 'Автоподбор поставил стратегию с меткой ts' : 'Перед применением: настройте Windows')) ]), o.lead ? E('p', { 'class': 'zm-wn-lead', 'style': 'margin-bottom:8px' }, o.lead) : '', E('p', { 'class': 'zm-wn-lead', 'style': 'margin-bottom:8px' }, [ 'Стратегия с меткой ', E('b', {}, 'ts') ].concat( parts.length ? [ ' (', E('span', { 'class': 'zm-ts-where' }, parts.map(function(t) { return E('span', {}, t); })), ')' ] : [], [ ' на компьютерах с Windows работает, только если включены метки времени TCP. Телефонам, Mac и ТВ ничего делать не нужно.' ])), E('ol', { 'class': 'zm-ts-steps' }, [ E('li', {}, [ E('div', {}, [ kbd('Win'), ' + ', kbd('X'), ' → ', E('b', {}, '«Терминал (администратор)»'), ', вставьте команду и нажмите ', kbd('Enter'), ':', cmd ]) ]), E('li', {}, [ E('div', {}, [ 'Ответ ', E('b', {}, '«ОК.»'), ' — готово, перезагрузка не нужна. Один раз на каждом компьютере.' ]) ]) ]), o.note ? E('div', { 'class': 'zm-dlg-note zm-dlg-note-warn' }, o.note) : '', E('label', { 'class': 'zm-wn-check', 'for': 'zm-ts-agree' }, [ cb, E('span', {}, 'Понимаю: на компьютерах с Windows нужно выполнить эту команду') ]) ]), E('div', { 'class': 'zm-actions zm-dlg-actions zm-wn-foot' }, o.info ? [ okBtn ] : [ E('button', { 'class': 'cbi-button', 'click': function() { close(false); } }, 'Отмена'), okBtn ]) ]) ]); sync(); document.documentElement.classList.add('zm-wn-lock'); document.body.appendChild(wrap); dlgTrack(close, false); document.addEventListener('keydown', onKey, true); setTimeout(function() { try { wrap.querySelector('.zm-wn').focus({ preventScroll: true }); } catch (e) {} }, 0); }); } function hostsWarn(name) { var href = document.body.classList.contains('zmw-body') ? '#/hosts' : L.url('admin/services/zapret-manager/hosts'); var key = 'hosts_warn_' + String(name).toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, ''); var want = false, hidden = uiFlagLocal(key); 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 + '.' ]), E('button', { 'type': 'button', 'class': 'cbi-button zm-hosts-warn-ok', 'click': function() { hidden = true; paint(); uiFlagSet(key); toast('Это предупреждение больше не появится', 'info'); } }, 'Понятно, больше не показывать') ]); function paint() { el.style.display = want && !hidden ? '' : 'none'; } uiFlags().then(function(f) { if (f[key]) { hidden = true; paint(); } }); el.set = function(on) { want = !!on; paint(); 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, floatBanner: floatBanner, subMeta: subMeta, pageHref: function(id) { return document.body.classList.contains('zmw-body') ? '#/' + id : L.url('admin/services/zapret-manager/' + id); }, toast: toast, notifyStrategyResult: notifyStrategyResult, status: callStatus, systemInfo: callSystemInfo, zapretAction: callZapretAction, 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, zmCleanup: callZmCleanup, purgeScan: callPurgeScan, purgeRun: callPurgeRun, backupList: callBackupList, backupAction: callBackupAction, backupUp: callBackupUp, backupDown: callBackupDown, sysinfoRun: callSysinfoRun, sysinfoGet: callSysinfoGet, mirrorStatus: callMirrorStatus, mirrorSet: callMirrorSet, exclusionsStatus: callExclusionsStatus, exclusionsToggle: callExclusionsToggle, exclusionsClear: callExclusionsClear, nfqwsOptGet: callNfqwsOptGet, zapretListsStatus: callZapretListsStatus, zapretListGet: callZapretListGet, zapretListSet: bigArg2(callZapretListSet), zapretListRestore: callZapretListRestore, nfqwsOptSet: bigText(callNfqwsOptSet), tgStatus: callTgStatus, tgAction: callTgAction, tgRestartAll: callTgRestartAll, tgwsStatus: callTgwsStatus, tgwsAction: callTgwsAction, hostsStatus: callHostsStatus, hostsToggle: callHostsToggle, hostsReplaceGeohide: callHostsReplaceGeohide, hostsReset: callHostsReset, hostsFileGet: callHostsFileGet, hostsFileSet: bigText(callHostsFileSet), dohStatus: callDohStatus, dohCheck: callDohCheck, dohProbe: callDohProbe, dohInstall: callDohInstall, dohRemove: callDohRemove, dohSet: callDohSet, dohBootstrapSet: callDohBootstrapSet, dohForceSet: callDohForceSet, dohFailoverSet: callDohFailoverSet, dohOwnSet: callDohOwnSet, dnsNetSet: callDnsNetSet, testStatus: callTestStatus, testAction: bigArg2(callTestAction), zapretOptSet: callZapretOptSet, systemTimeSet: callSystemTimeSet, testResults: callTestResults, zmUpdateStatus: callZmUpdateStatus, changelogShow: changelogShow, zmUpdateAction: callZmUpdateAction, zmUpdateNotify: zmUpdateNotify, zmUpdateFromVersions: zmUpdateFromVersions, mixomoStatus: callMixomoStatus, mixomoAction: callMixomoAction, mixomoConfigGet: callMixomoConfigGet, mixomoConfigSet: bigText(callMixomoConfigSet), mixomoApplySubscription: callMixomoApplySubscription, mixomoClearSubscription: function() { return callMixomoApplySubscription('clear'); }, mixomoFilterSet: callMixomoFilterSet, mixomoProxies: callMixomoProxies, mixomoMagitrickleListSet: callMixomoMagitrickleListSet, mixomoAutorestartSet: callMixomoAutorestartSet, mixomoUiAction: callMixomoUiAction, mixomoWarpStatus: callMixomoWarpStatus, mixomoWarpAction: callMixomoWarpAction, mixomoWarpIntegrateAction: callMixomoWarpIntegrateAction, mixomoWarpConfigSet: bigText(callMixomoWarpConfigSet), health: callHealth, awgStatus: callAwgStatus, awgAction: bigArg2(callAwgAction), termStatus: callTermStatus, termAction: bigArg2(callTermAction), tscStatus: callTscStatus, tscAction: bigArg2(callTscAction), monitor: callMonitor, monitorProcs: callMonitorProcs, monitorAction: callMonitorAction, watchStatus: callWatchStatus, watchSet: callWatchSet, adblockStatus: callAdblockStatus, adblockAction: bigArg2(callAdblockAction), steerStatus: callSteerStatus, catalogWatch: catalogWatch, versions: callVersions, versionsMode: callVersionsMode, steerAction: bigArg2(callSteerAction), boardInfo: callBoardInfo, forkopStatus: callForkopStatus, forkopConfigGet: callForkopConfigGet, forkopConfigSet: bigText(callForkopConfigSet), forkopAction: bigArg2(callForkopAction), dock: dock, riCovers: riCovers, svcPickToggle: svcPickToggle, svcIco: svcIco, riNote: riNote, swRow: swRow, dialog: dialog, deadNodeDialog: deadNodeDialog, ask: ask, askText: askText, saveBar: saveBar, exclParse: exclParse, exclHit: exclHit, hidePicker: hidePicker, linksEditor: linksEditor, linkInfo: linkInfo, routeCheck: routeCheck, routeWhere: routeWhere, rebootBanner: rebootBanner, rebootRouter: rebootRouter, rebootSlot: rebootSlot, hostsWarn: hostsWarn, tsWarn: tsWarn, busyToast: busyToast, warnDialog: warnDialog, warnOnce: warnOnce, warnSeen: warnSeen, copyText: copyText, uiFlags: uiFlags, uiFlagSet: uiFlagSet, 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'; var E = (function(raw) { return function() { var a = Array.prototype.slice.call(arguments), i = a.length - 1; if (i >= 1 && (typeof a[i] === 'string' || typeof a[i] === 'number')) a[i] = [ String(a[i]) ]; return raw.apply(null, a); }; })(window.E); 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 {}; }), zm.backupList().catch(function() { return {}; }) ]); }, render: function(all) { 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 bkData = all[8] || {}; 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 bkWhen(at) { var t = new Date((+at || 0) * 1000), n = new Date(), p2 = function(x) { return (x < 10 ? '0' : '') + x; }, hm = p2(t.getHours()) + ':' + p2(t.getMinutes()); var day = new Date(n.getFullYear(), n.getMonth(), n.getDate()).getTime(), dd = new Date(t.getFullYear(), t.getMonth(), t.getDate()).getTime(); return dd === day ? 'сегодня ' + hm : day - dd === 864e5 ? 'вчера ' + hm : p2(t.getDate()) + '.' + p2(t.getMonth() + 1) + '.' + t.getFullYear(); } function renderOverview(d, doh, hosts, sys, h, bk) { var hostsAi = (hosts.items || []).filter(function(it) { return /^ai_/.test(it.id); }); var hostsOther = (hosts.items || []).filter(function(it) { return !/^ai_/.test(it.id); }); var hostsEnabled = hostsOther.filter(function(it) { return it.enabled; }).length + (hostsAi.some(function(it) { return it.enabled; }) ? 1 : 0); var hostsTotal = hostsOther.length + (hostsAi.length ? 1 : 0); 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', { 'style': 'display:inline-flex; gap:8px; flex-wrap:wrap; align-items:center' }, [ E('span', {}, [ String(d.strategy) ]), d.rkn_on ? E('span', { 'class': 'zm-badge zm-ok' }, [ E('span', { 'class': 'zm-dot' }), 'по спискам РКН' ]) : d.ulist_on ? E('span', { 'class': 'zm-badge zm-ok' }, [ E('span', { 'class': 'zm-dot' }), 'по вашему списку' ]) : '', d.testing ? E('span', { 'class': 'zm-badge zm-warn', 'title': 'Пока идёт тест, роутер по очереди пробует стратегии. Потом вернётся ваша' }, [ E('span', { 'class': 'zm-dot' }), 'идёт тест' + (d.test_now ? ': сейчас ' + d.test_now : '') ]) : '' ]))); 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 = /^iface:/.test(h.steer_exit || '') ? 'через ' + h.steer_exit.slice(6) : { 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, '', (h.steer_fail ? 'туннель не принят' : 'не работает') + (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 : h.awg_total ? 'установлен' : 'установлен, туннелей нет', '') : awgSt === 3 ? warnBadge(h.awg_total ? 'работает ' + (h.awg_up || 0) + ' из ' + h.awg_total : 'установлен не полностью') : awgSt === 2 ? zm.badge(false, '', 'туннели не отвечают') : awgSt === 5 ? offBadge('установлен, туннелей нет') : zm.badge(false, '', 'не установлен'))); var tsSt = +h.tsc || 0; items.push(row('Удалённый доступ', tsSt === 1 ? zm.badge(true, 'работает', '') : tsSt === 3 ? warnBadge('нужно войти в Tailscale') : tsSt === 2 ? zm.badge(false, '', 'не работает') : tsSt === 5 ? offBadge('выключен') : zm.badge(false, '', 'не установлен'))); var trSt = h && h.dots && typeof h.dots.term === 'number' ? h.dots.term : 0; items.push(row('Терминал', trSt === 1 ? zm.badge(true, 'работает', '') : trSt === 2 ? zm.badge(false, '', 'не работает') : trSt === 4 ? offBadge('выключен') : zm.badge(false, '', 'не установлен'))); items.push(row('htop', h.htop ? zm.badge(true, 'установлен', '') : zm.badge(false, '', 'не установлен'))); items.push(row('Домены в hosts', hosts.geohide ? zm.badge(true, hosts.geohide === 'malw' ? 'dns.malw.link' : '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', {}, '—'))); var abSt = h && h.dots && typeof h.dots.adblock === 'number' ? h.dots.adblock : 0; items.push(row('Блокировка рекламы', abSt === 1 ? zm.badge(true, 'работает', '') : abSt === 2 ? warnBadge('список не подключён') : offBadge('выключена'))); var bkItems = (bk && bk.items) || []; items.push(row('Резервная копия', E('span', { 'style': 'display:inline-flex; gap:8px; flex-wrap:wrap; align-items:center' }, [ !bk || !bk.items ? E('span', {}, '—') : bkItems.length ? zm.badge(true, 'копий: ' + bkItems.length + ' · последняя ' + bkWhen(bkItems[0].at), '') : offBadge('копий нет'), bk && bk.items && bk.auto === false ? E('span', { 'class': 'zm-badge zm-off', 'title': 'Копия перед установкой, удалением и обновлением не сохраняется сама' }, [ E('span', { 'class': 'zm-dot' }), 'авто выкл' ]) : '' ]))); 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 || '—') ])) ]; if (sysInfo.mirror_host && sysInfo.mirror_host !== 'downloads.openwrt.org') { var mAt = +sysInfo.mirror_at > 0 ? new Date(sysInfo.mirror_at * 1000) : null; var mWhen = mAt ? ('0' + mAt.getDate()).slice(-2) + '.' + ('0' + (mAt.getMonth() + 1)).slice(-2) : ''; rows.push(row('Зеркало OpenWrt', E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap' }, [ E('span', { 'title': String(sysInfo.mirror_host) }, [ String(sysInfo.mirror || sysInfo.mirror_host) ]), sysInfo.mirror_how === 'auto' ? E('span', { 'class': 'zm-badge zm-warn', 'title': 'Основной репозиторий отвечал с ошибками — панель переключилась сама' }, [ E('span', { 'class': 'zm-dot' }), 'автовыбор' + (mWhen ? ' · ' + mWhen : '') ]) : E('span', { 'class': 'zm-badge zm-off' }, [ E('span', { 'class': 'zm-dot' }), 'вручную' ]) ]))); } 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)); 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 === 'nodns' ? E('span', { 'class': 'zm-badge zm-warn', 'title': 'Сайты по IP-адресу отвечают, а имена сайтов роутер найти не может — поэтому панель ничего не скачивает' }, [ E('span', { 'class': 'zm-dot' }), 'есть, но не работает DNS роутера' ]) : 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' }), 'проверяем…' ]))); if (sysInfo.inet === 'nodns') extras.push(E('p', { 'class': 'zm-hint', 'style': 'margin:4px 0 0' }, 'Откройте вкладку «DNS over HTTPS»: выберите другой сервер или выключите его. Если DNS не трогали — перезагрузите роутер.')); 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 {}; }), zm.backupList().catch(function() { return {}; }) ]).then(function(res) { overviewEl.innerHTML = ''; overviewEl.appendChild(renderOverview(res[0], res[1], res[2], res[3], res[4], res[5])); if (res[4] && typeof res[4].zapret === 'number') { try { window.dispatchEvent(new CustomEvent('zm:health', { detail: res[4] })); } catch (e) {} } }); } var verEl = E('div', {}); var verTimer = null, verData = null; function renderVersions() { verEl.innerHTML = ''; var d = verData || { items: [], pending: true }; var items = d.items || []; var checked = +d.at > 0; var card = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Версии') ]); if (!items.length) { card.appendChild(E('p', { 'class': 'zm-hint' }, d.pending ? 'Проверяем версии установленных пакетов…' : d.ts === '' && !checked ? 'Версии ещё не проверялись — нажмите «Проверить».' : 'Пакеты 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 || '') ]), !it.latest && !checked ? E('span', { 'class': 'zm-badge zm-off', 'title': 'Нажмите «Проверить», чтобы узнать, есть ли версия новее' }, [ E('span', { 'class': 'zm-dot' }), 'не проверялась' ]) : 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)) ])); } var mm = ('0' + (parseInt(d.minute, 10) || 0)).slice(-2); var how = d.mode === 'open' ? 'версии проверяются при каждом открытии Дашборда' : d.mode === 'button' ? 'новые версии ищутся только по этой кнопке' : d.bg === false ? 'фоновые проверки выключены — проверка при открытии Дашборда раз в сутки' : 'роутер сам проверяет раз в день, около 04:' + mm; var said = d.pending ? 'Проверяем новые версии…' : checked && d.ts ? 'Проверено: ' + d.ts + ' · ' + how : d.mode === 'button' ? 'Новые версии ищутся только по этой кнопке — так роутер не нагружается' : d.mode === 'open' ? 'Версии ещё не проверялись · ' + how : 'Версии ещё не проверялись — роутер проверит сам около 04:' + mm + ', или нажмите «Проверить»'; 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 ? 'Проверяем…' : checked ? 'Проверить снова' : 'Проверить'), E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, [ said + ' · ', E('a', { 'href': document.body.classList.contains('zmw-body') ? '#/system' : L.url('admin/services/zapret-manager/system'), 'title': 'Система → Поиск новых версий', 'style': 'color:inherit; text-decoration:underline; text-underline-offset:2px' }, 'изменить') ]) ])); verEl.appendChild(card); } function loadVersions(action) { if (verTimer) { clearTimeout(verTimer); verTimer = null; } if (action === 'refresh' && verData) { verData.pending = true; renderVersions(); } (zm.versions ? zm.versions(action || '') : Promise.resolve({ items: [] })).then(function(res) { verData = res || { items: [] }; renderVersions(); if (!verData.pending) applyZmUpdate(zm.zmUpdateFromVersions(verData)); if (verData.pending && document.body.contains(verEl)) verTimer = setTimeout(function() { loadVersions(''); }, 4000); }).catch(function() { verData = { items: (verData && verData.items) || [] }; renderVersions(); }); } overviewEl.appendChild(renderOverview(data, dohData, hostsData, sysData, healthData, bkData)); renderCards(); var updateEl = E('div', {}); var updateLog = E('pre', { 'class': 'zm-log' }); wrap.appendChild(updateEl); wrap.appendChild(updateLog); if (!document.body.classList.contains('zmw-body')) wrap.appendChild(zm.alertBanners(healthData, function() { location.reload(); })); if (!document.body.classList.contains('zmw-body')) wrap.appendChild(E('div', { 'class': 'zm-header' }, [ E('h2', {}, 'Zapret Manager для LuCI и Web'), 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(); try { window.dispatchEvent(new CustomEvent('zm:sysinfo', { detail: { sys: r[0] && !r[0].error ? r[0] : null, board: r[1] && r[1].memory ? r[1] : null } })); } catch (e) {} if (sysTick % 3 === 0) refreshOverview(); if (sysTick % 60 === 0) zm.zmUpdateStatus().then(applyZmUpdate).catch(function() {}); }).catch(function() {}).then(function() { sysBusy = false; }); }, 10000); var zmUpdateBusy = false; function applyZmUpdate(u) { if (!u || u.error || !u.current) return; var was = [ zmUpdate.current, zmUpdate.latest, zmUpdate.newer ].join('|'); zmUpdate = { current: u.current, latest: u.latest || '', newer: u.newer }; zm.zmUpdateNotify(zmUpdate); if (zmUpdateBusy || was === [ zmUpdate.current, zmUpdate.latest, zmUpdate.newer ].join('|')) return; renderZmUpdate(); } 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(zm.floatBanner(E('div', { 'class': 'zm-refresh-banner zm-show' }, [ E('span', {}, [ 'Доступна новая версия панели Zapret Manager: ' + zmUpdate.latest + ' (у вас установлена ' + zmUpdate.current + ').' ]), E('button', { 'class': 'cbi-button zm-cl-link', 'type': 'button', 'click': function() { var upd = this.parentNode.querySelector('.cbi-button-positive'); zm.changelogShow('remote', { want: zmUpdate.latest, current: zmUpdate.current, onUpdate: upd ? function() { upd.click(); } : null }); } }, 'Что изменится'), 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) { if (res.error) { zmUpdateBusy = false; zm.toast('Обновление не началось: ' + res.error, 'error', 8000); return; } var go = function(v) { zm.toast('Устанавливаем версию ' + (v || zmUpdate.latest) + ' — через 4 секунды вы будете выведены из LuCI. Подождите полминуты и зайдите заново', 'warning', 8000); waitForServerAndReload(); }; if (!res.started) { zmUpdateBusy = false; go(res.version); return; } try { updateLog.scrollIntoView({ block: 'nearest' }); } catch (e) {} zm.pollJob('zm_update', updateLog, function(ok) { zmUpdateBusy = false; if (ok) { go(''); return; } var m = /ОШИБКА:\s*([^\n]+)/.exec(updateLog.textContent || ''); zm.toast('Обновление не началось: ' + (m ? m[1] : 'причина — в журнале на странице'), 'error', 8000); }); }).catch(function() { zmUpdateBusy = false; }); } }, 'Обновить панель') ]))); } renderZmUpdate(); zm.zmUpdateNotify(zmUpdate); return wrap; } }); ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/dashboard.js' cat > '/www/luci-static/resources/view/zapret-manager/doh.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require view'; 'require zapret-manager.common as zm'; var E = (function(raw) { return function() { var a = Array.prototype.slice.call(arguments), i = a.length - 1; if (i >= 1 && (typeof a[i] === 'string' || typeof a[i] === 'number')) a[i] = [ String(a[i]) ]; return raw.apply(null, a); }; })(window.E); // k — тип сервера: plain — только шифрование, safe — защита от угроз, family — защита от угроз и сайтов для взрослых, geo — обход геоблокировок. // v — название варианта внутри «папки» провайдера (FAMS). Адреса сверены с документацией провайдеров. var PROVIDERS = [ { id: 'google', label: 'Google', url: 'https://dns.google/dns-query', boot: '8.8.8.8, 8.8.4.4', k: 'plain' }, { id: 'cloudflare', label: 'Cloudflare', v: 'Без фильтрации', url: 'https://cloudflare-dns.com/dns-query', boot: '1.1.1.1, 1.0.0.1', k: 'plain' }, { id: 'cloudflare_sec', label: 'Cloudflare · Защита от угроз', v: 'Защита от угроз', url: 'https://security.cloudflare-dns.com/dns-query', boot: '1.1.1.2, 1.0.0.2', k: 'safe' }, { id: 'cloudflare_fam', label: 'Cloudflare · Семейный фильтр', v: 'Семейный фильтр', url: 'https://family.cloudflare-dns.com/dns-query', boot: '1.1.1.3, 1.0.0.3', k: 'family' }, { id: 'quad9_open', label: 'Quad9 · Без фильтрации', v: 'Без фильтрации', url: 'https://dns10.quad9.net/dns-query', boot: '9.9.9.10, 149.112.112.10', k: 'plain' }, { id: 'quad9', label: 'Quad9 · Защита от угроз', v: 'Защита от угроз', url: 'https://dns.quad9.net/dns-query', boot: '9.9.9.9, 149.112.112.112', k: 'safe' }, { id: 'controld', label: 'Control D', v: 'Без фильтрации', url: 'https://freedns.controld.com/p0', boot: '76.76.2.0, 76.76.10.0', k: 'plain' }, { id: 'controld_sec', label: 'Control D · Защита от угроз', v: 'Защита от угроз', url: 'https://freedns.controld.com/p1', boot: '76.76.2.1, 76.76.10.1', k: 'safe' }, { id: 'controld_fam', label: 'Control D · Семейный фильтр', v: 'Семейный фильтр', url: 'https://freedns.controld.com/family', boot: '76.76.2.4, 76.76.10.4', k: 'family' }, { id: 'dns4eu', label: 'DNS4EU', v: 'Без фильтрации', url: 'https://unfiltered.joindns4.eu/dns-query', boot: '86.54.11.100, 86.54.11.200', k: 'plain' }, { id: 'dns4eu_sec', label: 'DNS4EU · Защита от угроз', v: 'Защита от угроз', url: 'https://protective.joindns4.eu/dns-query', boot: '86.54.11.1, 86.54.11.201', k: 'safe' }, { id: 'dns4eu_fam', label: 'DNS4EU · Семейный фильтр', v: 'Семейный фильтр', url: 'https://child.joindns4.eu/dns-query', boot: '86.54.11.12, 86.54.11.212', k: 'family' }, { id: 'yandex', label: 'Яндекс', v: 'Без фильтрации', url: 'https://common.dot.dns.yandex.net/dns-query', boot: '77.88.8.8, 77.88.8.1', k: 'plain' }, { id: 'yandex_safe', label: 'Яндекс · Защита от угроз', v: 'Защита от угроз', url: 'https://safe.dot.dns.yandex.net/dns-query', boot: '77.88.8.88, 77.88.8.2', k: 'safe' }, { id: 'yandex_family', label: 'Яндекс · Семейный фильтр', v: 'Семейный фильтр', url: 'https://family.dot.dns.yandex.net/dns-query', boot: '77.88.8.7, 77.88.8.3', k: 'family' }, { id: 'xbox', label: 'XBOX', url: 'https://xbox-dns.ru/dns-query', k: 'geo' }, { id: 'comss', label: 'Comss', url: 'https://dns.comss.one/dns-query', k: 'geo' }, { id: 'dnsai', label: 'DNS-AI', url: 'https://dns.dns-ai.ru/dns-query', k: 'geo' }, { id: 'bezmezhau', label: 'Bezmezhau', url: 'https://dns.bezmezhau.com/dns-query', k: 'geo' }, { id: 'geohide_ru', label: 'GeoHide RU', v: 'RU', d: 'российский сервер', url: 'https://geohide.ru/dns-query', k: 'geo' }, { id: 'geohide_eu', label: 'GeoHide EU', v: 'EU', d: 'европейский сервер', url: 'https://eu.geohide.ru/dns-query', k: 'geo' }, { id: 'geohide_us', label: 'GeoHide US', v: 'US', d: 'американский сервер', url: 'https://us.geohide.ru/dns-query', k: 'geo' }, { id: 'malw', label: 'MALW', url: 'https://dns.malw.link/dns-query', k: 'geo' }, { id: 'zapretmoe', label: 'zapret.moe', url: 'https://dns.zapret.moe/dns-query', k: 'geo' }, { id: 'webshift', label: 'WebShift', url: 'https://d.webshiftvpn.ru/dns-query', k: 'geo' }, { id: 'dagami', label: 'Dagami', url: 'https://dns.dagami.ru/dns-query', k: 'geo' } ]; var BOOT_STD = '1.1.1.1, 8.8.8.8'; var OWN_RE = /^https:\/\/[A-Za-z0-9.-]+\.[A-Za-z0-9-]+(:\d{1,5})?(\/[A-Za-z0-9._~%\/+=&?-]*)?$/; var INTRO = 'Шифрованный DNS для всей сети: интернет-провайдер не видит DNS-запросы устройств и не может их подменить.'; var DNSM_URL = '/cgi-bin/luci/admin/services/dns-manager'; var OWN_MAX = 12; var OWN_NAMES = {}; // «Папки»: одна плитка на провайдера, по нажатию раскрываются его варианты var FAMS = { cloudflare: { label: 'Cloudflare', ids: [ 'cloudflare', 'cloudflare_sec', 'cloudflare_fam' ] }, quad9: { label: 'Quad9', ids: [ 'quad9_open', 'quad9' ] }, controld: { label: 'Control D', ids: [ 'controld', 'controld_sec', 'controld_fam' ] }, dns4eu: { label: 'DNS4EU', ids: [ 'dns4eu', 'dns4eu_sec', 'dns4eu_fam' ] }, yandex: { label: 'Яндекс', ids: [ 'yandex', 'yandex_safe', 'yandex_family' ] }, geohide: { label: 'GeoHide', ids: [ 'geohide_ru', 'geohide_eu', 'geohide_us' ] } }; var VAR_TEXT = { plain: 'только шифрование', safe: 'блокирует вредоносные и фишинговые сайты', family: 'блокирует угрозы и сайты для взрослых' }; // ids — плитки: провайдер-«папка» (ключ FAMS) или отдельный сервер var GROUPS = [ { title: 'Без подмены', hint: 'только шифрование; у Cloudflare, Quad9, Control D, DNS4EU и Яндекса есть варианты «Защита от угроз» и «Семейный фильтр»', ids: [ 'google', 'cloudflare', 'quad9', 'controld', 'dns4eu', 'yandex' ] }, { title: 'С обходом геоблокировок', hint: 'открывают сервисы, закрытые для России', ids: [ 'xbox', 'comss', 'dnsai', 'bezmezhau', 'geohide', 'malw', 'zapretmoe', 'webshift', 'dagami' ] } ]; function famIds(key) { return FAMS[key] ? FAMS[key].ids : [ key ]; } function groupIds(g) { var out = []; g.ids.forEach(function(t) { out = out.concat(famIds(t)); }); return out; } function prov(id) { for (var i = 0; i < PROVIDERS.length; i++) if (PROVIDERS[i].id === id) return PROVIDERS[i]; return null; } function isUrl(id) { return /^https:\/\//.test(id || ''); } function label(id) { var p = prov(id); return p ? p.label : isUrl(id) ? (OWN_NAMES[id] || host(id)) : ''; } 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 seg(items, cur, pick, cls) { return E('div', { 'class': 'zm-seg' + (cls ? ' ' + cls : '') }, 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 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 probe = {}, probing = false, subs = {}, health = null, healthSeq = 0, own = [], blocked = 0; var openFam = null, mirrors = {}; var svcBox = E('span', { 'class': 'zm-doh-svc' }); var probeBtn = E('button', { 'class': 'cbi-button', 'click': runProbe }, 'Проверить все'); var ownBusy = false; function cur() { var l = data.resolvers || []; if (data.current) return data.current; return l.length === 1 && !l[0].provider && isUrl(l[0].url) ? l[0].url : ''; } // Основной сервер цепочки: пока работает запасной, это не тот, через который сейчас идёт DNS function curMain() { var f = data.failover || {}; return (f.fo || f.direct) && f.pref && f.mode && f.mode !== 'normal' ? f.pref : cur(); } function syncOwn() { var c = cur(), f = data.failover || {}; own = []; OWN_NAMES = {}; (data.own || []).forEach(function(o) { if (!o || !isUrl(o.url) || own.indexOf(o.url) >= 0) return; own.push(o.url); if (o.name) OWN_NAMES[o.url] = o.name; }); [ c ].concat(f.chain || [], f.pref ? [ f.pref ] : []).forEach(function(u) { if (isUrl(u) && own.indexOf(u) < 0) own.push(u); }); } function ownSaved(u) { return (data.own || []).some(function(o) { return o && o.url === u; }); } function ownCall(arg) { ownBusy = true; render(); return zm.dohOwnSet(arg).then(function(r) { if (!r || r.error) throw new Error((r && r.error) || 'Роутер не ответил'); data.own = r.own || []; if (r.failover) data.failover = r.failover; return r; }).catch(function(e) { zm.toast(e && e.message || 'Роутер не ответил', 'error'); return null; }).then(function(r) { ownBusy = false; syncOwn(); render(); return r; }); } function addOwn(edit) { if (busy || ownBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } if (!edit && (data.own || []).length >= OWN_MAX) { zm.toast('В списке «Свои» уже ' + OWN_MAX + ' серверов — уберите ненужный', 'warning'); return; } function norm(v) { v = String(v || '').trim(); if (/^[A-Za-z0-9.-]+\.[A-Za-z0-9-]+(:\d{1,5})?$/.test(v)) v = 'https://' + v + '/dns-query'; return v; } zm.dialog({ title: edit ? 'Свой сервер DoH' : 'Добавить свой сервер DoH', intro: edit ? edit : '', okText: edit ? 'Сохранить' : 'Добавить', blocks: [ edit ? null : { type: 'input', id: 'url', title: 'Адрес', placeholder: 'https://dns.example.com/dns-query', hint: 'Ссылка DoH-сервера. Можно указать только имя сервера — путь /dns-query подставится сам.' }, { type: 'input', id: 'name', title: 'Название', value: edit ? (OWN_NAMES[edit] || '') : '', placeholder: 'например, Мой DNS', hint: 'Необязательно, до 32 знаков. Без названия в списке будет имя сервера.' } ], canOk: function(v) { return !!edit || !!String(v.url || '').trim(); } }).then(function(v) { if (!v) return; var url = edit || norm(v.url), name = String(v.name || '').replace(/[\u0000-\u001f"\\<>]/g, '').replace(/\s+/g, ' ').trim().slice(0, 32).trim(), i; if (!edit) { if (!OWN_RE.test(url) || url.length > 200) { zm.toast('Адрес не подходит — нужна ссылка вида https://dns.example.com/dns-query', 'warning'); return; } for (i = 0; i < PROVIDERS.length; i++) if (PROVIDERS[i].url === url) { zm.toast('Этот сервер уже есть в списке — ' + PROVIDERS[i].label, 'info'); return; } if (ownSaved(url) && (OWN_NAMES[url] || '') === name) { zm.toast('Этот сервер уже есть в «Свои»', 'info'); return; } } else if ((OWN_NAMES[url] || '') === name) return; ownCall('add ' + url + (name ? ' ' + name : '')).then(function(r) { if (!r) return; if (edit) { zm.toast('Название сохранено', 'info'); return; } zm.toast('«' + label(url) + '» добавлен в «Свои» — его можно выбрать основным или запасным', 'info'); probeOne(url); }); }); } function delOwn(u) { if (busy || ownBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } var f = data.failover || {}, inChain = (f.chain || []).indexOf(u) >= 0 || !!(foDraft && foDraft.chain.indexOf(u) >= 0); if (u === cur()) { zm.toast('Сейчас DNS идёт через этот сервер — сначала выберите другой', 'warning'); return; } if (u === f.pref && (f.fo || f.direct)) { zm.toast('Это основной сервер цепочки «Если сервер перестал отвечать» — сначала выберите другой', 'warning'); return; } zm.dialog({ title: 'Убрать «' + label(u) + '» из своих?', danger: true, okText: 'Убрать', blocks: [ { type: 'list', title: 'Что произойдёт', items: [ 'Сервер ' + host(u) + ' пропадёт из списка «Свои»' ].concat(inChain ? [ 'Из цепочки запасных он тоже уберётся' ] : []) } ] }).then(function(v) { if (!v) return; ownCall('del ' + u).then(function(r) { if (!r) return; if (pickProv === u) pickProv = null; if (foDraft) { foDraft.chain = foDraft.chain.filter(function(x) { return x !== u; }); if (JSON.stringify(foDraft) === JSON.stringify(foSaved())) foDraft = null; } delete probe[u]; render(); zm.toast('Сервер убран из «Свои»', 'info'); }); }); } function sub(id) { if (!subs[id]) subs[id] = E('span', { 'class': 'zm-lat' }); return subs[id]; } function paintLat(el, r) { el.className = 'zm-lat' + (r === 'wait' ? ' zm-lat-none' : r ? ' ' + (r.ok ? latClass(r.ms) : 'zm-lat-bad') : ''); el.textContent = !r ? '' : r === 'wait' ? 'проверяем…' : r.ok ? r.ms + ' мс' : r.why === 'tlsblock' ? 'блокируется' : 'не отвечает'; el.title = !r || r === 'wait' ? '' : r.ok ? 'Отвечает на DNS-запросы: ' + r.ms + ' мс на запрос' + (r.conn > 0 ? ', подключение ' + r.conn + ' мс' : '') + (r.ip ? ' · ' + r.ip : '') : r.err.charAt(0).toUpperCase() + r.err.slice(1); } function paintProbe(id) { var r = probe[id]; paintLat(sub(id), r); if (r === 'wait') sub(id).textContent = '…'; (foLats[id] || []).forEach(function(el) { paintLat(el, r); }); (mirrors[id] || []).forEach(function(el) { paintLat(el, r); }); } function probeOne(id) { probe[id] = 'wait'; paintProbe(id); return zm.dohProbe(id).then(function(r) { r = r || {}; return r.ok ? { ok: true, ms: r.ms, conn: +r.conn || 0, ip: r.ip || '' } : { ok: false, err: r.err || r.error || 'роутер не ответил', why: r.why || '' }; }, function() { return { ok: false, err: 'роутер не ответил' }; }).then(function(r) { probe[id] = r; paintProbe(id); return r; }); } function runProbe() { if (probing) return; if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } var queue = PROVIDERS.map(function(p) { return p.id; }).concat(own), total = queue.length, left = 0, good = 0; blocked = 0; probing = true; probeBtn.disabled = true; probeBtn.textContent = 'Проверяем…'; function next() { if (!queue.length) { if (left) return; probing = false; probeBtn.disabled = false; probeBtn.textContent = 'Проверить все'; zm.toast(good ? 'Отвечают ' + good + ' из ' + total + (blocked ? ', блокируются ' + blocked : '') : 'Ни один сервер не ответил — проверьте интернет на роутере', good ? 'info' : 'error'); render(); return; } left++; probeOne(queue.shift()).then(function(r) { if (r.ok) good++; else if (r.why === 'tlsblock') blocked++; left--; next(); }); } next(); next(); next(); } function paintHealth() { svcBox.innerHTML = ''; if (!data.running) { var dm = data.failover && data.failover.mode === 'direct'; svcBox.appendChild(badge(dm ? 'zm-warn' : 'zm-bad', dm ? 'временно выключена — серверы не отвечают' : 'остановлена')); return; } if (health && health !== 'wait' && !health.local) { svcBox.appendChild(badge('zm-bad', 'не отвечает')); svcBox.appendChild(E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, health.ok ? 'Сервер отвечает, а DNS на роутере — нет. Выберите другой сервер.' : health.err.charAt(0).toUpperCase() + health.err.slice(1) + '. Выберите другой сервер.')); return; } if (health && health !== 'wait') { svcBox.appendChild(badge('zm-ok', 'отвечает' + (health.ok ? ' · ' + health.ms + ' мс' : ''))); return; } svcBox.appendChild(badge('zm-ok', 'работает')); if (health === 'wait') svcBox.appendChild(E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'проверяем ответ…')); } function checkHealth() { var mine = ++healthSeq; if (!data.installed || !data.running || !(data.resolvers || []).length) { health = null; paintHealth(); return; } health = 'wait'; paintHealth(); zm.dohCheck().then(function(r) { return r && !r.error && !r.skip ? r : null; }, function() { return null; }).then(function(r) { if (mine !== healthSeq) return; health = r; paintHealth(); }); } var bar = zm.saveBar({ onCancel: function() { pickProv = null; pickForce = null; foDraft = null; pickCanary = null; pickRu = null; bootReset(); render(); }, onSave: applyPick }); function curForce() { return data.force_mode === 'off' ? 'off' : 'auto'; } function pending() { var n = 0; if (pickProv && pickProv !== curMain()) n++; if (pickForce && pickForce !== curForce()) n++; if (bootChanged()) n++; if (foChanged()) n++; return n + netPending(); } function applyPick() { if (busy) return; var steps = []; var bw = bootWant(); if (bootChanged()) steps.push(function() { logLines.push(' → ' + (bw ? 'Ставим Bootstrap DNS: ' + bw.split(',').join(', ') : 'Возвращаем 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.split(',').join(', ') : 'авто')); }); }); if (pickProv && pickProv !== curMain()) steps.push(function() { var id = pickProv; if (id === cur() && data.running && (data.failover || {}).mode === 'backup') { logLines.push(' → Делаем ' + label(id) + ' основным — DNS уже идёт через него, перезапуск не нужен'); zm.renderLog(logEl, logLines.join('\n')); return zm.dohSet(id).then(function(res) { if (res.error) throw new Error(res.error); logLines.push(' ✓ ' + label(id) + ' теперь основной'); }); } logLines.push(' → Проверяем, отвечает ли ' + label(id)); zm.renderLog(logEl, logLines.join('\n')); return probeOne(id).then(function(r) { if (!r.ok && r.why === 'tlsblock' && data.zapret_doh === 'on') logLines.push(' · Соединение обрывает провайдер — пропускаем сервер через «Обход блокировки DoH» Zapret'); else if (!r.ok) throw new Error(label(id) + ' не отвечает: ' + r.err + '. Настройки не меняли'); else logLines.push(' ✓ Отвечает за ' + r.ms + ' мс'); logLines.push(' → Переключаем DNS на ' + label(id) + ' и перезапускаем https-dns-proxy'); zm.renderLog(logEl, logLines.join('\n')); return zm.dohSet(id); }).then(function(res) { if (res.error) throw new Error(res.error); logLines.push(' ✓ ' + label(id) + ' применён — DNS на роутере отвечает'); }); }); if (foChanged()) steps.push(function() { var w = foDraft, main = foMain(), ch = w.chain.filter(function(id) { return id !== main; }); logLines.push(' → ' + (w.on ? 'Сохраняем цепочку: ' + [ label(main) ].concat(ch.map(label)).join(' → ') + (w.direct ? ' → без DoH' : '') : 'Выключаем автопереключение')); return zm.dohFailoverSet('fo=' + (w.on ? 1 : 0) + ' direct=' + (w.on && w.direct ? 1 : 0) + ' chain=' + ch.join(',')).then(function(r) { if (!r || r.error) throw new Error((r && r.error) || 'Роутер не ответил'); data.failover = r; logLines.push(' ✓ ' + (r.fo || r.direct ? 'Автопереключение включено — основной сервер проверяется раз в 2 минуты' : 'Автопереключение выключено')); }); }); 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 (netPending()) steps.push(function() { var c = pickCanary !== null ? pickCanary : curCanary(), r = pickRu || curRu(); if (c !== curCanary()) logLines.push(' → ' + (c ? 'Включаем' : 'Выключаем') + ' «Не давать браузерам свой DNS»'); if (r !== curRu()) logLines.push(' → Российские сайты: ' + RU_TEXT[r]); zm.renderLog(logEl, logLines.join('\n')); return zm.dnsNetSet('canary=' + (c ? 1 : 0) + ' ru=' + r).then(function(res) { if (!res || res.error) throw new Error((res && res.error) || 'Роутер не ответил'); if (res.net) data.net = res.net; logLines.push(' ✓ Браузеры и российские сайты: настройки применены'); }); }); if (!steps.length) return; busy = true; bar.set(true, true); var logLines = [ '==> Применяем настройки DNS' ]; 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; foDraft = null; pickCanary = null; pickRu = null; refresh().then(function() { bootReset(); render(); }); }); } function refresh() { return zm.dohStatus().then(function(res) { if (res && !res.error) data = res; syncOwn(); render(); checkHealth(); }, function() { render(); }); } function dohWarn(info) { return zm.warnOnce('warn_doh', function() { return zm.warnDialog({ info: info, title: 'Весь DNS сети — через один сервер', lead: (info ? 'DNS over HTTPS включён: роутер узнаёт' : 'Роутер будет узнавать') + ' адреса сайтов только у выбранного сервера DoH — по шифрованному каналу, провайдер не может их подменить или подсмотреть.', items: [ [ 'Сервер перестал отвечать — сайты не открываются', 'на всех устройствах, хотя интернет есть' ], [ 'Серверы DoH иногда блокируют или они падают', 'тот, что работает сегодня, может пропасть завтра' ] ], tip: { title: 'Подстраховка:', text: 'включите «Переключать на запасной сервер» — роутер сам перейдёт на следующий. Не открываются сайты — выберите другой сервер или удалите DoH.' }, check: 'Понимаю: если сервер DoH не отвечает, сайты не откроются, пока не сменю сервер', okText: info ? '' : 'Установить DNS over HTTPS' }); }); } 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 && !data.dnsm)); card.innerHTML = ''; if (data.dnsm && !busy) { zm.blockCard(card, 'DNS over HTTPS', INTRO, 'DNS на роутере настраивает DNS Manager — серверы DoH, перехват DNS и запасные серверы выбираются в нём. Панель эти настройки не меняет, чтобы две программы не переписывали друг друга.'); card.appendChild(E('div', { 'class': 'zm-actions' }, [ E('a', { 'class': 'cbi-button', 'href': DNSM_URL, 'target': '_blank', 'rel': 'noopener' }, 'Открыть DNS Manager') ])); foCard.style.display = 'none'; forceCard.style.display = 'none'; netCard.style.display = 'none'; bar.set(false, false); return; } if (!data.installed && data.forkozz && !busy) { zm.blockCard(card, 'DNS over HTTPS', INTRO, 'Стоит ' + (data.fk_name || 'Forkozz') + ' — он сам шифрует DNS. Чтобы поставить DoH, удалите ' + (data.fk_name || 'Forkozz') + '.'); if (logEl.classList.contains('zm-show')) card.appendChild(logEl); renderForce(); renderNet(); var nf = pending(); bar.set(nf > 0 || busy, busy, nf ? 'Есть несохранённые изменения' : ''); return; } card.appendChild(E('h3', {}, 'DNS over HTTPS')); card.appendChild(E('p', { 'class': 'zm-hint' }, INTRO)); card.appendChild(row('Пакет', data.installed ? badge('zm-ok', 'установлен') : badge('zm-off', 'не установлен'))); var list = data.resolvers || []; if (data.installed) { paintHealth(); card.appendChild(row('Служба', svcBox)); 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 : '')), data.failover && data.failover.mode === 'backup' && list.length === 1 ? E('span', { 'style': 'margin-left:8px' }, [ badge('zm-warn', 'запасной') ]) : '' ]); })) ])); 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() { zm.dialog({ title: 'Удалить DNS over HTTPS?', danger: true, okText: 'Удалить DNS over HTTPS', blocks: [ { type: 'list', title: 'Что произойдёт', items: [ 'DNS over HTTPS удалится вместе с пакетами и настройками', 'Правила перехвата DNS устройств уберутся', 'DNS роутера вернётся к обычному — без шифрования' ] } ] }).then(function(v) { if (v) job(zm.dohRemove, 'doh_remove', 'Удаляем DNS over HTTPS', 'DNS over HTTPS удалён', 'Ошибка удаления'); }); } }, 'Удалить') ] : [ E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } dohWarn(false).then(function(ok) { if (ok) job(zm.dohInstall, 'doh_install', 'Устанавливаем DNS over HTTPS', 'DNS over HTTPS установлен — выберите сервер', 'Ошибка установки'); }); } }, 'Установить DNS over HTTPS') ])); if (data.installed) { card.appendChild(E('div', { 'class': 'zm-doh-head' }, [ E('h4', {}, 'Сервер'), probeBtn ])); card.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:0' }, 'Проверка шлёт каждому серверу два настоящих DNS-запроса: случайное имя, которого нет в кэше, и example.com по тому же соединению. Цифра — время ответа на запрос.')); if (blocked) card.appendChild(E('p', { 'class': 'zm-hint zm-ab-warn', 'style': 'margin-top:0' }, 'Соединение с ' + blocked + ' ' + (blocked % 10 === 1 && blocked % 100 !== 11 ? 'сервером' : 'серверами') + ' обрывает провайдер. ' + (data.zapret_doh === 'on' ? 'Сервер, выбранный основным или запасным, Zapret начнёт пропускать сам.' : data.zapret_doh === 'off' ? 'Включите «Обход блокировки DoH» в Zapret → Стратегии → Дополнения к стратегии.' : 'Обойти блокировку поможет Zapret с «Обходом блокировки DoH».'))); var curProv = pickProv || cur(); var tool = function(cls, title, text, fn) { return E('button', { 'class': 'zm-doh-own-btn' + (cls ? ' ' + cls : ''), 'type': 'button', 'title': title, 'aria-label': title, 'disabled': busy || ownBusy ? '' : null, 'click': function(ev) { ev.stopPropagation(); fn(); } }, text); }; mirrors = {}; var pick = function(id) { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } pickProv = id === curMain() ? null : id; render(); }; var mirror = function(id) { var el = E('span', { 'class': 'zm-lat' }); (mirrors[id] = mirrors[id] || []).push(el); paintLat(el, probe[id]); return el; }; var folder = function(key) { var f = FAMS[key], on = f.ids.indexOf(curProv) >= 0, show = on ? curProv : f.ids[0], open = openFam === key; var toggle = function() { openFam = open ? null : key; render(); }; return E('div', { 'class': 'zm-node zm-doh-fold' + (on ? ' zm-active' : '') + (open ? ' zm-doh-fold-open' : ''), 'role': 'button', 'tabindex': '0', 'aria-expanded': open ? 'true' : 'false', 'title': open ? 'Свернуть' : 'Показать варианты ' + f.label, 'click': toggle, 'keydown': function(ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); toggle(); } } }, [ E('div', { 'class': 'zm-node-name zm-doh-fold-name' }, [ E('span', {}, f.label), E('span', { 'class': 'zm-svc-chev', 'aria-hidden': 'true' }, '›') ]), on ? E('div', { 'class': 'zm-doh-fold-cur' }, prov(curProv).v) : '', E('div', { 'class': 'zm-node-foot' }, [ E('span', {}, on ? prov(curProv).url.replace(/^https:\/\//, '') : f.ids.length + ' ' + (f.ids.length < 5 ? 'варианта' : 'вариантов')), mirror(show) ]) ]); }; var panel = function(key) { var f = FAMS[key]; return E('div', { 'class': 'zm-doh-fold-panel' }, [ E('div', { 'class': 'zm-doh-fold-head' }, [ E('b', {}, f.label + ' — выберите вариант'), E('button', { 'class': 'zm-doh-fold-x', 'type': 'button', 'title': 'Свернуть', 'aria-label': 'Свернуть', 'click': function() { openFam = null; render(); } }, '✕') ]), E('div', { 'class': 'zm-nodes zm-nodes-3' }, f.ids.map(function(id) { var p = prov(id); paintProbe(id); return E('div', { 'class': 'zm-node' + (curProv === id ? ' zm-active' : ''), 'role': 'button', 'tabindex': '0', 'click': function() { pick(id); }, 'keydown': function(ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); pick(id); } } }, [ E('div', { 'class': 'zm-node-name' }, [ p.v ]), E('div', { 'class': 'zm-doh-var-desc' }, p.d || VAR_TEXT[p.k] || ''), E('div', { 'class': 'zm-node-foot' }, [ E('span', {}, [ p.url.replace(/^https:\/\//, '') ]), sub(id) ]) ]); })) ]); }; var nodes = function(ids, mine) { var tiles = []; if (!mine) { ids.forEach(function(key) { if (!FAMS[key]) { tiles = tiles.concat(single([ key ], false)); return; } tiles.push(folder(key)); if (openFam === key) tiles.push(panel(key)); }); return E('div', { 'class': 'zm-nodes zm-nodes-3 zm-doh-grid' }, tiles); } return E('div', { 'class': 'zm-nodes zm-nodes-3' }, single(ids, true)); }; var single = function(ids, mine) { var tiles = ids.map(function(id) { var p = prov(id); paintProbe(id); return E('div', { 'class': 'zm-node' + (curProv === id ? ' zm-active' : '') + (mine ? ' zm-doh-own' : ''), 'title': mine ? id : null, 'click': function() { pick(id); } }, [ E('div', { 'class': 'zm-node-name' }, [ label(id) ]), mine ? E('div', { 'class': 'zm-doh-own-tools' }, ownSaved(id) ? [ tool('', 'Изменить название', '✎', function() { addOwn(id); }), tool('zm-doh-own-del', 'Убрать из своих', '✕', function() { delOwn(id); }) ] : [ tool('zm-doh-own-save', 'Сохранить в «Свои»', '+', function() { if (!busy && !ownBusy) ownCall('add ' + id); }) ]) : '', E('div', { 'class': 'zm-node-foot' }, [ E('span', {}, [ (p ? p.url : id).replace(/^https:\/\//, '') ]), sub(id) ]) ]); }); if (mine && (data.own || []).length < OWN_MAX) tiles.push(E('div', { 'class': 'zm-node zm-sec-add', 'role': 'button', 'tabindex': '0', 'title': 'Свой DoH-сервер', 'click': function() { addOwn(); }, 'keydown': function(ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); addOwn(); } } }, [ E('span', { 'class': 'zm-sec-add-text' }, ownBusy ? 'Сохраняем…' : '+ Добавить свой сервер') ])); return tiles; }; GROUPS.concat([ { title: 'Свои', hint: own.length ? 'выбираются основным и запасными так же, как серверы выше' : 'добавьте свой DoH-сервер — его можно будет выбрать и запасным', ids: own, mine: true } ]).forEach(function(g) { card.appendChild(E('div', { 'class': 'zm-doh-group' }, [ E('div', { 'class': 'zm-doh-group-head' }, [ E('b', {}, g.title), E('span', { 'class': 'zm-hint' }, g.hint) ]), nodes(g.ids, !!g.mine) ])); }); if (list.length > 1) card.appendChild(E('p', { 'class': 'zm-hint' }, 'Сейчас настроено несколько серверов сразу, поэтому ни один не выделен. Выбор заменит их одним.')); renderBoot(curProv); } card.appendChild(logEl); renderFo(); renderForce(); renderNet(); var n = pending(); bar.set(n > 0 || busy, busy, n ? 'Есть несохранённые изменения' : ''); } 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 || '').split(',').join(', '); 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 || '').split(',').join(', '); } bootInput.addEventListener('input', function() { var n = pending(); bar.set(n > 0 || busy, busy, n ? 'Есть несохранённые изменения' : ''); }); function renderBoot(sel) { var m = bootOwn ? 'own' : 'auto', l = data.resolvers || [], p = prov(sel), now; card.appendChild(E('h4', { 'style': 'margin:18px 0 4px' }, 'Bootstrap DNS')); card.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:0' }, 'Обычный DNS, через который роутер узнаёт адрес самого DoH-сервера.')); card.appendChild(row('Адреса', seg([ { id: 'auto', label: 'Авто' }, { id: 'own', label: 'Свои' } ], m, function(v) { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } bootOwn = v === 'own'; render(); if (bootOwn) setTimeout(function() { bootInput.focus(); }, 0); }))); if (m === 'own') { card.appendChild(E('div', { 'class': 'zm-actions' }, [ bootInput ])); card.appendChild(E('p', { 'class': 'zm-hint' }, 'IP-адреса через запятую, до 6 штук.')); return; } now = p && p.boot ? p.boot : BOOT_STD; if (!data.bootstrap_custom && !pickProv && l.length === 1 && l[0].bootstrap) now = l[0].bootstrap.split(',').filter(function(x) { return x.indexOf(':') < 0; }).join(', ') || now; card.appendChild(E('p', { 'class': 'zm-hint' }, 'Сейчас: ' + now + (p && p.boot ? ' — адреса самого ' + p.label + '.' : ' — стандартные.'))); } var foCard = E('div', { 'class': 'zm-card', 'style': 'display:none' }), foDraft = null, foPicking = false, foLats = {}; var FO_MAX = 3; var KIND_TEXT = { plain: 'Без фильтрации — только шифрование', safe: 'Защита от угроз', geo: 'С обходом геоблокировок', family: 'Семейный фильтр', own: 'Свой сервер' }; function at(ts) { if (!ts) return ''; var d = new Date(ts * 1000), n = new Date(), p = function(x) { return (x < 10 ? '0' : '') + x; }; var t = p(d.getHours()) + ':' + p(d.getMinutes()); return d.toDateString() === n.toDateString() ? t : p(d.getDate()) + '.' + p(d.getMonth() + 1) + ' ' + t; } function kind(id) { var p = prov(id); return p ? p.k : 'own'; } function kindIds(k) { return k === 'own' ? own : PROVIDERS.filter(function(p) { return p.k === k; }).map(function(p) { return p.id; }); } function foMain() { var f = data.failover || {}; return pickProv || f.pref || cur(); } function foSaved() { var f = data.failover || {}; return { on: !!(f.fo || f.direct), chain: (f.chain || []).slice(), direct: !!f.direct }; } function foCur() { return foDraft || foSaved(); } function foSig(d) { var main = foMain(); return d.on ? '1|' + (d.direct ? 1 : 0) + '|' + d.chain.filter(function(id) { return id !== main; }).join(',') : '0'; } function foChanged() { return !!foDraft && foSig(foDraft) !== foSig(foSaved()); } function foEdit(fn) { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } var c = foCur(), d = { on: c.on, chain: c.chain.slice(), direct: c.direct }; fn(d); d.chain = d.chain.filter(function(id, i) { return id && d.chain.indexOf(id) === i; }).slice(0, FO_MAX); foDraft = JSON.stringify(d) === JSON.stringify(foSaved()) ? null : d; render(); } function foTiers(main) { var k = kind(main), order = k === 'family' ? [ 'family', 'safe', 'plain' ] : k === 'safe' ? [ 'safe', 'family', 'plain' ] : k === 'plain' ? [ 'plain', 'geo' ] : [ 'geo', 'plain' ]; if (own.length) order.push('own'); return order.map(function(x) { return kindIds(x).filter(function(id) { return id !== main; }); }); } function probeMany(ids) { var queue = ids.slice(), left = 0; return new Promise(function(done) { function next() { if (!queue.length) { if (!left) done(); return; } left++; probeOne(queue.shift()).then(function() { left--; next(); }); } if (!queue.length) done(); for (var i = 0; i < 3; i++) next(); }); } function foAuto(quiet) { if (foPicking) return; if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } var main = foMain(), tiers = foTiers(main); foPicking = true; renderFo(); (function tier(i) { if (i >= tiers.length) return Promise.resolve([]); return probeMany(tiers[i]).then(function() { var ok = tiers[i].filter(function(id) { return probe[id] && probe[id].ok; }) .sort(function(x, y) { return probe[x].ms - probe[y].ms; }).slice(0, FO_MAX); return ok.length ? ok : tier(i + 1); }); })(0).then(function(got) { foPicking = false; if (!got.length) { zm.toast('Сейчас не отвечает ни один подходящий сервер — проверьте интернет на роутере и попробуйте ещё раз', 'error'); renderFo(); return; } foEdit(function(d) { d.on = true; d.chain = got; }); if (!quiet) zm.toast('Подобрали: ' + got.map(label).join(', ') + ' — самые быстрые из отвечающих. Осталось применить', 'info'); }); } function foNext(main, used) { var all = []; foTiers(main).forEach(function(t) { t.forEach(function(id) { if (used.indexOf(id) < 0 && all.indexOf(id) < 0) all.push(id); }); }); var ok = all.filter(function(id) { return probe[id] && probe[id].ok; }); return ok.length && kind(ok[0]) === kind(all[0]) ? ok[0] : all[0]; } function foLat(id) { var el = E('span', { 'class': 'zm-lat' }); (foLats[id] = foLats[id] || []).push(el); paintLat(el, probe[id]); return el; } function foTag(text, cls) { return E('span', { 'class': 'zm-fo-tag' + (cls ? ' ' + cls : '') }, text); } function foStep(num, cls, body, side) { return E('div', { 'class': 'zm-fo-step' + (cls ? ' ' + cls : '') }, [ E('span', { 'class': 'zm-fo-num', 'aria-hidden': 'true' }, num), E('div', { 'class': 'zm-fo-card' }, [ E('div', { 'class': 'zm-fo-body' }, body), side ? E('div', { 'class': 'zm-fo-side' }, side) : '' ]) ]); } function foSelect(val, main, used, n) { var sel = E('select', { 'class': 'cbi-input-select zm-fo-sel', 'aria-label': 'Запасной сервер ' + n }); GROUPS.map(function(g) { return { title: g.title, ids: groupIds(g) }; }).concat(own.length ? [ { title: 'Свои', ids: own } ] : []).forEach(function(g) { var og = E('optgroup', { 'label': g.title }); g.ids.forEach(function(id) { if (id === main || (id !== val && used.indexOf(id) >= 0)) return; var o = E('option', { 'value': id }, label(id)); if (id === val) o.selected = true; og.appendChild(o); }); if (og.childNodes.length) sel.appendChild(og); }); sel.disabled = busy; sel.addEventListener('change', function() { var v = sel.value; foEdit(function(d) { var i = d.chain.indexOf(val); if (i >= 0) d.chain[i] = v; }); probeOne(v); }); return sel; } // Поменять местами основной и запасной: запасной id становится основным, прежний основной встаёт на его место function foSwapMain(id) { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } var main = foMain(), ch = foCur().chain.filter(function(x) { return x !== main; }), j = ch.indexOf(id); if (!id || id === main || j < 0) return; ch[j] = main; pickProv = id === curMain() ? null : id; foEdit(function(x) { x.chain = ch; }); zm.toast(label(id) + ' станет основным, ' + label(main) + ' — запасным. Осталось применить', 'info'); } function foIco(text, title, click, off) { return E('button', { 'class': 'cbi-button zm-fo-ico', 'title': title, 'aria-label': title, 'disabled': off || busy ? '' : null, 'click': click }, text); } function renderFo() { foCard.innerHTML = ''; foLats = {}; var list = data.resolvers || [], f = data.failover || {}; foCard.style.display = data.installed && (list.length || f.mode === 'direct') ? '' : 'none'; if (foCard.style.display) return; var d = foCur(), saved = foSaved(), main = foMain(), now = cur(), single = list.length <= 1; var live = saved.on && single, chain = d.chain.filter(function(id) { return id !== main; }); foCard.appendChild(E('h3', {}, [ 'Если сервер перестал отвечать ', saved.on ? badge(f.mode === 'direct' ? 'zm-bad' : f.mode === 'backup' ? 'zm-warn' : 'zm-ok', f.mode === 'direct' ? 'сейчас без DoH' : f.mode === 'backup' ? 'на запасном' : 'следим') : E([]) ])); if (live && f.mode === 'backup') foCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show', 'role': 'status', 'style': 'margin:0 0 12px' }, [ E('span', {}, 'Сейчас работает запасной ' + label(now) + ': основной ' + label(f.pref || main) + ' не отвечает' + (f.since ? ' с ' + at(f.since) : '') + '. Как только он ответит две проверки подряд, DNS вернётся на него. Хотите оставить ' + label(now) + ' основным — выберите его в списке серверов выше или поднимите стрелкой ↑ и нажмите «Применить».') ])); if (saved.on && f.mode === 'direct') foCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show', 'role': 'status', 'style': 'margin:0 0 12px' }, [ E('span', {}, 'DNS временно идёт без шифрования, через обычные серверы' + (f.since ? ' — с ' + at(f.since) : '') + ': не отвечает ни один сервер цепочки. DoH включится сам, как только какой-нибудь из них ответит.') ])); foCard.appendChild(zm.swRow(d.on, 'Переключать на запасной сервер', 'Раз в 2 минуты панель проверяет основной сервер настоящим DNS-запросом. Не ответил две проверки подряд — DNS переходит на следующий отвечающий сервер цепочки, сверху вниз. Основной снова ответил две проверки подряд — DNS возвращается на него.', function() { var turnOn = !d.on; foEdit(function(x) { x.on = turnOn; }); if (turnOn && !chain.length) foAuto(true); }, busy)); if (!d.on) { foEvents(f); return; } if (!single) foCard.appendChild(E('p', { 'class': 'zm-hint zm-ab-warn' }, 'Сейчас настроено несколько серверов сразу — https-dns-proxy сам обходит неответивший. Цепочка заработает, когда выбран один сервер.')); var box = E('div', { 'class': 'zm-fo-chain' }), used = [ main ].concat(chain), mk = kind(main); box.appendChild(foStep('1', 'zm-fo-main' + (live && f.mode !== 'direct' && now === main ? ' zm-fo-now' : ''), [ E('div', { 'class': 'zm-fo-name' }, [ E('b', {}, label(main) || '—'), foTag('основной'), live && f.mode !== 'direct' && now === main ? foTag('сейчас работает', 'zm-fo-tag-now') : '' ]), E('div', { 'class': 'zm-fo-sub' }, KIND_TEXT[mk] + (pickProv && pickProv !== curMain() ? ' · станет основным после применения' : ' · меняется выбором сервера выше или стрелками')) ], [ foLat(main), chain.length ? foIco('↓', 'Сделать запасным — основным станет ' + label(chain[0]), function() { foSwapMain(chain[0]); }) : '' ])); chain.forEach(function(id, i) { var k = kind(id), isNow = live && f.mode !== 'direct' && now === id, warn = ''; if (mk === 'geo' && k !== 'geo') warn = '⚠ Не обходит геоблокировки: пока работает он, ChatGPT, Gemini и другие закрытые для России сервисы могут не открываться'; else if (mk === 'family' && k !== 'family') warn = k === 'safe' ? '⚠ Без семейного фильтра: пока работает он, взрослые сайты не блокируются' : '⚠ Без семейного фильтра: пока работает он, вредоносные и взрослые сайты не блокируются'; else if (mk === 'safe' && k !== 'safe' && k !== 'family') warn = '⚠ Без защиты от угроз: пока работает он, вредоносные и фишинговые сайты не блокируются'; box.appendChild(foStep(String(i + 2), isNow ? 'zm-fo-now' : '', [ E('div', { 'class': 'zm-fo-name' }, [ foSelect(id, main, used, i + 2), isNow ? foTag('сейчас работает', 'zm-fo-tag-now') : '' ]), E('div', { 'class': 'zm-fo-sub' + (warn ? ' zm-fo-warn' : '') }, warn || KIND_TEXT[k]) ], [ foLat(id), i === 0 ? foIco('↑', 'Сделать основным', function() { foSwapMain(id); }) : foIco('↑', 'Выше', function() { foEdit(function(x) { var j = x.chain.indexOf(id); if (j > 0) { x.chain[j] = x.chain[j - 1]; x.chain[j - 1] = id; } }); }), foIco('↓', 'Ниже', function() { foEdit(function(x) { var j = x.chain.indexOf(id); if (j >= 0 && j < x.chain.length - 1) { x.chain[j] = x.chain[j + 1]; x.chain[j + 1] = id; } }); }, i === chain.length - 1), foIco('✕', 'Убрать из цепочки', function() { foEdit(function(x) { x.chain = x.chain.filter(function(y) { return y !== id; }); }); }) ])); }); var tools = []; if (chain.length < FO_MAX) tools.push(E('button', { 'class': 'cbi-button', 'disabled': busy || foPicking ? '' : null, 'click': function() { var nx = foNext(main, used); if (!nx) { zm.toast('Больше нечего добавить — все серверы уже в цепочке', 'info'); return; } foEdit(function(x) { x.chain = chain.concat([ nx ]); }); if (!probe[nx]) probeOne(nx); } }, '+ Добавить запасной')); tools.push(E('button', { 'class': 'cbi-button', 'disabled': busy || foPicking ? '' : null, 'click': function() { foAuto(false); } }, foPicking ? 'Подбираем…' : 'Подобрать автоматически')); tools.push(E('button', { 'class': 'cbi-button', 'disabled': busy || foPicking ? '' : null, 'click': function() { if (busy) return; probeMany(used).then(function() { var bad = used.filter(function(id) { return !(probe[id] && probe[id].ok); }); zm.toast(bad.length ? 'Не отвечают: ' + bad.map(label).join(', ') : 'Все серверы цепочки отвечают', bad.length ? 'warning' : 'info'); }); } }, 'Проверить цепочку')); box.appendChild(E('div', { 'class': 'zm-fo-add' }, tools)); var dm = d.direct ? 'direct' : 'wait', nowDirect = saved.on && f.mode === 'direct'; box.appendChild(foStep('!', 'zm-fo-last' + (nowDirect ? ' zm-fo-now' : ''), [ E('div', { 'class': 'zm-fo-name' }, [ E('b', {}, 'Если не ответил никто'), nowDirect ? foTag('сейчас', 'zm-fo-tag-now') : '' ]), seg([ { id: 'wait', label: 'Ждать — DoH не выключать' }, { id: 'direct', label: 'Работать без DoH' } ], dm, function(v) { foEdit(function(x) { x.direct = v === 'direct'; }); }), E('div', { 'class': 'zm-fo-sub' }, dm === 'direct' ? 'DNS временно пойдёт через обычные серверы провайдера, без шифрования: сайты продолжат открываться, но провайдер снова видит запросы. DoH включится сам, как только ответит любой сервер цепочки.' : 'DNS остаётся на последнем сервере и ждёт, когда какой-нибудь из цепочки ответит. Пока ждёт, новые сайты могут не открываться, зато запросы не уходят провайдеру.') ])); foCard.appendChild(box); if (!chain.length && dm === 'wait') foCard.appendChild(E('p', { 'class': 'zm-hint zm-ab-warn' }, 'Запасных нет — при обрыве переключаться не на что. Добавьте запасной или нажмите «Подобрать автоматически».')); foEvents(f); if (!busy && !foPicking) used.forEach(function(id) { if (!probe[id]) probeOne(id); }); } function foEvents(f) { var ev = (f.events || []).slice().reverse().slice(0, 5); if (!ev.length) return; foCard.appendChild(E('h4', { 'style': 'margin:16px 0 6px' }, 'Последние переключения')); foCard.appendChild(E('div', { 'class': 'zm-doh-events' }, ev.map(function(e) { return E('div', { 'class': 'zm-row', 'style': 'gap:10px' }, [ E('span', { 'class': 'zm-label', 'style': 'min-width:80px' }, at(e.at)), E('span', {}, e.text) ]); }))); } 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.')); } var netCard = E('div', { 'class': 'zm-card', 'style': 'display:none' }), pickCanary = null, pickRu = null; var RU_MODES = [ { id: 'off', label: 'Через DoH' }, { id: 'yandex', label: 'Яндекс DNS' }, { id: 'isp', label: 'DNS провайдера' } ]; var RU_TEXT = { off: 'через DoH, как все сайты', yandex: 'через Яндекс DNS', isp: 'через DNS провайдера' }; function net() { return data.net || {}; } function curCanary() { return !!net().canary; } function curRu() { return net().ru || 'off'; } function netPending() { var n = 0; if (pickCanary !== null && pickCanary !== curCanary()) n++; if (pickRu && pickRu !== curRu()) n++; return n; } function renderNet() { netCard.innerHTML = ''; netCard.style.display = data.dnsm ? 'none' : ''; if (data.dnsm) return; var t = net(), auto = !!t.canary_doh && !curCanary(), can = pickCanary !== null ? pickCanary : curCanary(), ru = pickRu || curRu(); var isp = String(t.isp || '').split(',').filter(Boolean); netCard.appendChild(E('h3', {}, 'Браузеры и российские сайты')); netCard.appendChild(zm.swRow(can || auto, 'Не давать браузерам свой DNS', auto ? 'Уже включено: так работает «Перехват DNS устройств» в режиме «Авто».' : 'Firefox и Частный узел iCloud (iCloud Private Relay) на iPhone и Mac умеют узнавать адреса сайтов в обход роутера — тогда для них не работают DoH роутера, блокировка рекламы и Steer. Роутер подскажет им так не делать.', function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } var v = !can; pickCanary = v === curCanary() ? null : v; render(); }, auto || busy)); if (can || auto) netCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Подсказку не слушают только браузеры, где свой DNS включили вручную (например, Firefox в режиме «Максимальная защита»).')); if (!(data.installed && !data.forkozz) && curRu() === 'off' && !pickRu) return; netCard.appendChild(E('h4', { 'style': 'margin:18px 0 4px' }, 'Российские сайты — .ru, .su, .рф')); netCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:0' }, 'У кого роутер узнаёт их адреса.')); netCard.appendChild(E('div', { 'class': 'zm-actions' }, [ seg(RU_MODES, ru, function(v) { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } if (v !== 'off' && data.forkozz) { zm.toast('Стоит ' + (data.fk_name || 'Forkozz') + ' — он сам решает, куда идут запросы DNS', 'warning'); return; } if (v === 'isp' && !isp.length) { zm.toast('Провайдер не выдал роутеру адреса DNS — выберите Яндекс DNS', 'warning'); return; } pickRu = v === curRu() ? null : v; render(); }, 'zm-seg-stack') ])); netCard.appendChild(E('p', { 'class': 'zm-hint' }, ru === 'yandex' ? 'Адреса сайтов в .ru, .su и .рф роутер узнаёт у Яндекс DNS (77.88.8.8, 77.88.8.1) — российские сайты открываются с ближайших к вам серверов. Эти запросы идут без шифрования, остальные — через DoH.' : ru === 'isp' ? 'Адреса сайтов в .ru, .su и .рф — у DNS провайдера' + (isp.length ? ' (' + isp.join(', ') + ')' : '') + '. Провайдер видит, какие российские сайты открываются, остальные запросы идут через DoH. Сменит провайдер адреса DNS — панель обновит их сама.' : 'Все сайты — через сервер DoH. Зарубежный сервер иногда отдаёт адреса российских сайтов не в России: Госуслуги, банки и онлайн-кинотеатры могут открываться медленнее или не пускать.')); if (curRu() !== 'off' && (data.forkozz || !data.installed)) netCard.appendChild(E('p', { 'class': 'zm-hint zm-ab-warn' }, data.forkozz ? 'Стоит ' + (data.fk_name || 'Forkozz') + ' — он сам решает, куда идут запросы DNS. Выберите «Через DoH», чтобы российские сайты не шли мимо него.' : 'DoH не установлен — выберите «Через DoH», чтобы российские сайты шли через обычный DNS роутера.')); } syncOwn(); render(); checkHealth(); if (data.installed && !data.dnsm) setTimeout(function() { if (document.body.contains(wrap)) dohWarn(true); }, 600); wrap.appendChild(hostsWarn); wrap.appendChild(card); wrap.appendChild(foCard); wrap.appendChild(forceCard); wrap.appendChild(netCard); 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 E = (function(raw) { return function() { var a = Array.prototype.slice.call(arguments), i = a.length - 1; if (i >= 1 && (typeof a[i] === 'string' || typeof a[i] === 'number')) a[i] = [ String(a[i]) ]; return raw.apply(null, a); }; })(window.E); 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 fixCard = E('div', { 'class': 'zm-card zm-kv' }); var gen = { 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 перезапущен на новой точке входа', autofix: 'Автопочинка: туннель снова работает' }[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() { return zm.ask('Переустановить AmneziaWG?\n\nЕсли туннели работают, новый модуль заработает после перезагрузки роутера.').then(function(ok) { if (!ok) return; job('update', '', 'Переустанавливаем AmneziaWG'); }); } }, 'Переустановить')); acts.push(E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { var list = (data.ifaces || []).filter(function(f) { return f.owner !== 'steer'; }); var foreign = list.filter(function(f) { return !f.owned; }).map(function(f) { return f.name; }); var fk = list.filter(function(f) { return f.forkozz; }).map(function(f) { return f.name; }); var blocks = [ { type: 'list', title: 'Что произойдёт', items: [ 'Пакеты AmneziaWG и модуль ядра удалятся', 'Сохранённые конфиги туннелей удалятся' ].concat(list.map(function(f) { return 'Туннель ' + f.name + (f.owned ? '' : ' (создан не панелью)') + ' удалится — с ключами и зоной файрвола'; })) } ]; var nsBusy = fk.length && data.fk_name === 'NetShift'; if (nsBusy) blocks.push({ type: 'note', kind: 'warn', text: 'Через ' + fk.join(', ') + ' сейчас работает NetShift. Панель его не трогает — сначала выберите в нём другое подключение или выключите его (Службы → NetShift).' }); else if (fk.length) blocks.push({ type: 'note', kind: 'warn', text: 'Через ' + fk.join(', ') + ' сейчас работает Forkozz. Он будет выключен, трафик пойдёт напрямую. Его настройки останутся — потом выберите в нём другое подключение.' }); if (foreign.length) blocks.push({ type: 'switch', id: 'all', value: false, label: 'Удалить и туннели, созданные не панелью: ' + foreign.join(', '), hint: 'Без AmneziaWG они всё равно не заработают. Не отмечено — удаление не начнётся.' }); if (list.some(function(f) { return f.route_all && f.up; })) blocks.push({ type: 'note', text: 'Через один из туннелей сейчас идёт весь интернет роутера. После удаления он пойдёт напрямую через провайдера.' }); zm.dialog({ title: 'Удалить AmneziaWG?', danger: true, okText: 'Удалить AmneziaWG', blocks: blocks, canOk: function(v) { return !nsBusy && (!foreign.length || !!v.all); } }).then(function(v) { if (v) job('remove', foreign.length ? 'all' : '', 'Удаляем 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') : '', f.forkozz ? badge('zm-ok', 'работает в ' + (data.fk_name || 'Forkozz')) : '', (data.steer_ifs || []).indexOf(f.name) >= 0 ? badge('zm-ok', 'работает в 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(); }); } }, 'Проверить') ]; var bySteer = (data.steer_ifs || []).indexOf(f.name) >= 0; function steerBusy(what) { zm.dialog({ title: 'Туннель ' + f.name + ' занят', okText: 'Понятно', cancelText: '', blocks: [ { type: 'note', kind: 'warn', text: 'Через этот туннель сейчас идут сервисы Steer. Если его ' + what + ', они перестанут открываться.' }, { type: 'list', title: 'Что сделать сначала', items: [ 'Откройте Steer', 'Выберите другой режим WARP или другое подключение для секции, которая идёт через ' + f.name ] } ] }); } 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() { if (bySteer) { steerBusy('выключить'); return; } 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() { return zm.ask('Новые ключи WARP для ' + f.name + '?\n\n' + (f.warp ? 'Точка входа и маскировка сохранятся.' : 'Туннель переключится с вашего сервера на WARP.'), { okText: 'Получить ключи' }).then(function(ok) { if (!ok) 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 (bySteer) { steerBusy('удалить'); return; } if (f.forkozz) { var fkn = data.fk_name || 'Forkozz'; zm.dialog({ title: 'Туннель ' + f.name + ' занят', okText: 'Понятно', cancelText: '', blocks: [ { type: 'note', kind: 'warn', text: 'Через этот туннель сейчас работает ' + fkn + '. Если его удалить, сервисы ' + fkn + ' перестанут открываться.' }, { type: 'list', title: 'Что сделать сначала', items: [ fkn === 'NetShift' ? 'Выключите NetShift в его разделе (Службы → NetShift)' : 'Выключите Forkozz на его вкладке', 'или выберите в нём другое подключение' ] } ] }); return; } zm.dialog({ title: 'Удалить туннель ' + f.name + '?', danger: true, okText: 'Удалить туннель', blocks: [ { type: 'list', title: 'Что произойдёт', items: [ 'Туннель ' + f.name + ' удалится вместе с ключами', 'Его зона файрвола уберётся, если её создала панель' ] }, f.route_all && f.up ? { type: 'note', text: 'Сейчас через него идёт весь интернет роутера. После удаления он пойдёт напрямую через провайдера.' } : null ] }).then(function(v) { if (v) 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 && !data.steer_ext ? '' : ', сейчас выключен') + '. Конфиг меняется здесь или в Steer.' : data.steer_own ? 'Туннель Steer. Выключен, пока ' + (data.steer_ext ? 'Steer работает через ' + data.steer_ext : 'работает свой 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; } return zm.ask('Заменить конфиг ' + f.name + '?\n\nЗона firewall и маршруты останутся как есть, туннель переподключится.').then(function(ok) { if (!ok) 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)); }); } var FIX_MODES = [ { id: 'off', label: 'Выключено' }, { id: '30', label: '30 минут' }, { id: '60', label: '1 час' }, { id: '180', label: '3 часа' } ]; function fixAct(action, mode, okText) { return zm.awgAction(action, mode).then(function(res) { if (!res || res.error) { zm.toast((res && res.error) || 'Роутер не ответил', 'error'); return; } if (okText) zm.toast(okText, 'info'); refresh(); if (action === 'fix_set' && mode !== 'off') setTimeout(refresh, 8000); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } function renderFix() { fixCard.innerHTML = ''; var list = (data.ifaces || []).filter(function(f) { return f.warp && f.owner !== 'steer'; }); fixCard.style.display = data.installed && list.length ? '' : 'none'; if (fixCard.style.display) return; var w = data.fix || {}, mode = w.mode || 'off', on = mode !== 'off'; var now = parseInt(w.now, 10) || Math.floor(Date.now() / 1000), lim = (parseInt(mode, 10) || 0) * 60, max = parseInt(w.max, 10) || 4; var skip = {}, dead = {}, stuck = false; (w.skip || []).forEach(function(n) { skip[n] = true; }); (w.dead || []).forEach(function(d) { dead[d.name] = d; }); 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; } var AFTER = { '30': '30 минут', '60': 'часа', '180': '3 часов' }; fixCard.appendChild(E('h3', {}, [ 'Автопочинка WARP ', on ? badge('zm-ok', 'включена') : E([]) ])); fixCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Раз в 10 минут роутер проверяет отмеченные туннели WARP. Если туннель не работает или ушёл на российский узел Cloudflare (DME и другие — через них блокировки не снимаются) дольше выбранного времени, роутер подбирает ему зарубежную точку входа, а с третьей попытки ещё и новые ключи. Новая настройка остаётся, только если с ней стало лучше, иначе возвращается прежняя. Работающие туннели не трогаются.')); var rows = E('div', { 'class': 'zm-afix-list' }); list.forEach(function(f) { var watched = !skip[f.name], d = dead[f.name], right = []; if (!watched) right.push(badge('zm-off', 'не проверяется')); else if (w.fixing === f.name) right.push(badge('zm-warn', 'чиним сейчас')); else if (!f.enabled) right.push(badge('zm-off', 'выключен вручную')); else if (d) { var since = parseInt(d.since, 10) || now, tries = parseInt(d.tries, 10) || 0, last = parseInt(d.last, 10) || 0; var ru = /^ru:/.test(d.why || '') ? d.why.slice(3) : ''; right.push(badge(tries >= max ? 'zm-bad' : 'zm-warn', ru ? 'российский узел ' + ru + ' · ' + dur(now - since) : 'не работает ' + dur(now - since))); if (tries >= max) { stuck = true; right.push(E('span', { 'class': 'zm-afix-note' }, max + ' попытки не помогли')); } else if (on) right.push(E('span', { 'class': 'zm-afix-note' }, 'починка в ' + clock(tickAt(Math.max(since + lim, tries ? last + lim : 0))) + ' · попытка ' + (tries + 1) + ' из ' + max)); } else right.push(live(f) ? badge('zm-ok', 'работает') : badge('zm-warn', 'нет рукопожатия')); var uses = []; if ((data.steer_ifs || []).indexOf(f.name) >= 0) uses.push('Steer'); if (f.forkozz) uses.push(data.fk_name || 'Forkozz'); var sub = uses.length ? 'используется в ' + uses.join(' и ') : (f.endpoint || ''); rows.appendChild(E('div', { 'class': 'zm-swrow zm-afix-row', 'click': function() { fixAct('fix_skip', f.name + '|' + (watched ? 'off' : 'on'), watched ? f.name + ' больше не проверяется' : f.name + ' снова под присмотром'); } }, [ E('div', { 'class': 'zm-switch' + (watched ? ' zm-switch-on' : '') }, [ E('span') ]), E('div', { 'class': 'zm-swrow-text' }, [ E('div', { 'class': 'zm-swrow-label' }, f.name), sub ? E('div', { 'class': 'zm-hint' }, sub) : '' ]), E('div', { 'class': 'zm-afix-st' }, right) ])); }); fixCard.appendChild(rows); fixCard.appendChild(E('div', { 'class': 'zm-row zm-afix-mode' }, [ E('span', { 'class': 'zm-label' }, 'Чинить, если не работает'), E('div', { 'class': 'zm-seg zm-afix-seg' }, FIX_MODES.map(function(m) { return E('div', { 'class': 'zm-seg-item' + (m.id === mode ? ' zm-active' : ''), 'click': function() { if (m.id === mode) return; fixAct('fix_set', m.id, m.id === 'off' ? 'Автопочинка выключена' : 'Автопочинка включена: туннель чинится после ' + AFTER[m.id] + ' простоя'); } }, m.label); })) ])); var log = (w.log || []).slice(-3).reverse(); if (!on) fixCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Выключено — туннели чинятся вручную кнопками «Точка входа» и «Новый WARP».')); if (log.length) fixCard.appendChild(E('div', { 'class': 'zm-hint zm-afix-log' }, 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 ]); }))); if (stuck && on) fixCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'disabled': busy ? '' : null, 'click': function() { fixAct('fix_reset', '', 'Попытки сброшены — автопочинка попробует снова'); } }, 'Попробовать снова') ])); } 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; } var zmGo = function() { job('gen', mode, toastText); }; if (data.warp_conf) return zm.ask('WARP.conf уже есть — заменить его новым?').then(function(ok) { return ok ? zmGo() : undefined; }); return zmGo(); } function tile(title, time, text, foot) { return E('div', { 'class': 'zm-awg-if zm-gen-tile' }, [ E('div', { 'class': 'zm-gen-head' }, [ E('span', { 'class': 'zm-gen-title' }, title), E('span', { 'class': 'zm-gen-time' }, time) ]), E('p', { 'class': 'zm-hint zm-gen-text' }, text), E('div', { 'class': 'zm-gen-foot' }, foot) ]); } function goBtn(fn, off) { return E('button', { 'class': 'cbi-button', 'disabled': (busy || off) ? '' : null, 'click': fn }, 'Создать'); } if (!gen.ci) { gen.ci = E('input', { 'class': 'cbi-input-text', 'type': 'text', 'placeholder': '162.159.192.7:2408', 'value': gen.ep || '' }); gen.ci.addEventListener('input', function() { gen.ep = gen.ci.value.trim(); }); gen.ci.addEventListener('keydown', function(ev) { if (ev.key === 'Enter' && gen.go) gen.go(); }); } var ci = gen.ci; var goCustom = 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'); ci.focus(); return; } run('std|' + ep, 'Создаём WARP с точкой ' + ep); }; gen.go = goCustom; var genLabel = E('div', { 'class': 'zm-gen-label' }, data.warp_conf ? 'Создать заново' : 'Как создать'); var genGrid = E('div', { 'class': 'zm-gen-grid' }, [ tile('Быстро', 'секунды', 'Стандартная точка входа, маскировка под SIP-звонок. Без проверок — подойдёт, если WARP у провайдера не режется.', [ goBtn(function() { run('std|default', 'Создаём WARP'); }) ]), tile('С подбором точки входа', '≈ 1 минута', 'Роутер проверит точки входа Cloudflare и возьмёт зарубежную: через российские (DME и др.) часть сайтов не открывается.', [ goBtn(function() { run('std|auto', 'Создаём WARP и подбираем точку входа'); }) ]), tile('С проверкой связи', 'до 5 минут', 'Ключи, точки входа и маски перебираются настоящим туннелем — записывается связка, где трафик реально идёт. Самый надёжный способ.', [ goBtn(function() { if (!data.installed) { zm.toast('Сначала установите AmneziaWG', 'warning'); return; } run('check', 'Создаём WARP с проверкой, это несколько минут'); }, !data.installed), !data.installed ? E('span', { 'class': 'zm-hint zm-gen-need' }, 'нужен AmneziaWG') : '' ]), tile('Своя точка входа', 'секунды', 'Как «Быстро», но с вашим адресом:портом — если знаете точку, которая у вас работает.', [ ci, goBtn(goCustom) ]) ]); 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))); if (data.warp_mask) genCard.appendChild(row('Маскировка', E('span', {}, data.warp_mask === 'SIP' ? 'SIP-звонок' : data.warp_mask))); 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); } } genCard.appendChild(genLabel); genCard.appendChild(genGrid); } 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; }); var zmGo = function() { job('create', nm + '|' + (mk.route ? 1 : 0) + '|' + (mk.fw ? 1 : 0) + '|' + mk.text, 'Создаём интерфейс ' + nm); mk.name = ''; }; if (exists) return zm.ask('Интерфейс ' + nm + ' уже есть — заменить его?').then(function(ok) { return ok ? zmGo() : undefined; }); return zmGo(); } }, 'Создать интерфейс') ])); } function renderAll() { renderMain(); renderIfaces(); renderFix(); renderGen(); renderNew(); } renderAll(); wrap.appendChild(zm.rebootSlot()); wrap.appendChild(mainCard); wrap.appendChild(logEl); wrap.appendChild(ifCard); wrap.appendChild(fixCard); wrap.appendChild(genCard); wrap.appendChild(newCard); if (data.running) follow('awg', data.phase || ''); var fixPoll = setInterval(function() { if (!document.body.contains(wrap)) { clearInterval(fixPoll); return; } if (busy || document.hidden || !data.fix || !data.fix.mode || data.fix.mode === 'off') return; zm.awgStatus().then(function(res) { if (busy || !res || !res.fix) return; data.fix = res.fix; data.ifaces = res.ifaces || data.ifaces; data.steer_ext = res.steer_ext; data.steer_ifs = res.steer_ifs; renderFix(); }).catch(function() {}); }, 60000); return wrap; } }); ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/awg.js' cat > '/www/luci-static/resources/view/zapret-manager/terminal.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require view'; 'require zapret-manager.common as zm'; var E = (function(raw) { return function() { var a = Array.prototype.slice.call(arguments), i = a.length - 1; if (i >= 1 && (typeof a[i] === 'string' || typeof a[i] === 'number')) a[i] = [ String(a[i]) ]; return raw.apply(null, a); }; })(window.E); return view.extend({ load: function() { zm.injectCss(); return zm.termStatus(); }, render: function(data) { data = data || {}; var wrap = E('div', { 'class': 'zm-wrap' }); var card = E('div', { 'class': 'zm-card' }); var headEl = E('div', {}); var viewEl = E('div', { 'style': 'display:none; margin:0 0 14px' }); var actEl = E('div', {}); var logEl = E('pre', { 'class': 'zm-log' }); var busy = false, frame = null, autoTried = false; var canEmbed = window.location.protocol === 'http:'; var keep = window.zmTermKeep || null; function dropFrame() { frame = null; if (keep) keep.drop(); } var DONE = { install: 'Терминал установлен', remove: 'Терминал удалён' }; function addr() { return 'http://' + window.location.hostname + ':' + (data.port || 7681) + '/'; } function termTitle() { return 'ttyd · ' + window.location.hostname + ':' + (data.port || 7681); } function frameUrl() { var base = addr(), bg = ''; if (window.zmTermUrl && String(window.zmTermUrl).indexOf(base) === 0) return window.zmTermUrl; try { bg = getComputedStyle(document.documentElement).getPropertyValue('--console').trim(); } catch (e) { bg = ''; } if (!/^#[0-9a-f]{6}$/i.test(bg)) bg = '#0d1117'; var theme = { background: bg, foreground: '#e6edf3', cursor: '#e6edf3', black: '#000000', red: '#d81e00', green: '#5ea702', yellow: '#cfae00', blue: '#427ab3', magenta: '#89658e', cyan: '#00a7aa', white: '#dbded8', brightBlack: '#686a66', brightRed: '#f54235', brightGreen: '#99e343', brightYellow: '#fdeb61', brightBlue: '#84b0d8', brightMagenta: '#bc94b7', brightCyan: '#37e6e8', brightWhite: '#f1f1f0' }; window.zmTermUrl = base + '?disableLeaveAlert=true&theme=' + encodeURIComponent(JSON.stringify(theme)); return window.zmTermUrl; } function refresh() { return zm.termStatus().then(function(d) { data = d || {}; renderAll(); autoStart(); }); } function follow(action) { busy = true; dropFrame(); renderAll(); zm.pollJob('term', logEl, function(ok) { busy = false; zm.toast(ok ? (DONE[action] || 'Готово') : 'Не получилось — подробности в журнале', ok ? 'info' : 'error'); refresh(); }); } function job(action, text) { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } zm.termAction(action, '').then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast(text, 'warning'); follow(action); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } function quick(action, okText) { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } busy = true; dropFrame(); renderAll(); zm.termAction(action, '').then(function(res) { busy = false; if (res.error) zm.toast(res.error, 'error'); else if (okText) zm.toast(okText, 'info'); refresh(); }).catch(function() { busy = false; zm.toast('Роутер не ответил', 'error'); renderAll(); }); } function autoStart() { if (autoTried || busy || data.job || !data.installed || data.running) return; autoTried = true; quick('start', ''); } function btn(cls, text, fn) { return E('button', { 'class': 'cbi-button ' + cls, 'disabled': busy ? '' : null, 'click': fn }, text); } function renderHead() { headEl.innerHTML = ''; if (data.installed) return; headEl.appendChild(E('h3', {}, 'Терминал')); headEl.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Командная строка роутера прямо в браузере (ttyd). Ставится пакет ttyd из репозитория OpenWrt (около 1 МБ), терминал запустится сам и откроется здесь.')); } function renderView() { var show = !!data.installed && !!data.running && canEmbed && !busy; if (keep) { viewEl.style.display = 'none'; if (!show) keep.hide(); else keep.open(frameUrl(), termTitle()); return; } if (!show) { viewEl.style.display = 'none'; return; } if (!frame) { frame = E('iframe', { 'class': 'zm-term-frame', 'src': frameUrl() }); viewEl.innerHTML = ''; viewEl.appendChild(E('div', { 'class': 'zm-term' }, [ E('div', { 'class': 'zm-term-bar' }, [ E('span', { 'class': 'zm-term-dots' }, [ E('i', {}), E('i', {}), E('i', {}) ]), E('span', { 'class': 'zm-term-title' }, termTitle()) ]), E('div', { 'class': 'zm-term-body' }, [ frame ]) ])); } viewEl.style.display = ''; } function renderActions() { actEl.innerHTML = ''; var b = []; if (!data.installed) { b.push(btn('cbi-button-positive', 'Установить', function() { job('install', 'Устанавливаем терминал — ход работы виден в окне вывода'); })); actEl.appendChild(E('div', { 'class': 'zm-actions' }, b)); return; } if (data.running) b.push(E('a', { 'class': 'cbi-button cbi-button-positive', 'href': addr(), 'target': '_blank', 'rel': 'noopener' }, 'Открыть в новой вкладке')); b.push(btn('', 'Перезапустить', function() { quick('restart', 'Терминал перезапущен'); })); b.push(btn('cbi-button-remove', 'Удалить', function() { zm.dialog({ title: 'Удалить терминал?', danger: true, okText: 'Удалить терминал', blocks: [ { type: 'list', title: 'Что произойдёт', items: [ 'Терминал остановится', 'Пакет ttyd удалится вместе с настройками' ] } ] }).then(function(v) { if (v) job('remove', 'Удаляем терминал — ход работы виден в окне вывода'); }); })); actEl.appendChild(E('div', { 'class': 'zm-actions' }, b)); if (data.running && !canEmbed) actEl.appendChild(E('p', { 'class': 'zm-hint' }, 'Панель открыта по https, а терминал работает по http — встроить его сюда нельзя, он откроется в отдельной вкладке.')); } var accCard = E('div', { 'class': 'zm-card' }); function renderAccess() { accCard.innerHTML = ''; accCard.style.display = data.installed && data.ts ? '' : 'none'; if (accCard.style.display) return; var m = data.mode || 'home', on = m === 'all'; accCard.appendChild(E('h3', {}, [ 'Доступ через Tailscale ', on ? E('span', { 'class': 'zm-badge zm-ok' }, [ E('span', { 'class': 'zm-dot' }), 'дома и в Tailscale' ]) : m === 'ts' ? E('span', { 'class': 'zm-badge zm-warn' }, [ E('span', { 'class': 'zm-dot' }), 'только Tailscale' ]) : m === 'other' ? E('span', { 'class': 'zm-badge zm-warn' }, [ E('span', { 'class': 'zm-dot' }), 'своя настройка' ]) : E('span', { 'class': 'zm-badge zm-off' }, [ E('span', { 'class': 'zm-dot' }), 'только дома' ]) ])); var hint = on ? 'Работает дома и через Tailscale. Из интернета закрыт.' : m === 'ts' ? 'Сейчас только через Tailscale — дома не откроется.' : m === 'other' ? 'Выбран интерфейс «' + (data.iface || '') + '». Включите, чтобы работал и дома, и через Tailscale.' : 'Сейчас только дома. Вне дома вместо терминала будет пустая страница.'; if (!data.wan_closed && !on) hint = 'Нельзя: в firewall открыт вход из интернета (зона wan → Входящие: отклонять).'; accCard.appendChild(zm.swRow(on, 'Открывать через Tailscale', hint, function() { quick(on ? 'ts_off' : 'ts_on', on ? 'Терминал теперь только для домашней сети' : 'Терминал открывается и через Tailscale'); }, busy || (!on && !data.wan_closed))); var addrs = []; if (data.lan_ip) addrs.push(E('b', {}, 'http://' + data.lan_ip + ':7788')); if (data.ts_ip) addrs.push(E('b', {}, 'http://' + data.ts_ip + ':7788')); if (on && addrs.length) accCard.appendChild(E('p', { 'class': 'zm-hint' }, [ 'Вне дома откройте панель: ', addrs[0], addrs[1] ? E([ ' или ', addrs[1] ]) : '' ])); } function renderAll() { renderHead(); renderView(); renderActions(); renderAccess(); } card.appendChild(headEl); card.appendChild(viewEl); card.appendChild(actEl); wrap.appendChild(card); wrap.appendChild(accCard); wrap.appendChild(logEl); renderAll(); if (data.job) follow(''); else autoStart(); return wrap; } }); ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/terminal.js' cat > '/www/luci-static/resources/view/zapret-manager/tailscale.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require view'; 'require zapret-manager.common as zm'; var E = (function(raw) { return function() { var a = Array.prototype.slice.call(arguments), i = a.length - 1; if (i >= 1 && (typeof a[i] === 'string' || typeof a[i] === 'number')) a[i] = [ String(a[i]) ]; return raw.apply(null, a); }; })(window.E); var ROUTERICH = 'https://rc.routerich.ru'; var ROUTERICH_NEW = 'https://remote.routerich.ru/create-tailnet'; var APK_URL = 'https://github.com/tailscale/tailscale-android/releases/latest'; var IOS_URL = 'https://apps.apple.com/app/tailscale/id1470499037'; var TESTFLIGHT_URL = 'https://testflight.apple.com/join/tLcYLZJV'; var TAILSOCKS_URL = 'https://github.com/bropines/tailsocks/releases/latest'; 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 ext(href, text) { return E('a', { 'href': href, 'target': '_blank', 'rel': 'noopener' }, text); } function link(href, text, cls) { return E('a', { 'class': 'cbi-button ' + (cls || ''), 'href': href, 'target': '_blank', 'rel': 'noopener' }, text); } function steps(items) { return E('ol', { 'class': 'zm-ts-steps' }, items.filter(Boolean).map(function(it) { return E('li', {}, [ E('div', {}, it) ]); })); } function code(text, okMsg) { return E('div', { 'class': 'zm-ts-cmd' }, [ E('code', {}, text), E('button', { 'type': 'button', 'class': 'cbi-button', 'click': function() { zm.copyText(text, okMsg || 'Скопировано'); } }, 'Скопировать') ]); } return view.extend({ load: function() { zm.injectCss(); return null; }, render: function() { var data = {}, loading = true, loadErr = '', slowTimer = null; var wrap = E('div', { 'class': 'zm-wrap zm-tsc' }); var mainCard = E('div', { 'class': 'zm-card' }); var setCard = E('div', { 'class': 'zm-card' }); var phoneCard = E('div', { 'class': 'zm-card' }); var bypassCard = E('div', { 'class': 'zm-card' }); var logEl = E('pre', { 'class': 'zm-log' }); var busy = false, loginUrl = '', watchTimer = null, watchLeft = 0, luciMode = false; var DONE = { install: 'Tailscale установлен — осталось подключить роутер к сети', remove: 'Tailscale удалён', repair: 'Настройки для телефона восстановлены' }; function loggedIn() { return data.state === 'Running'; } function official() { return !data.server_kind || data.server_kind === 'official'; } function srvName() { return official() ? 'Tailscale' : data.server_kind === 'routerich' ? 'Routerich' : (data.server || 'свой сервер'); } function panel(path) { if (!data.admin) return ''; if (official()) return data.admin + path; if (data.server_kind === 'routerich') return data.admin + ({ '/machines': '/devices', '/dns': '/settings' }[path] || ''); return data.admin; } function panelLink(path, text) { var u = panel(path); return u ? ext(u, text) : E('b', {}, text); } function luciUrl() { return '/cgi-bin/luci/' + (data.luci_path || 'admin/services/tailscale'); } function luciWhere() { return 'LuCI → ' + (/^admin\/vpn\//.test(data.luci_path || '') ? 'VPN' : 'Службы') + ' → Tailscale'; } function keyLogin() { return !official() && data.has_key; } var FOLD_KEY = 'zm-tsc-fold', folded = {}; try { folded = JSON.parse(localStorage.getItem(FOLD_KEY) || '{}') || {}; } catch (e) { folded = {}; } function foldHead(id, title) { var open = !folded[id]; function flip() { folded[id] = open; if (!folded[id]) delete folded[id]; try { localStorage.setItem(FOLD_KEY, JSON.stringify(folded)); } catch (e) {} renderAll(); } return E('div', { 'class': 'zm-tsc-fhead' + (open ? ' zm-svc-open' : ''), 'role': 'button', 'tabindex': '0', 'aria-expanded': open ? 'true' : 'false', 'title': open ? 'Свернуть' : 'Развернуть', 'click': flip, 'keydown': function(ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); flip(); } } }, [ title, E('span', { 'class': 'zm-svc-chev' }, '›') ]); } function keyDate() { var d = data.key_expiry ? new Date(data.key_expiry) : null; return d && !isNaN(d) && d.getFullYear() > 2000 ? d : null; } function keyExp() { var d = keyDate(); return d ? ('0' + d.getDate()).slice(-2) + '.' + ('0' + (d.getMonth() + 1)).slice(-2) + '.' + d.getFullYear() : ''; } function keyDays() { var d = keyDate(); return d ? Math.floor((d - Date.now()) / 86400000) : 9999; } // Ссылку для входа берём из состояния роутера: вход могли начать или закончить и в LuCI, // и в другой вкладке. Не ждёт входа (NeedsLogin) — плашки «Войдите» нет. function syncLogin() { var s = data.state || ''; if (s === 'Running') luciMode = false; if (!data.running || s === 'Running' || s === 'Starting' || s === 'NeedsMachineAuth' || s === 'Stopped') loginUrl = ''; else if (s === 'NeedsLogin' && data.auth_url) loginUrl = data.auth_url; } function waiting() { return data.running && !loggedIn() && (data.state === 'NeedsLogin' || data.state === 'Starting' || data.state === 'NoState' || data.state === 'NeedsMachineAuth'); } function refresh() { return zm.tscStatus().then(function(d) { data = d || {}; syncLogin(); renderAll(); }); } // Пока роутер не в сети — следим за входом: первые 3 минуты раз в 3 с, дальше раз в 15 с, // пока открыта страница. Вход мог занять больше 3 минут или пройти в LuCI. function watchLogin() { if (watchTimer) clearTimeout(watchTimer); watchLeft = 0; (function tick() { watchTimer = setTimeout(function() { if (!document.body.contains(wrap)) { watchTimer = null; return; } if (busy) { tick(); return; } zm.tscStatus().then(function(d) { var was = loggedIn(); data = d || {}; syncLogin(); renderAll(); if (loggedIn()) { watchTimer = null; if (!was) zm.toast('Вход выполнен — роутер в вашей сети ' + srvName(), 'info'); return; } tick(); }).catch(function() { tick(); }); }, ++watchLeft <= 60 ? 3000 : 15000); })(); } function follow(action) { busy = true; renderAll(); zm.pollJob('tsc', logEl, function(ok) { busy = false; zm.toast(ok ? (DONE[action] || 'Готово') : 'Не получилось — подробности в журнале', ok ? 'info' : 'error'); refresh(); }); } function job(action, text) { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } zm.tscAction(action, '').then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast(text, 'warning'); follow(action); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } function quick(action, mode, okText) { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return Promise.resolve(null); } busy = true; renderAll(); return zm.tscAction(action, mode || '').then(function(res) { busy = false; if (res.error) zm.toast(res.error, 'error'); else if (okText) zm.toast(okText, 'info'); return refresh().then(function() { return res; }); }).catch(function() { busy = false; zm.toast('Роутер не ответил', 'error'); renderAll(); return null; }); } // Вход через аккаунт из панели у части роутеров не завершается — роутер ждёт входа, хотя сайт // пишет «Login successful». Кнопка Login в LuCI входит надёжно, поэтому ведём туда. function luciLogin() { var w = null; luciMode = true; try { w = window.open(luciUrl(), '_blank'); } catch (e) { w = null; } zm.toast(w ? 'Открыли LuCI → Tailscale — войдите на вкладке Account Settings' : 'Нажмите «Войти через LuCI»', 'info'); renderAll(); watchLogin(); } function login(noPopup) { if (!keyLogin() && data.luci_app) { luciLogin(); return Promise.resolve(null); } var w = null; if (!keyLogin() && !noPopup) { try { w = window.open('', '_blank'); } catch (e) { w = null; } } if (keyLogin()) zm.toast('Подключаем роутер к ' + srvName() + ' по ключу…', 'info'); return quick('login', '', '').then(function(res) { if (!res || res.error) { if (w) w.close(); return; } if (res.url) { loginUrl = res.url; if (w) { try { w.location.href = res.url; } catch (e) { w.close(); w = null; } } zm.toast(w ? 'Войдите в открывшейся вкладке' : 'Нажмите «Открыть страницу входа»', 'info'); renderAll(); watchLogin(); } else { if (w) w.close(); zm.toast(res.state === 'NeedsMachineAuth' ? 'Роутер подключён — подтвердите его в панели сервера' : 'Роутер подключён к сети ' + srvName(), 'info'); } }); } function routerichDialog() { return zm.dialog({ title: 'Подключение к Routerich', okText: 'Подключить', blocks: [ { type: 'list', title: 'Если сети ещё нет', items: [ E('span', {}, [ 'Откройте ', ext(ROUTERICH_NEW, 'remote.routerich.ru/create-tailnet'), ' и нажмите «Create My Tailnet»' ]), 'Сохраните оба ключа (кнопка «Download Details») — их показывают один раз и не восстанавливают', 'Management Key — для входа в панель Routerich, Device Auth Key — сюда и на телефон', 'Ключ действует год. Сетей можно создать не больше двух в день с одного адреса' ] }, { type: 'input', id: 'key', title: 'Device Auth Key', placeholder: 'ключ устройства' } ], canOk: function(x) { return String(x.key || '').trim().length >= 8; } }).then(function(x) { if (!x) return; var k = String(x.key).trim(); if (/^[0-9a-f]{64}$/i.test(k)) { zm.toast('Это Management Key — ключ входа в панель. Для роутера нужен Device Auth Key', 'error'); return; } return apply(ROUTERICH, k); }); } function customDialog() { return zm.dialog({ title: 'Свой сервер', okText: 'Подключить', blocks: [ { type: 'input', id: 'url', title: 'Адрес сервера', placeholder: 'https://headscale.example.com', value: data.server_kind === 'custom' ? data.server : '' }, { type: 'input', id: 'key', title: 'Ключ устройства (если есть)', placeholder: 'auth key', hint: 'Без ключа панель покажет ссылку для входа, если сервер её выдаёт.' } ], canOk: function(x) { return /^https?:\/\/\S+$/.test(String(x.url || '').trim()); } }).then(function(x) { if (x) return apply(String(x.url).trim(), String(x.key || '').trim()); }); } function officialLogin() { if (official()) return login(false); return apply('', ''); } function changeServer() { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } var cur = official() ? 'official' : data.server_kind; zm.dialog({ title: 'Сервер Tailscale', okText: 'Дальше', intro: 'Через сервер устройства находят друг друга. Сам трафик идёт напрямую между телефоном и роутером и зашифрован — сервер его не видит.', blocks: [ { type: 'choice', id: 'k', value: cur, title: 'Где ваша сеть', options: [ { id: 'routerich', label: 'Routerich — рекомендуется', hint: 'Вход по ключу, без аккаунта. Сеть создаётся в один клик на remote.routerich.ru. Работает из России.' }, { id: 'official', label: 'Аккаунт Tailscale', hint: 'Вход через Google, Apple, Microsoft или GitHub. Сайт Tailscale из России часто не открывается.' }, { id: 'custom', label: 'Свой сервер', hint: 'Headscale, ionscale или другой совместимый сервер.' } ] } ] }).then(function(v) { if (!v) return; if (v.k === 'official') return apply('', ''); if (v.k === 'routerich') return routerichDialog(); return customDialog(); }); } function apply(srv, key) { return quick('server', srv + '\t' + key, '').then(function(res) { if (!res || res.error) return; if (!data.running) { zm.toast('Сервер сохранён — запустите Tailscale', 'info'); return; } if (loggedIn() && !res.changed && !key) { zm.toast('Сервер не изменился', 'info'); return; } return login(true); }); } function btn(cls, text, fn) { return E('button', { 'class': 'cbi-button ' + cls, 'disabled': busy ? '' : null, 'click': fn }, text); } function renderIntro() { var free = +data.free_kb || 0, need = +data.need_kb || 20480, low = free > 0 && free < need; mainCard.appendChild(E('h3', {}, 'Удалённый доступ')); mainCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Работает на Tailscale. Телефон в любой сети — мобильный интернет, кафе, поездка — выходит в интернет через домашний роутер, и Zapret, Steer и sTGWS работают для него как дома.')); mainCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show', 'role': 'note', 'style': 'margin:12px 0 4px; font-size:14px' }, [ E('span', {}, [ E('b', {}, 'Не для новичков.'), ' Ставьте, только если знаете, как настраивается Tailscale: сервер, одобрение маршрутов, приложение на телефоне. Белые списки он не обходит — для них есть ', E('a', { 'href': 'http://stresskvn.lol/', 'target': '_blank', 'rel': 'noreferrer' }, 'StressKVN'), '.' ]) ])); mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Всё ставится с GitHub: уменьшенная сборка Tailscale для OpenWrt и страница для LuCI.')); mainCard.appendChild(E('div', { 'class': 'zm-kv' }, [ row('Нужно', E('span', {}, 'около ' + Math.round(need / 1024) + ' МБ флеш-памяти')), free ? row('Свободно', low ? badge('zm-warn', Math.round(free / 1024) + ' МБ — не хватает') : E('span', {}, Math.round(free / 1024) + ' МБ')) : E([]) ])); mainCard.appendChild(E('div', { 'class': 'zm-actions' }, [ btn('cbi-button-positive', 'Установить', function() { job('install', 'Устанавливаем Tailscale — ход работы виден в окне вывода'); }) ])); } function renderMain() { mainCard.innerHTML = ''; if (!data.installed) { renderIntro(); return; } mainCard.appendChild(E('h3', {}, 'Удалённый доступ')); var st = data.state || '', kv = E('div', { 'class': 'zm-kv' }); kv.appendChild(row('Служба', data.running ? badge('zm-ok', 'работает') : badge(data.enabled ? 'zm-bad' : 'zm-off', 'остановлена'))); kv.appendChild(row('Сервер', E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap' }, [ E('span', {}, srvName() + (keyLogin() ? ' · вход по ключу' : '')), E('button', { 'type': 'button', 'class': 'cbi-button', 'style': 'padding:2px 10px', 'disabled': busy ? '' : null, 'click': changeServer }, 'Сменить') ]))); if (data.running) { kv.appendChild(row('Сеть', loggedIn() ? E('span', {}, data.tailnet || 'подключена') : st === 'NeedsMachineAuth' ? badge('zm-warn', 'ждёт подтверждения роутера') : st === 'Starting' ? badge('zm-warn', 'вход выполнен — подключаемся к серверу') : badge('zm-warn', 'роутер не подключён'))); } if (data.ip) kv.appendChild(row('Роутер в сети', E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap' }, [ E('span', {}, (data.host ? data.host + ' · ' : '') + data.ip), E('button', { 'type': 'button', 'class': 'cbi-button', 'style': 'padding:2px 10px', 'click': function() { zm.copyText(data.ip, 'Адрес скопирован'); } }, 'Скопировать') ]))); if (loggedIn()) { kv.appendChild(row('Выход в интернет', !data.exit_want ? badge('zm-off', 'выключен') : data.exit_ok ? badge('zm-ok', 'готов — выберите роутер на телефоне') : badge('zm-warn', 'ждёт одобрения на сервере'))); if (data.lan_want) kv.appendChild(row('Домашняя сеть', data.lan_ok ? badge('zm-ok', 'доступна · ' + data.lan_cidr) : badge('zm-warn', 'ждёт одобрения · ' + data.lan_cidr))); if (data.manual_ok) kv.appendChild(row('Одобрение', E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap' }, [ E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'отмечено вручную'), E('button', { 'type': 'button', 'class': 'cbi-button', 'style': 'padding:2px 10px', 'disabled': busy ? '' : null, 'click': function() { quick('approved', 'off', 'Отметка снята — панель снова проверяет сервер'); } }, 'Снять отметку') ]))); kv.appendChild(row('Вход роутера', keyExp() ? badge(keyDays() < 30 ? 'zm-bad' : 'zm-warn', 'истекает ' + keyExp()) : badge('zm-ok', 'бессрочный'))); } var srcName = data.source === 'github' ? ' · сборка с GitHub' : data.source === 'openwrt' ? ' · из репозитория OpenWrt' : ''; kv.appendChild(row('Версия', E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap' }, [ E('span', {}, (data.version || '—') + srcName), data.newer ? E('button', { 'type': 'button', 'class': 'cbi-button cbi-button-action', 'style': 'padding:2px 10px', 'disabled': busy ? '' : null, 'click': function() { job('update', 'Обновляем Tailscale — ход работы в окне вывода'); } }, 'Обновить до ' + data.latest) : E([]) ]))); mainCard.appendChild(kv); if (data.on === false) { mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Tailscale установлен не через панель. Нажмите «Настроить для телефона» — панель подготовит сеть и firewall.')); } else if (data.running && (!data.net || !data.zone)) { mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Часть настроек сети пропала (интерфейс tailscale или его место в домашней зоне firewall). Нажмите «Исправить».')); } var viaLuci = data.luci_app && !keyLogin(); if (data.running && !loggedIn() && viaLuci && (luciMode || loginUrl)) { mainCard.appendChild(E('div', { 'class': 'zm-tsc-login' }, [ E('b', {}, 'Войдите через LuCI'), E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Так вход проходит надёжнее всего. Страница обновится сама, когда роутер подключится.'), steps([ [ 'Нажмите ', E('b', {}, 'Войти через LuCI'), ' — откроется ', luciWhere(), '. Перейдите на вкладку ', E('b', {}, 'Account Settings') ], official() ? [ 'Поля ', E('b', {}, 'Custom Login Server'), ' и ', E('b', {}, 'Auth Key'), ' оставьте пустыми' ] : [ 'В ', E('b', {}, 'Custom Login Server'), ' вставьте ', E('code', {}, data.server || ''), ', поле ', E('b', {}, 'Auth Key'), ' оставьте пустым' ], [ 'Нажмите ', E('b', {}, 'Login'), ', откройте ссылку и войдите в ', E('b', {}, 'тот же аккаунт'), official() ? ' (Google, Apple, Microsoft или GitHub), что и на телефоне' : ', что и на телефоне' ], [ 'Вернитесь сюда — панель сама увидит вход и включит выход в интернет и домашнюю сеть' ] ]), E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Если LuCI пишет «NOT RUNNING» и не даёт войти — нажмите здесь «Перезапустить» и попробуйте снова.'), E('div', { 'class': 'zm-actions', 'style': 'margin:2px 0 0' }, [ link(luciUrl(), 'Войти через LuCI', 'cbi-button-positive') ]) ])); } if (data.running && !loggedIn() && !viaLuci && loginUrl) { mainCard.appendChild(E('div', { 'class': 'zm-tsc-login' }, [ E('b', {}, 'Войдите в ' + srvName()), E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, official() ? 'Подойдёт Google, Microsoft, Apple или GitHub. Страница обновится сама, когда вход пройдёт.' : 'Страница обновится сама, когда вход пройдёт.'), link(loginUrl, 'Открыть страницу входа', 'cbi-button-positive') ])); } if (data.running && !loggedIn() && !loginUrl && !(viaLuci && luciMode) && st !== 'NeedsMachineAuth' && !keyLogin()) { mainCard.appendChild(E('div', { 'class': 'zm-tsc-login' }, [ E('b', {}, 'Подключите роутер к сети'), E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Сайт Tailscale из России часто не открывается, поэтому проще Routerich: сеть создаётся в один клик, роутер и телефон входят по ключу.'), E('div', { 'class': 'zm-actions', 'style': 'margin:2px 0 0' }, [ btn('cbi-button-positive', 'Routerich — по ключу', routerichDialog), btn('', 'Аккаунт Tailscale', officialLogin), btn('', 'Свой сервер', customDialog) ]), E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, [ E('b', {}, 'Роутер и телефон должны быть в одной сети: '), 'войдите в один и тот же аккаунт Tailscale или используйте один и тот же ключ Routerich.' ]), data.luci_app ? E('div', { 'class': 'zm-tsc-luci' }, [ E('b', {}, 'Или подключите роутер в LuCI'), steps([ [ 'Откройте ', ext(luciUrl(), luciWhere()), ' → вкладка ', E('b', {}, 'Account Settings'), '. Если там «Service Status: NOT RUNNING» — сначала нажмите «Перезапустить» на этой странице' ], [ E('b', {}, 'Routerich'), ': в ', E('b', {}, 'Custom Login Server'), ' вставьте ', E('code', {}, ROUTERICH), ', в ', E('b', {}, 'Custom Login Server Auth Key'), ' — ваш Device Auth Key. ', E('b', {}, 'Аккаунт Tailscale'), ': оба поля оставьте пустыми' ], [ 'Нажмите ', E('b', {}, 'Login'), '. Для аккаунта Tailscale откроется ссылка — войдите в ', E('b', {}, 'тот же аккаунт'), ' (Google, Apple, Microsoft или GitHub), что и на телефоне' ], [ 'Вернитесь на эту страницу — панель увидит подключение сама, включит выход в интернет и домашнюю сеть' ] ]) ]) : E([]) ])); } if (data.luci_app && data.on !== false) { mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'В LuCI кнопку «Auto Configure Firewall» нажимать не нужно — панель уже настроила сеть и firewall для телефона.')); } if (st === 'NeedsMachineAuth') { mainCard.appendChild(E('p', { 'class': 'zm-hint' }, [ 'В вашей сети новые устройства нужно подтверждать. Откройте ', panelLink('/machines', 'список устройств'), ' и подтвердите роутер.' ])); } if (loggedIn() && data.prefs_ok === false) { mainCard.appendChild(E('div', { 'class': 'zm-tsc-box' }, [ E('b', {}, 'Настройки Tailscale на роутере сбились'), E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Так бывает после входа через LuCI: роутер не предлагает себя как выход в интернет и домашнюю сеть, а Tailscale ставит свои правила firewall, из-за которых перестают работать Steer и Forkozz. Панель исправляет это сама раз в минуту — или нажмите кнопку сейчас.'), E('div', {}, [ btn('cbi-button-action', 'Применить настройки', function() { quick('apply', '', 'Настройки применены — роутер предлагает себя как выход'); }) ]) ])); } if (loggedIn() && data.lan_prev && data.lan_ip) { var dnsLan = data.lan_want !== false; mainCard.appendChild(E('div', { 'class': 'zm-tsc-box' }, [ E('b', {}, 'Адрес роутера в домашней сети изменился: ' + data.lan_prev + ' → ' + data.lan_ip), E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Роутер, Telegram через sTGWS и маршрут домашней сети панель уже перенастроила сама.' + (dnsLan ? ' Осталось одно — DNS телефона: он указан в панели сервера, и туда роутер сам не дотянется.' : '')), dnsLan ? steps([ [ 'Откройте ', official() ? panelLink('/dns', 'настройки DNS') : data.server_kind === 'routerich' ? panelLink('/dns', 'настройки панели Routerich') : 'панель своего сервера', ' и замените сервер имён ', E('code', {}, data.lan_prev), ' на ', code(data.lan_ip, 'Адрес роутера скопирован') ], data.lan_ok ? [ 'Подсеть ', E('b', {}, data.lan_cidr), ' уже одобрена' ] : [ 'Одобрите новую подсеть ', E('b', {}, data.lan_cidr), ' — инструкция ниже' ] ]) : E([]), E('div', {}, [ btn('cbi-button-action', dnsLan ? 'Готово, DNS заменил' : 'Понятно', function() { quick('lanack', '', 'Отмечено'); }) ]) ])); } if (loggedIn() && keyExp() && folded.key && keyDays() >= 14) { mainCard.appendChild(E('div', { 'class': 'zm-tsc-box' }, [ foldHead('key', E('b', {}, 'Вход роутера закончится ' + keyExp() + ' (через ' + keyDays() + ' дн.)')) ])); } else if (loggedIn() && keyExp()) { mainCard.appendChild(E('div', { 'class': 'zm-tsc-box' }, [ keyDays() >= 14 ? foldHead('key', E('b', {}, 'Вход роутера закончится ' + keyExp() + ' (через ' + keyDays() + ' дн.)')) : E('b', {}, 'Вход роутера закончится ' + keyExp() + (keyDays() >= 0 ? ' (через ' + keyDays() + ' дн.)' : '')), E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'После этого телефон потеряет связь с домом, пока роутер не войдёт заново. Сделайте вход роутера бессрочным — это делается один раз, адрес ' + (data.ip || '100.x') + ' останется прежним.'), official() ? steps([ [ 'Откройте ', panelLink('/machines', 'список устройств'), ' в панели Tailscale' ], [ 'У роутера ', E('b', {}, data.host || 'openwrt'), ' нажмите «···» → ', E('b', {}, 'Disable key expiry') ] ]) : steps([ [ 'Откройте ', data.server_kind === 'routerich' ? panelLink('/machines', 'устройства в панели Routerich') : 'панель своего сервера', ' и найдите роутер ', E('b', {}, data.host || 'openwrt') ], [ 'Отключите для него истечение ключа (Key expiry / Disable expiry), если сервер это позволяет' ] ]), E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Статус обновится сам.') ])); } if (loggedIn() && data.prefs_ok !== false && ((data.exit_want && !data.exit_ok) || (data.lan_want && !data.lan_ok))) { var what = []; if (data.exit_want && !data.exit_ok) what.push(E('span', {}, official() ? [ 'Включите ', E('b', {}, 'Use as exit node') ] : [ 'Одобрите ', E('b', {}, 'Exit node'), ' (0.0.0.0/0)' ])); if (data.lan_want && !data.lan_ok) what.push(E('span', {}, [ what.length ? 'отметьте подсеть ' : 'Отметьте подсеть ', E('b', {}, data.lan_cidr) ])); var whatNode = what.length > 1 ? E('span', {}, [ what[0], ' и ', what[1] ]) : what[0]; mainCard.appendChild(E('div', { 'class': 'zm-tsc-box' }, [ E('b', {}, 'Одобрите роутер на сервере — это делается один раз'), official() ? steps([ [ 'Откройте ', panelLink('/machines', 'список устройств'), ' в панели Tailscale' ], [ 'У роутера ', E('b', {}, data.host || 'openwrt'), ' нажмите «···» → «Edit route settings»' ], [ whatNode, ' и сохраните. Галочки неактивны? Роутер ещё не предложил маршруты — нажмите «Применить настройки заново» ниже и обновите страницу панели Tailscale' ] ]) : steps([ data.server_kind === 'routerich' ? [ 'Откройте ', panelLink('/machines', 'устройства в панели Routerich'), ' (вход по Management Key)' ] : [ 'Откройте панель своего сервера' + (data.server ? ' (' + data.server + ')' : '') ], data.server_kind === 'routerich' ? (data.exit_want && !data.exit_ok ? [ 'У роутера ', E('b', {}, data.host || 'openwrt'), ' включите тумблер ', E('b', {}, 'Exit Node'), ' — он между кнопкой «Маршруты» и удалением, его легко не заметить' ] : [ 'Найдите роутер ', E('b', {}, data.host || 'openwrt') ]) : [ 'Найдите роутер ', E('b', {}, data.host || 'openwrt'), ' в списке устройств и откройте его маршруты' ], data.server_kind === 'routerich' ? (data.lan_want && !data.lan_ok ? [ 'Нажмите ', E('b', {}, 'Маршруты'), ', отметьте подсеть ', E('b', {}, data.lan_cidr), ' и сохраните' ] : null) : [ whatNode ] ].filter(Boolean)), E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Статус здесь обновится в течение минуты. Уже одобрили, телефон видит домашнюю сеть и выходит в интернет через роутер, а надпись не уходит? Часть серверов не сообщает роутеру об одобрении — отметьте вручную.'), E('div', {}, [ E('button', { 'type': 'button', 'class': 'cbi-button', 'disabled': busy ? '' : null, 'click': function() { zm.dialog({ title: 'Всё уже работает?', okText: 'Да, всё работает', blocks: [ { type: 'list', title: 'Отметьте, только если с телефона через Tailscale', items: [ data.lan_want && !data.lan_ok ? 'открываются устройства домашней сети (например, LuCI по адресу ' + (data.lan_ip || 'роутера') + ')' : null, data.exit_want && !data.exit_ok ? 'работает выход в интернет через роутер' : null, 'Панель перестанет показывать «ждёт одобрения»; сменится домашняя подсеть — отметка сбросится сама' ].filter(Boolean) } ] }).then(function(v) { if (v) quick('approved', 'on', 'Отмечено: роутер одобрен на сервере'); }); } }, 'У меня всё работает') ]) ])); } var b = []; if (!data.running) b.push(btn('cbi-button-positive', 'Запустить', function() { quick('restart', '', 'Tailscale запущен'); })); else if (!loggedIn() && (keyLogin() || (loginUrl && !viaLuci))) b.push(btn('cbi-button-positive', keyLogin() ? 'Подключить' : 'Получить новую ссылку', function() { login(false); })); if (data.on === false) b.push(btn('cbi-button-action', 'Настроить для телефона', function() { job('repair', 'Настраиваем сеть для телефона — ход работы в окне вывода'); })); else if (data.running && (!data.net || !data.zone)) b.push(btn('cbi-button-action', 'Исправить', function() { job('repair', 'Восстанавливаем настройки — ход работы в окне вывода'); })); if (data.running) b.push(btn('', 'Перезапустить', function() { quick('restart', '', 'Tailscale перезапущен'); })); if (panel('/machines')) b.push(link(panel('/machines'), official() ? 'Панель Tailscale' : 'Панель ' + srvName())); if (data.luci_app) b.push(link(luciUrl(), 'Открыть в LuCI')); if (loggedIn()) b.push(btn('', 'Отключить роутер', function() { zm.dialog({ title: 'Отключить роутер от сети?', okText: 'Отключить', blocks: [ { type: 'list', title: 'Что произойдёт', items: [ 'Роутер пропадёт из вашей сети ' + srvName(), 'Телефоны перестанут выходить в интернет через него', 'Если роутер больше не нужен в сети — удалите его и в панели ' + srvName(), 'Подключить снова можно в любой момент' ] } ] }).then(function(v) { if (v) quick('logout', '', 'Роутер отключён от сети'); }); })); b.push(btn('cbi-button-remove', 'Удалить', function() { zm.dialog({ title: 'Удалить Tailscale?', danger: true, okText: 'Удалить Tailscale', blocks: [ { type: 'list', title: 'Что произойдёт', items: [ 'Роутер отключится и пропадёт из вашей сети', 'Пакет tailscale удалится вместе с настройками и ключом', 'Сеть, firewall и DNS вернутся к прежнему виду' ] } ] }).then(function(v) { if (v) job('remove', 'Удаляем Tailscale — ход работы виден в окне вывода'); }); })); mainCard.appendChild(E('div', { 'class': 'zm-actions' }, b)); } function renderSet() { setCard.innerHTML = ''; setCard.style.display = data.installed && data.on !== false ? '' : 'none'; if (setCard.style.display) return; setCard.appendChild(E('h3', {}, 'Настройки')); setCard.appendChild(zm.swRow(data.exit_want !== false, 'Выход в интернет через роутер', 'Телефон ходит в интернет через дом — с обходом блокировок.', function() { var on = data.exit_want === false; quick('exit', on ? 'on' : 'off', on ? 'Роутер снова предлагается как выход в интернет' : 'Роутер больше не выход в интернет'); }, busy)); setCard.appendChild(zm.swRow(!!data.lan_want, 'Доступ к домашней сети', 'С телефона откроются роутер, эта панель и устройства дома' + (data.lan_cidr ? ' (' + data.lan_cidr + ')' : '') + '.', function() { var on = !data.lan_want; quick('lan', on ? 'on' : 'off', on ? 'Домашняя сеть будет доступна после одобрения на сервере' : 'Доступ к домашней сети выключен'); }, busy)); if (loggedIn()) setCard.appendChild(E('div', { 'class': 'zm-actions', 'style': 'margin-top:4px' }, [ btn('', 'Применить настройки заново', function() { quick('apply', '', 'Настройки применены'); }) ])); if (data.term_mode && data.term_mode !== 'all') setCard.appendChild(E('p', { 'class': 'zm-hint' }, [ E('b', {}, 'Терминал'), ' сейчас открывается только ' + (data.term_mode === 'ts' ? 'через Tailscale, а дома нет' : 'дома') + '. Включите «Открывать через Tailscale» на ', E('a', { 'href': '#terminal' }, 'странице Терминала'), ', чтобы он работал и там, и там.' ])); setCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Панель раз в минуту проверяет, что всё на месте, и сама возвращает настройки — после входа через LuCI, перезагрузки роутера и обновления Forkozz, Zapret или sTGWS. Tailscale она держит без своих правил firewall и без MagicDNS на роутере, а после каждого его перезапуска перезапускает Forkozz и Steer: так он им не мешает.')); } function renderPhone() { phoneCard.innerHTML = ''; phoneCard.style.display = data.installed && data.on !== false ? '' : 'none'; if (phoneCard.style.display) return; var host = E('b', {}, data.host || 'роутер'); phoneCard.appendChild(foldHead('phone', E('h3', { 'style': 'margin:0' }, 'Как подключить телефон'))); if (folded.phone) return; phoneCard.appendChild(E('div', { 'style': 'height:12px' })); // адрес роутера для DNS телефона: домашний, если одобрена домашняя сеть, иначе адрес в Tailscale var useLan = data.lan_want !== false && data.lan_ip; var dnsIp = useLan ? data.lan_ip : data.ip; var ipNode = dnsIp ? code(dnsIp, 'Адрес роутера скопирован') : E('span', { 'class': 'zm-hint' }, ' — он появится после подключения роутера'); phoneCard.appendChild(E('h4', { 'style': 'margin:0 0 6px' }, '1. Один раз — в панели ' + (official() ? 'Tailscale' : srvName()))); phoneCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin:0 0 4px' }, 'Чтобы телефон узнавал адреса сайтов у роутера: так работают Steer, Forkozz, Mixomo и блокировка рекламы, а запросы не уходят мимо.')); if (official()) phoneCard.appendChild(steps([ [ 'Откройте ', panelLink('/dns', 'настройки DNS'), ' → ', E('b', {}, 'Nameservers'), ' → ', E('b', {}, 'Add nameserver'), ' → ', E('b', {}, 'Custom…'), ' и вставьте адрес роутера:', ipNode ], [ 'В этом же окне включите ', E('b', {}, 'Use with exit node'), ' и нажмите ', E('b', {}, 'Save'), '. ', E('b', {}, 'Restrict to domain'), ' (Split DNS) не включайте' ], [ 'Справа от ', E('b', {}, 'Global nameservers'), ' включите ', E('b', {}, 'Override DNS servers') ], [ E('b', {}, 'MagicDNS'), ' должен быть ', E('b', {}, 'выключен'), ' — кнопку «Enable MagicDNS» не нажимайте. Если он включён — нажмите «Disable MagicDNS»' ] ])); else phoneCard.appendChild(steps([ [ 'Откройте ', data.server_kind === 'routerich' ? panelLink('/dns', 'настройки панели Routerich') : 'панель своего сервера', ' → раздел ', E('b', {}, 'DNS'), ' и добавьте сервер имён — адрес роутера:', ipNode ], [ 'Если есть настройка «использовать с exit node» — включите её. Включите замену DNS для всех устройств (', E('b', {}, 'Override'), ') и сохраните' ], [ E('b', {}, 'MagicDNS'), ' выключите' ] ])); if (useLan && !data.lan_ok) phoneCard.appendChild(E('p', { 'class': 'zm-hint zm-ab-warn' }, 'Домашняя сеть ' + (data.lan_cidr || '') + ' ещё не одобрена на сервере — без этого телефон не достучится до ' + dnsIp + '. Одобрите её (см. выше).')); phoneCard.appendChild(E('h4', { 'style': 'margin:14px 0 6px' }, '2. На телефоне')); var android = [ E('b', {}, 'Android'), ': ', ext(APK_URL, 'Tailscale'), ' (файл tailscale-android-universal) или ', ext(TAILSOCKS_URL, 'TailSocks'), ' — неофициальный клиент Tailscale с открытым кодом' ]; var iphone = [ E('b', {}, 'iPhone'), ': ', ext(IOS_URL, 'App Store'), ', а если его там нет — ', ext(TESTFLIGHT_URL, 'TestFlight') ]; phoneCard.appendChild(steps([ [ 'Установите приложение ', E('b', {}, 'Tailscale'), '. ' ].concat(android, [ '. ' ], iphone), official() ? [ 'Войдите в ', E('b', {}, 'тот же аккаунт'), ', что и на роутере' + (data.tailnet ? ' (' + data.tailnet + ')' : '') ] : [ 'Укажите сервер. Android: шестерёнка → ', E('b', {}, 'Accounts'), ' → «⋮» → ', E('b', {}, 'Use an alternate server'), '. iPhone: «⋯» → ', E('b', {}, 'Settings'), ' → ', E('b', {}, 'Login Server'), '. Затем войдите с ', E('b', {}, 'Device Auth Key'), ' — тем же, что и для роутера', data.server ? code(data.server, 'Адрес сервера скопирован') : E([]) ], [ 'Откройте ', E('b', {}, 'Exit Node'), ', выберите ', host, ' и включите ', E('b', {}, 'Allow LAN access') ], [ 'Включите VPN — готово. ', E('b', {}, 'Use Tailscale DNS'), ' в приложении не выключайте (по умолчанию включено)' ] ])); phoneCard.appendChild(E('h4', { 'style': 'margin:14px 0 6px' }, 'Если у вас TailSocks')); phoneCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin:0 0 4px' }, 'В TailSocks часть настроек выключена по умолчанию — без них не откроются устройства дома и не заработают Steer и Forkozz.')); phoneCard.appendChild(steps([ [ E('b', {}, 'Настройки → Режим туннеля'), ' → ', E('b', {}, 'VPN (TUN)') ], official() ? null : [ E('b', {}, 'Настройки → Аккаунт и подключение → Сервер авторизации'), ' — адрес сервера (тот же, что выше)' ], [ 'Выберите ', E('b', {}, data.host || 'роутер'), ' выходным узлом (Exit Node)' ], [ E('b', {}, 'Настройки → Аккаунт и подключение'), ': включите ', E('b', {}, 'Доступ к локальной сети'), ' и ', E('b', {}, 'Принимать маршруты подсетей'), ' — без второго не откроются роутер и устройства дома' + (data.lan_cidr ? ' (' + data.lan_cidr + ')' : '') ], [ E('b', {}, 'Настройки → DNS'), ': в ', E('b', {}, 'Адрес DNS-прокси'), ' и ', E('b', {}, 'Резервный DNS'), ' впишите адрес роутера — тогда адреса сайтов телефон узнаёт у роутера, как дома:', dnsIp ? code(dnsIp, 'Адрес роутера скопирован') : E('span', { 'class': 'zm-hint' }, ' — он появится после подключения роутера') ] ])); phoneCard.appendChild(E('p', { 'class': 'zm-hint' }, [ 'TailSocks не подключается к серверу — включите ', E('b', {}, 'Настройки → Обход блокировок'), '.' ])); phoneCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Роутера нет в списке Exit Node? Подождите минуту после входа роутера и откройте список снова. На iPhone помогает включить и выключить «Run as Exit Node».')); if (!official() && data.server) phoneCard.appendChild(E('div', { 'class': 'zm-hint' }, [ E('b', {}, 'Ноутбук в поездке'), ': установите Tailscale с сайта tailscale.com, выполните в терминале (Windows — PowerShell) команду ниже и введите Device Auth Key на открывшейся странице. Затем выберите ', E('b', {}, data.host || 'роутер'), ' как Exit Node.', code('tailscale up --login-server=' + data.server, 'Команда скопирована'), 'Ноутбук уже был в другой сети? Добавьте к команде --reset --force-reauth.' ])); phoneCard.appendChild(E('p', { 'class': 'zm-hint' }, [ E('b', {}, 'Дома VPN Tailscale выключайте'), ' — и на телефоне, и на компьютере. В Wi-Fi роутера Steer, Forkozz и Zapret и так работают, а через Tailscale трафик идёт лишним кругом.' ])); } function bRow(name, node, hint) { return E('div', { 'class': 'zm-tsc-svc' }, [ E('div', { 'class': 'zm-tsc-svc-head' }, [ E('b', {}, name), node ]), hint ? E('div', { 'class': 'zm-hint', 'style': 'margin:2px 0 0' }, hint) : E([]) ]); } function renderBypass() { bypassCard.innerHTML = ''; bypassCard.style.display = data.installed && data.on !== false ? '' : 'none'; if (bypassCard.style.display) return; var dnsNeed = 'Работает, когда DNS телефона — роутер: шаг 1 в «Как подключить телефон».'; var zrInst = data.zapret_inst !== false && (data.zapret_inst || data.zapret), stInst = data.steer_inst !== false && (data.steer_inst || data.steer); if (!zrInst && !data.stgws && !stInst && !data.fk && !data.mx && !data.adblock) { bypassCard.style.display = 'none'; return; } bypassCard.appendChild(E('h3', {}, 'Обход блокировок на телефоне')); if (zrInst) bypassCard.appendChild(bRow('Zapret', data.zapret ? badge('zm-ok', 'работает') : badge('zm-off', 'не запущен'), data.zapret ? 'Включать отдельно не нужно — трафик телефона идёт через роутер и проходит через Zapret.' : 'Запустите Zapret на его странице — тогда он заработает и для телефона.')); if (data.stgws) bypassCard.appendChild(bRow('sTGWS (Telegram)', data.tg_rules >= 2 ? badge('zm-ok', 'работает') : badge('zm-warn', 'подключается'), 'Telegram с телефона идёт через мост sTGWS. Правила восстанавливаются сами после перезапуска sTGWS.')); if (stInst) bypassCard.appendChild(bRow('Steer', data.steer ? badge('zm-ok', 'подключён') : badge('zm-off', 'не включён'), data.steer ? 'Телефон уже в правилах Steer. ' + dnsNeed : '')); if (data.fk) bypassCard.appendChild(bRow('Forkozz', data.fk_ok ? badge('zm-ok', 'подключён') : badge('zm-warn', 'не подключён'), data.fk_ok ? 'Устройства Tailscale добавлены к домашней сети Forkozz. ' + dnsNeed : E('span', {}, [ 'Forkozz пока не берёт трафик телефона. ', btn('cbi-button-action', 'Подключить', function() { job('repair', 'Подключаем Forkozz — ход работы в окне вывода'); }) ]))); if (data.mx) bypassCard.appendChild(bRow('Mixomo', badge('zm-ok', 'готов'), 'Настраивать отдельно не нужно. Работает, когда DNS телефона — домашний адрес роутера' + (data.lan_ip ? ' (' + data.lan_ip + ')' : '') + ': шаг 1 в «Как подключить телефон».')); if (data.adblock) bypassCard.appendChild(bRow('Блокировка рекламы', badge('zm-ok', 'готова'), dnsNeed)); } function renderLoading() { mainCard.innerHTML = ''; mainCard.appendChild(E('h3', {}, 'Удалённый доступ')); if (loadErr) { mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Роутер не ответил: ' + loadErr)); mainCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-action', 'click': firstLoad }, 'Повторить') ])); } else { mainCard.appendChild(E('div', { 'class': 'zm-tsc-wait' }, [ E('span', { 'class': 'zm-tsc-spin' }), E('span', {}, 'Получаем состояние Tailscale…') ])); if (slowTimer === true) mainCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin:8px 0 0' }, 'Роутер отвечает дольше обычного — так бывает сразу после запуска Tailscale.')); } [ setCard, phoneCard, bypassCard ].forEach(function(c) { c.innerHTML = ''; c.style.display = 'none'; }); } function renderAll() { if (loading) { renderLoading(); return; } renderMain(); renderSet(); renderPhone(); renderBypass(); } function firstLoad() { loading = true; loadErr = ''; slowTimer = setTimeout(function() { if (loading && !loadErr) { slowTimer = true; renderAll(); } }, 4000); renderAll(); return zm.tscStatus().then(function(d) { if (slowTimer && slowTimer !== true) clearTimeout(slowTimer); data = d || {}; loading = false; syncLogin(); renderAll(); if (data.job) follow(''); else if (waiting()) watchLogin(); }).catch(function(e) { if (slowTimer && slowTimer !== true) clearTimeout(slowTimer); loadErr = (e && e.message) || String(e || 'нет ответа'); renderAll(); }); } [ mainCard, setCard, phoneCard, bypassCard, logEl ].forEach(function(n) { wrap.appendChild(n); }); firstLoad(); return wrap; } }); ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/tailscale.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 E = (function(raw) { return function() { var a = Array.prototype.slice.call(arguments), i = a.length - 1; if (i >= 1 && (typeof a[i] === 'string' || typeof a[i] === 'number')) a[i] = [ String(a[i]) ]; return raw.apply(null, a); }; })(window.E); var PHASE_TEXT = { install: 'устанавливаем', pkgs: 'ставим движок Steer', awg: 'ставим AmneziaWG', keys: 'получаем ключи WARP', tunnel: 'поднимаем туннели', warp: 'настраиваем туннели', rules: 'применяем правила', check: 'проверяем', remove: 'удаляем', sub: 'настраиваем подписку' }; var STEER_TABS = [ { id: 'conn', label: 'Подключение' }, { id: 'svc', label: 'Сервисы' }, { id: 'dev', label: 'Устройства' }, { id: 'byp', label: 'Исключения', common: true }, { id: 'set', label: 'Настройки', common: true } ]; var SEC_BOUND = { lists: 1, devs_set: 1, sub_status: 1, sub_probe: 1, sub_tcp: 1, lat_mode: 1, node_pol: 1, sub_home: 1, sub_links_get: 1, sub_set: 1, sub_links: 1, sub_update: 1, sub_remove: 1, sub_node: 1, sub_filter: 1, sub_hide: 1, sub_pick: 1, sub_ping: 1 }; 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.', netshift: 'Стоит NetShift — он сам направляет сервисы в туннель. Чтобы поставить Steer, удалите NetShift.' }; 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 chkRow(head, body) { return E('div', { 'class': 'zm-row zm-chk' }, [ E('span', { 'class': 'zm-chk-h' }, [ head ]), E('span', { 'class': 'zm-chk-b' }, [ body ]) ]); } 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; } var DOHN = { cloudflare: 'Cloudflare', google: 'Google', quad9: 'Quad9 · Защита от угроз', xbox: 'XBOX', comss: 'Comss', dnsai: 'DNS-AI', bezmezhau: 'Bezmezhau', yandex: 'Яндекс', yandex_family: 'Яндекс · Семейный фильтр', yandex_safe: 'Яндекс · Защита от угроз', cloudflare_sec: 'Cloudflare · Защита от угроз', cloudflare_fam: 'Cloudflare · Семейный фильтр', quad9_open: 'Quad9 · Без фильтрации', controld_sec: 'Control D · Защита от угроз', controld_fam: 'Control D · Семейный фильтр', dns4eu_sec: 'DNS4EU · Защита от угроз', dns4eu_fam: 'DNS4EU · Семейный фильтр', geohide_ru: 'GeoHide RU', geohide_eu: 'GeoHide EU', geohide_us: 'GeoHide US', malw: 'MALW', zapretmoe: 'zapret.moe', webshift: 'WebShift', dagami: 'Dagami', controld: 'Control D', dns4eu: 'DNS4EU', multi: 'несколько серверов', other: 'свой сервер' }; 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; } 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, viewKind = null, viewRes = null, viewBusy = false; var warpPick = null, ownTa = null, ownOpen = false, ownLoaded = false; var extPick = null, awgData = null, awgAt = 0, awgBusy = false; function awgLoad(force) { if (awgBusy || (!force && awgData && Date.now() - awgAt < 20000)) return; awgBusy = true; zm.awgStatus().then(function(r) { awgBusy = false; awgData = r || {}; awgAt = Date.now(); renderWarp(); renderConn(); }).catch(function() { awgBusy = false; awgData = awgData || { ifaces: [] }; renderWarp(); renderConn(); }); } 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 exclCard = E('div', { 'class': 'zm-card' }); var exclData = null, exclLoading = false, exclEditor = null, exclDraft = null, exclDirty = 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 (tab === 'sub' || tab === 'warp') tab = 'conn'; if (!STEER_TABS.some(function(t) { return t.id === tab; })) tab = 'svc'; var tabBar = E('div', { 'class': 'zm-tabsplit' }); var panes = { conn: E('div', {}), svc: E('div', {}), dev: E('div', {}), byp: E('div', {}), set: E('div', {}) }; var setCard = E('div', { 'class': 'zm-card' }); var secCard = E('div', { 'class': 'zm-card zm-kv' }), connCard = E('div', { 'class': 'zm-card zm-kv' }); var secCur = 'main', secGen = 0, connPick = null, ifChoose = false, ifSel = null, nameDraft = null, wayOpen = false, warpExitAfter = false; try { secCur = localStorage.getItem('zm.steer.sec') || 'main'; } catch (e) {} function secList() { return data.sections || []; } function hasSecs() { return !!data.installed && !data.blocker && secList().length > 0; } function secObj(id) { return secList().filter(function(s) { return s.id === id; })[0] || null; } function isMain() { return secCur === 'main'; } function secFix() { if (secCur !== 'main' && !secObj(secCur)) { secCur = 'main'; try { localStorage.setItem('zm.steer.sec', secCur); } catch (e) {} } } function stA(action, arg) { return zm.steerAction(action, (SEC_BOUND[action] && !isMain() ? '~' + secCur + '~' : '') + (arg || '')); } function stC(action, arg) { return zm.steerAction(action, (isMain() ? '' : '~' + secCur + '~') + (arg || '')); } function secName(id) { if (!id || id === 'main') return data.main_name || 'Основная'; var s = secObj(id); return s ? s.name : 'Секция'; } function secIds() { return [ 'main' ].concat(secList().map(function(s) { return s.id; })); } function secSvc(id) { if (!id || id === 'main') return (data.services || []).filter(function(s) { return s.on; }).map(function(s) { return s.id; }); var s = secObj(id); return s ? (s.services || []).slice() : []; } function secDevs(id) { if (!id || id === 'main') return data.devices || []; var s = secObj(id); return s ? s.devices || [] : []; } function secWork(id) { if (secSvc(id).length || secDevs(id).some(function(d) { return d.mode === 'all'; })) return true; return (!id || id === 'main') && !hasSecs() && (parseInt(data.channels, 10) || 0) > 0; } function anyWork() { return secIds().some(secWork); } function secOff(id) { if (!hasSecs()) return false; if (!id || id === 'main') return !!data.main_off; var s = secObj(id); return !!(s && s.off); } function secExit(id) { if (!id || id === 'main') return data.exit || 'none'; var s = secObj(id); if (!s) return 'none'; return s.live ? s.kind : 'none'; } function secVpnCount(skip) { var n = data.exit === 'vpn' && skip !== 'main' ? 1 : 0; secList().forEach(function(s) { if (s.kind === 'vpn' && s.id !== skip) n++; }); return n; } function svcNames() { var m = {}; (data.services || []).forEach(function(s) { m[s.id] = s.name; }); return m; } 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_iface') msg = 'Steer работает через ' + (data.warp_iface || 'интерфейс AmneziaWG'); else if (lastAction === 'ext_pick') msg = 'Точка входа подобрана'; 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 === 'excl') msg = 'Готово, исключения работают'; else if (lastAction === 'lists' || lastAction === 'start') msg = 'Готово, выбор применён'; else if (/^sub_/.test(lastAction)) msg = 'Готово'; else if (lastAction === 'devs_set') msg = 'Готово, выбор устройств применён'; else if (lastAction === 'engine') msg = 'Движок Steer обновлён'; else if (lastAction === 'sec_kind') msg = 'Подключение секции «' + secName(secCur) + '» применено'; else if (lastAction === 'sec_del') msg = 'Секция удалена'; else if (lastAction === 'sec_off') msg = secOffMsg || 'Готово'; zm.toast(msg, ok ? 'info' : 'error'); var done = lastAction; lastAction = ''; if (ok && (done === 'warp_own' || done === 'warp_mode' || done === 'warp_iface' || done === 'warp_setup')) { warpPick = null; ownOpen = false; extPick = null; wayOpen = false; } var exitWarp = ok && warpExitAfter && /^warp_(setup|own|mode|iface)$/.test(done) && data.exit === 'vpn' && data.warp_on; warpExitAfter = false; if (ok && (done === 'sub_set' || done === 'sub_exit' || done === 'sec_kind')) { connPick = null; ifChoose = false; ifSel = null; } if (ok && done === 'sec_kind') awgData = null; if (done === 'warp_iface' || done === 'ext_pick' || done === 'warp_restart') awgData = null; if (ok && (done === 'sub_set' || done === 'sub_links' || done === 'sub_update')) { probeAfterLoad = true; zm.toast('Серверы обновлены — проверяем задержку до каждого', 'info'); } diagRes = null; domData = {}; renderAll(); if (subShow()) loadSub(); if (exitWarp) { setTimeout(function() { act('sub_exit', 'warp', 'Пускаем сервисы через ' + warpName()); }, 0); return; } 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(); renderSet(); } }).catch(function() {}); }); } function act(action, arg, toastText, done) { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); if (done) done(false); return; } stA(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'); if (action !== 'onfail' && action !== 'devs_set') pick = null; refresh().then(function() { if (subShow()) 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_|ext_)/.test(action) ? 'warp' : /^sub_/.test(action) ? 'sub' : action === 'engine' ? 'pkgs' : 'rules'; follow(); }).catch(function() { if (done) done(false); zm.toast('Роутер не ответил', 'error'); }); } function warpUsers() { return secIds().filter(function(id) { return id === 'main' ? data.exit !== 'vpn' : (secObj(id) || {}).kind === 'warp'; }); } function warpName() { return data.warp_mode === 'iface' && data.warp_iface ? data.warp_iface : data.warp_mode === 'own' ? 'Свой WARP' : 'WARP'; } var devCard = E('div', { 'class': 'zm-card' }), devList = null, devLoading = false, devPick = null; var MAC_RE = /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/i, IP4_RE = /^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/; function loadDevs() { if (devLoading) return; devLoading = true; renderDevs(); zm.steerAction('devices', '').then(function(r) { devLoading = false; devList = (r && r.devices) || []; renderDevs(); renderRoute(); }).catch(function() { devLoading = false; devList = devList || []; renderDevs(); }); } function devSaved() { var m = {}; secDevs(secCur).forEach(function(d) { if (d && d.key) m[String(d.key).toLowerCase()] = { mode: d.mode, name: d.name || '' }; }); return m; } function devElse() { var m = {}; secIds().forEach(function(id) { if (id === secCur) return; secDevs(id).forEach(function(d) { if (!d || !d.key || (d.mode !== 'all' && d.mode !== 'byp')) return; var k = String(d.key).toLowerCase(); if (!m[k]) m[k] = d.mode === 'byp' ? 'мимо Steer' : 'весь трафик через «' + secName(id) + '»'; }); }); return m; } function devSig(m) { return JSON.stringify(Object.keys(m).sort().map(function(k) { return k + '=' + m[k].mode; })); } function devAllCount() { return (data.devices || []).filter(function(d) { return d.mode === 'all'; }).length; } function devBypCount() { return (data.devices || []).filter(function(d) { return d.mode === 'byp'; }).length; } function renderDevs() { devCard.innerHTML = ''; devCard.style.display = data.blocker || !data.installed ? 'none' : ''; if (data.blocker || !data.installed) return; var main = isMain(), M2 = main ? 'byp' : 'only'; var saved = devSaved(), cur = devPick || saved, changed = devSig(cur) !== devSig(saved), other = devElse(); var known = {}, rows = []; (devList || []).forEach(function(d) { var k = String(d.mac || '').toLowerCase(); if (!k || known[k]) return; known[k] = true; if (d.ip) known[d.ip] = k; rows.push({ key: k, name: d.name || '', ip: d.ip || '', mac: k }); }); Object.keys(cur).forEach(function(k) { if (known[k] === true) return; known[k] = true; rows.push({ key: k, name: cur[k].name || '', ip: IP4_RE.test(k) ? k : '', mac: MAC_RE.test(k) ? k : '', manual: true }); }); var T1 = main ? '«Весь трафик через Steer»' : '«Весь трафик через секцию»', T2 = main ? '«Мимо Steer»' : '«Только для этих устройств»'; function setMode(r, mode) { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } var m = {}; Object.keys(cur).forEach(function(k) { m[k] = { mode: cur[k].mode, name: cur[k].name }; }); if (m[r.key] && m[r.key].mode === mode) delete m[r.key]; else { if (m[r.key] && m[r.key].mode !== mode) zm.toast('Устройство перенесено: ' + (mode === 'all' ? T1 : T2), 'info'); m[r.key] = { mode: mode, name: r.name || r.ip || '' }; } devPick = m; renderDevs(); } function grid(mode) { var g = E('div', { 'class': 'zm-nodes' }); rows.forEach(function(r) { var on = !!(cur[r.key] && cur[r.key].mode === mode), mine = cur[r.key] && cur[r.key].mode !== mode; var tags = [ r.ip, r.mac && r.mac !== r.ip ? r.mac : '' ].filter(Boolean).join(' · ') + (r.manual ? ' · добавлено вручную' : ''); if (mine) tags += ' · ' + (mode === 'all' ? (main ? 'мимо Steer' : 'только для этих') : 'весь трафик'); else if (!on && other[r.key]) tags += ' · ' + other[r.key]; g.appendChild(E('div', { 'class': 'zm-node' + (on ? ' zm-active' : ''), 'click': function() { setMode(r, mode); } }, [ E('div', { 'class': 'zm-node-name' }, [ String(r.name || r.ip || r.mac) ]), E('div', { 'class': 'zm-node-foot' }, [ E('span', {}, [ tags ]), E('span') ]) ])); }); if (!rows.length) g.appendChild(E('p', { 'class': 'zm-hint', 'style': 'grid-column: 1 / -1; margin: 0' }, devLoading ? 'Ищем устройства…' : 'Устройства не найдены — добавьте MAC или IP-адрес вручную.')); return g; } function cnt(mode, cls) { var n = Object.keys(cur).filter(function(k) { return cur[k].mode === mode; }).length; return n ? badge(cls, n + ' ' + plural(n, 'устройство', 'устройства', 'устройств')) : E([]); } var ex = secExit(secCur), sname = '«' + secName(secCur) + '»'; var via = ex === 'vpn' ? 'VPN' + (main && data.sub_label ? ' (' + data.sub_label + ')' : '') : ex === 'warp' ? warpName() : ex === 'iface' ? (secObj(secCur) || {}).iface : 'туннель'; var fall = data.onfail === 'drop' ? 'интернета у них не будет, пока он не вернётся' : 'пойдут напрямую'; if (hasSecs()) devCard.appendChild(E('p', { 'class': 'zm-hint' }, [ 'Секция ', E('b', {}, sname), ' · выход: ' + (ex === 'none' ? 'не подключён' : via) ])); devCard.appendChild(E('h3', {}, [ main ? 'Весь трафик устройства через Steer ' : 'Весь трафик устройства через секцию ', cnt('all', 'zm-ok') ])); devCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Весь трафик выбранных устройств идёт через ' + via + ', а не только выбранные сервисы. Локальная сеть — напрямую. Если туннель упал — ' + fall + '.' + (hasSecs() ? ' Устройство с таким выбором в нескольких секциях пойдёт через первую из них.' : ''))); devCard.appendChild(grid('all')); if (main) { devCard.appendChild(E('h3', { 'style': 'margin-top:20px' }, [ 'Мимо Steer ', cnt('byp', 'zm-warn') ])); devCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Эти устройства всегда ходят напрямую, даже к выбранным сервисам. Этот выбор важнее выбранных сервисов' + (hasSecs() ? ' и действует во всех секциях.' : '.'))); } else { devCard.appendChild(E('h3', { 'style': 'margin-top:20px' }, [ 'Только для этих устройств ', cnt('only', 'zm-ok') ])); devCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Сервисы секции ' + sname + ' идут через неё только с отмеченных устройств, остальные открывают их как обычно. Ничего не отмечено — сервисы секции идут через неё со всех устройств.')); } devCard.appendChild(grid(M2)); var inp = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'placeholder': 'aa:bb:cc:dd:ee:ff или 192.168.1.100', 'style': 'flex:1; min-width:200px' }); function add(mode) { var v = inp.value.trim().toLowerCase().replace(/-/g, ':'); if (!MAC_RE.test(v) && !IP4_RE.test(v)) { zm.toast('Введите MAC (aa:bb:cc:dd:ee:ff) или IP-адрес одного устройства (192.168.1.100)', 'warning'); return; } var mk = typeof known[v] === 'string' ? known[v] : v; var r = rows.filter(function(x) { return x.key === mk; })[0] || { key: mk, name: '', ip: IP4_RE.test(mk) ? mk : '', mac: MAC_RE.test(mk) ? mk : '', manual: true }; if (cur[mk] && cur[mk].mode === mode) { zm.toast('Это устройство уже в списке', 'info'); return; } inp.value = ''; setMode(r, mode); } inp.addEventListener('keydown', function(ev) { if (ev.key === 'Enter') { ev.preventDefault(); add('all'); } }); devCard.appendChild(E('div', { 'class': 'zm-actions', 'style': 'margin-top:14px' }, [ inp, E('button', { 'class': 'cbi-button', 'click': function() { add('all'); } }, 'Весь трафик'), E('button', { 'class': 'cbi-button', 'click': function() { add(M2); } }, main ? 'Мимо Steer' : 'Только для них'), E('button', { 'class': 'cbi-button', 'disabled': devLoading ? '' : null, 'click': loadDevs }, devLoading ? 'Ищем…' : 'Обновить список') ])); if (ex === 'none') devCard.appendChild(E('p', { 'class': 'zm-hint' }, main ? 'Туннель ещё не подключён — выбор сохранится и заработает, когда подключите WARP или VPN.' : 'У секции ещё нет подключения — выбор сохранится и заработает, когда настроите его на вкладке «Подключение».')); var bar = zm.saveBar({ saveLabel: data.installed && !data.stopped ? 'Сохранить и применить' : 'Сохранить', onCancel: function() { devPick = null; renderDevs(); }, busyHint: 'Ход работы — в окне вывода', async: true, onSave: function() { var lines = Object.keys(cur).map(function(k) { return cur[k].mode + '|' + k + '|' + String(cur[k].name || '').replace(/[|\r\n\t"\\]/g, ' ').slice(0, 48); }); return new Promise(function(done) { act('devs_set', lines.join('\n'), 'Применяем выбор устройств', function(ok) { if (ok) { devPick = null; renderDevs(); } done(ok); }); }); } }); bar.set(changed, false); devCard.appendChild(bar); } function failText() { return data.onfail === 'drop' ? 'сервисы остановлены' : 'сервисы идут напрямую'; } function outName(o) { if (!o) return ''; if (o === 'zm_warp') return warpName(); if (o === 'zm_vpn' || /^zmv_/.test(o)) return 'VPN' + (hasSecs() ? ' · ' + secName('main') : ''); var sm = /^(?:zm_vpn|zmv|zm_if)([2-9])(?:_|$)/.exec(o); if (sm && secObj('s' + sm[1])) { var so = secObj('s' + sm[1]); return (so.kind === 'iface' ? so.iface : 'VPN') + ' · ' + so.name; } if (o === 'zm_direct') return 'напрямую (устройство мимо Steer)'; var t = (data.tunnels || []).filter(function(x) { return x.if === o; })[0]; if (t) return data.warp_mode === 'own' || data.warp_mode === 'iface' ? warpName() : 'WARP ' + t.n; return o; } 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' ]; if (data.steer_running && data.warp_failed) return [ 'туннель не принят', '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 (hasSecs()) { if (!anyWork()) return badge('zm-off', 'сервисы не выбраны'); if (data.steer_running && data.failopen) return badge('zm-bad', 'туннель не принят — ' + failText()); return data.steer_running ? badge('zm-ok', 'работает') : badge('zm-bad', 'не работает'); } if (!n && !devAllCount()) 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 не принят — ' + failText()); 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', 'туннель не принят — ' + failText()); 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 ifAlive(f) { return f && f.up && f.hs_age !== '' && +f.hs_age < 180; } function secBadge(id) { var s = id === 'main' ? null : secObj(id), ex = secExit(id); if (busy || data.running) return badge('zm-warn', 'работаем'); if (data.stopped) return badge('zm-off', 'Steer выключен'); if (secOff(id)) return badge('zm-off', 'секция выключена'); if (ex === 'none') return badge('zm-warn', s && s.kind === 'vpn' ? 'нет подписки' : s && s.kind === 'iface' ? 'нет интерфейса' : s ? 'WARP не подключён' : 'нет подключения'); if (!secWork(id)) return badge('zm-off', 'ничего не выбрано'); if (!data.steer_running) return badge('zm-bad', 'не работает'); if (ex === 'vpn') { if (!s) return data.failopen ? badge('zm-bad', 'VPN не принят') : data.vpn_live === false && data.vpn_up ? badge('zm-bad', 'нет связи') : data.vpn_up ? badge('zm-ok', 'работает') : badge('zm-warn', 'подключаемся'); var v = (s.vpn && s.vpn.state) || {}; if (v.up) return badge('zm-ok', 'работает'); if (v.probe && v.probe.state === 'probing') return badge('zm-warn', 'ищем узел'); if (v.failed || (v.probe && v.probe.state)) return badge('zm-bad', 'узел не отвечает'); return badge('zm-warn', 'подключаемся'); } if (ex === 'iface') { var f = s.ifs || {}; return f.failed ? badge('zm-bad', 'туннель не принят') : ifAlive(f) ? badge('zm-ok', 'работает') : f.up ? badge('zm-warn', 'нет связи') : badge('zm-bad', 'не поднят'); } var ts = tunnelState(); return badge(ts[1], ts[0].split(' · ')[0]); } function secLine(id) { if (secOff(id)) { var l0 = secLine0(id); return [ l0[0], 'выключена — её сервисы идут напрямую' ]; } return secLine0(id); } function secLine0(id) { var s = id === 'main' ? null : secObj(id), ex = secExit(id), kind = s ? s.kind : ex; if (!s && ex === 'none') return [ 'Не подключено', 'выберите WARP или VPN' ]; if (kind === 'vpn') { if (!s) return [ 'VPN' + (data.sub_label ? ' · ' + data.sub_label : ''), 'подписка или свои серверы' ]; var vp = s.vpn || {}, lab = vp.label || '', node = (vp.state && vp.state.up && vp.state.node) || vp.pinned || ''; if (!vp.has) return [ 'VPN', 'подписка ещё не добавлена' ]; return [ 'VPN · ' + (node || lab || 'подписка'), (node && lab ? lab + ' · ' : '') + (vp.pinned ? 'узел выбран вручную' : 'узел выбирается сам') ]; } if (kind === 'iface') { var f = (s && s.ifs) || {}; return [ 'Интерфейс ' + (s.iface || '—'), (f.warp ? 'WARP' : 'свой сервер') + ' · ' + (f.up ? (f.hs_age !== '' ? 'связь ' + fmtAge(f.hs_age) : 'связи ещё не было') : 'не поднят') ]; } if (!data.warp_on) return [ 'WARP', s ? 'туннели WARP ещё не подключены' : 'выберите WARP или VPN' ]; var t = data.tunnels || [], colos = t.map(function(x) { return x.colo; }).filter(function(c, i, a) { return c && c !== '?' && a.indexOf(c) === i; }); return [ warpName() + (data.warp_colo ? ' · ' + data.warp_colo : ''), t.length > 1 ? 'общие туннели · ' + (colos.join(', ') || t.length + ' шт.') : 'общий туннель WARP' ]; } var CHIPS_SHOW = 6, chipsOpen = {}; function secChips(id) { var nm = svcNames(), names = secSvc(id).map(function(x) { return nm[x] || x; }), extra = []; var dv = secDevs(id), dAll = dv.filter(function(d) { return d.mode === 'all'; }).length, dOnly = dv.filter(function(d) { return d.mode === 'only'; }).length; if (dAll) extra.push('весь трафик: ' + dAll + ' ' + plural(dAll, 'устройство', 'устройства', 'устройств')); if (dOnly) extra.push('только для ' + dOnly + ' ' + plural(dOnly, 'устройства', 'устройств', 'устройств')); var box = E('div', { 'class': 'zm-fk-chips' }), cut = !chipsOpen[id] && names.length > CHIPS_SHOW ? CHIPS_SHOW - 1 : names.length; names.slice(0, cut).forEach(function(n) { box.appendChild(E('span', { 'class': 'zm-fk-chip', 'title': n }, n)); }); if (cut < names.length) box.appendChild(E('span', { 'class': 'zm-fk-chip zm-fk-chip-more', 'title': names.slice(cut).join(', '), 'click': function(ev) { ev.stopPropagation(); chipsOpen[id] = true; box.parentNode.replaceChild(secChips(id), box); } }, 'ещё ' + (names.length - cut))); extra.forEach(function(t) { box.appendChild(E('span', { 'class': 'zm-fk-chip zm-fk-chip-x', 'title': t }, t)); }); if (!names.length && !extra.length) box.appendChild(E('span', { 'class': 'zm-fk-chip zm-fk-chip-x' }, 'ничего не выбрано')); return box; } function secBox(id) { var l = secLine(id); return E('div', { 'class': 'zm-st-stat zm-fk-sec' + (id === 'main' ? ' zm-fk-sec-main' : '') + (id === secCur ? ' zm-fk-sec-cur' : '') + (secOff(id) ? ' zm-fk-sec-off' : ''), 'title': 'Открыть секцию «' + secName(id) + '»', 'click': function() { secTo(id, true); } }, [ E('span', { 'class': 'zm-fk-sec-head' }, [ E('span', { 'class': 'zm-st-stat-label', 'title': secName(id) }, [ secName(id) ]), secBadge(id) ]), E('span', { 'class': 'zm-st-stat-value', 'title': l[0] }, [ l[0] ]), E('span', { 'class': 'zm-st-stat-sub', 'title': l[1] }, [ l[1] ]), secChips(id) ]); } function seg(label, items) { return E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, label), E('div', { 'class': 'zm-seg' }, items.map(function(it) { return E('div', { 'class': 'zm-seg-item' + (it[0] ? ' zm-active' : ''), 'click': function() { if (busy || it[0]) return; it[2](); } }, it[1]); })) ]); } function renderSet() { setCard.innerHTML = ''; if (!data.installed || data.blocker) return; setCard.appendChild(E('h3', {}, 'Настройки Steer')); if (hasSecs()) setCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Общие для всех секций.')); var isDrop = data.onfail === 'drop'; setCard.appendChild(seg('Если туннель упал', [ [ !isDrop, 'Пустить напрямую', function() { act('onfail', 'direct', 'Без туннеля сервисы пойдут напрямую'); } ], [ isDrop, 'Остановить', function() { act('onfail', 'drop', 'Без туннеля сервисы будут остановлены'); } ] ])); setCard.appendChild(E('p', { 'class': 'zm-hint' }, isDrop ? 'Сервисы не открываются, пока туннель не заработает. Остальной интернет работает как обычно.' : 'Сервисы идут через провайдера, как без Steer, пока туннель не заработает.')); var dnsTun = data.dnsmode !== 'router'; setCard.appendChild(seg('DNS сервисов', [ [ dnsTun, 'Через туннель', function() { act('dnsmode', 'tun', 'DNS сервисов — через туннель'); } ], [ !dnsTun, 'Через роутер', function() { act('dnsmode', 'router', 'DNS сервисов — через роутер'); } ] ])); setCard.appendChild(E('p', { 'class': 'zm-hint' }, dnsTun ? 'Сервисы видят ту же страну, что и их трафик. Рекомендуется.' : 'Сервисы могут видеть Россию и работать не полностью.')); var ripOn = data.realip !== 'off'; setCard.appendChild(seg('Точно по IP', [ [ ripOn, 'Включено', function() { act('realip', 'on', 'Точно по IP включено'); } ], [ !ripOn, 'Выключено', function() { act('realip', 'off', 'Точно по IP выключено'); } ] ])); setCard.appendChild(E('p', { 'class': 'zm-hint' }, ripOn ? 'В туннель идёт всё, что ведёт на адреса сервиса, — так работают и приложения, которые запоминают адреса. Иногда за компанию уходят соседние сайты. Рекомендуется.' : 'В туннель идёт только то, что открыто по имени сайта. Некоторые приложения могут работать не полностью.')); setCard.appendChild(zm.swRow(!!data.lists_via, 'Скачивать списки через туннель', 'Если GitHub у провайдера не открывается. Не ответит туннель — скачаются напрямую.', 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'); renderSet(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); })); } function topBox() { var newer = data.latest && data.version && verLt(data.version, data.latest); var nd = exclData ? (parseInt(exclData.domains, 10) || 0) + (parseInt(exclData.subnets, 10) || 0) : 0, nb = devBypCount(); var ts = data.warp_on ? tunnelState() : null; function info(k, v) { return E('div', {}, [ E('span', {}, k), E('b', { 'title': v }, [ v ]) ]); } return E('div', { 'class': 'zm-st-stat zm-fk-top' }, [ E('span', { 'class': 'zm-fk-sec-head' }, [ E('span', { 'class': 'zm-st-stat-label' }, [ 'Общее · для всех секций' ]), E('span', { 'class': 'zm-fk-top-badges' }, [ statusBadge(), newer && !busy ? badge('zm-warn', 'есть версия ' + data.latest) : E([]) ]) ]), E('div', { 'class': 'zm-fk-info' }, [ info('WARP', ts ? warpName() + ' · ' + ts[0] : 'не подключён'), info('DNS сервисов', data.dnsmode === 'router' ? 'через роутер' : 'через туннель секции'), info('Если туннель упал', data.onfail === 'drop' ? 'сервисы остановятся' : 'сервисы пойдут напрямую'), info('Исключения', nd ? nd + ' ' + plural(nd, 'адрес', 'адреса', 'адресов') : 'нет'), info('Мимо Steer', nb ? nb + ' ' + plural(nb, 'устройство', 'устройства', 'устройств') : 'нет устройств'), data.doh ? info('Шифрованный DNS', DOHN[data.doh] || data.doh) : E([]), info('Ядро steer', (data.version || '—') + (!data.ext && data.version ? ' · нужно обновить' : '')) ]) ]); } 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 (подписка или свои серверы). Остальной интернет — как обычно.')); if (data.blocker) { mainCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show' }, [ String(BLOCKERS[data.blocker] || data.blocker) ])); if (data.installed && !busy) { mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'На роутере остались настройки Steer панели: туннели WARP, списки и выбор сервисов. «Удалить Steer панели» уберёт только неиспользуемое — ядро steer, а также туннели и списки из его нынешних правил останутся.')); mainCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { zm.dialog({ title: 'Удалить Steer панели?', danger: true, okText: 'Удалить Steer панели', blocks: [ { type: 'list', title: 'Что произойдёт', items: [ 'Туннели WARP, списки и выбор сервисов панели удалятся', 'Ядро steer и всё, на что ссылаются его нынешние правила, останется на месте' ] } ] }).then(function(v) { if (v) act('remove', '', 'Удаляем Steer панели'); }); } }, 'Удалить Steer панели') ])); } return; } var withSecs = hasSecs(); if (!withSecs) mainCard.appendChild(row('Состояние', statusBadge())); if (withSecs) { mainCard.appendChild(topBox()); var ids = secIds(), cols = ids.length === 4 ? 2 : Math.min(ids.length, 3); mainCard.appendChild(E('div', { 'class': 'zm-st-stats zm-fk-secs zm-fk-cols-' + cols }, ids.map(secBox))); } else 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', 'выберите VPN или WARP'))); var dAll = devAllCount(); mainCard.appendChild(row('Через туннель', E('span', {}, [ n ? n + ' ' + plural(n, 'сервис', 'сервиса', 'сервисов') : (dAll ? 'сервисы не выбраны' : 'ничего не выбрано'), dAll ? ' · весь трафик ' + dAll + ' ' + plural(dAll, 'устройства', 'устройств', 'устройств') : '' ].join('')))); if (data.doh) { 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 — около минуты. Потом во вкладке «Подключение» выберите, через что пускать сервисы: VPN или WARP.')); return; } var actions = [], offIds = withSecs ? secIds().filter(secOff) : []; 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() { if (withSecs) offDialog(); else act('stop', '', 'Выключаем Steer'); } }, withSecs ? 'Выключить…' : 'Выключить')); offIds.forEach(function(id) { actions.push(E('button', { 'class': 'cbi-button cbi-button-action', 'title': 'Сервисы секции снова пойдут через её подключение', 'click': function() { secOffSet(id, false); } }, 'Включить «' + secName(id) + '»')); }); } 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() { zm.dialog({ title: 'Удалить Steer?', danger: true, okText: 'Удалить Steer', blocks: [ { type: 'list', title: 'Что произойдёт', items: [ 'Steer удалится со всеми настройками: подписка, выбор сервисов, списки', 'Туннели WARP и всё, что для них ставилось, удалятся', 'Сервисы, которые шли через туннель, пойдут напрямую' ] } ] }).then(function(v) { if (v) act('remove', '', 'Удаляем Steer'); }); } }, 'Удалить')); mainCard.appendChild(E('div', { 'class': 'zm-actions' }, actions)); mainCard.appendChild(E('p', { 'class': 'zm-hint' }, withSecs ? '«Выключить…» — весь Steer или только одну секцию: её сервисы пойдут напрямую, остальные секции продолжат работать. Настройки и выбор сервисов сохранятся.' : '«Выключить» — всё пойдёт напрямую, настройки и выбор сервисов сохранятся.')); } var secOffMsg = ''; function secOffSet(id, off) { secOffMsg = off ? 'Секция «' + secName(id) + '» выключена — её сервисы идут напрямую' : 'Секция «' + secName(id) + '» снова работает'; secAct('sec_off', id + '|' + (off ? 1 : 0), secOffMsg); } function offDialog() { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } var ids = secIds(), on = ids.filter(function(id) { return !secOff(id); }); var opts = [ { id: 'all', label: 'Весь Steer', hint: 'Все секции выключатся — всё пойдёт напрямую. Настройки и выбор сервисов сохранятся.' } ]; ids.forEach(function(id) { var l = secLine0(id), off = secOff(id), last = !off && on.length === 1, n = secSvc(id).length; opts.push({ id: id, label: 'Только секцию «' + secName(id) + '»', hint: off ? 'Уже выключена' : last ? 'Это последняя работающая секция — чтобы выключить и её, выберите «Весь Steer»' : l[0] + (n ? ' · ' + n + ' ' + plural(n, 'сервис', 'сервиса', 'сервисов') : '') + ' — пойдут напрямую, остальные секции продолжат работать', disabled: off || last }); }); zm.dialog({ title: 'Что выключить?', okText: 'Выключить', blocks: [ { type: 'choice', id: 'what', value: 'all', title: 'Выберите, что выключить', options: opts }, { type: 'note', text: 'Сервис, выбранный ещё и в другой включённой секции, пойдёт через неё.' } ] }).then(function(v) { if (!v) return; if (v.what === 'all') act('stop', '', 'Выключаем Steer'); else secOffSet(v.what, true); }); } function currentPick() { var m = {}; secSvc(secCur).forEach(function(id) { m[id] = true; }); return m; } function svcElse() { var m = {}; if (!hasSecs()) return m; secIds().forEach(function(id) { if (id === secCur) return; secSvc(id).forEach(function(x) { if (!m[x]) m[x] = id; }); }); return m; } var CATEGORY_IDS = [ 'geoblock', 'block', 'news', 'anime', 'porn', 'hodca', 'russia_inside', 'russia_outside', 'ukraine_inside' ]; var openGroups = {}; var domOpen = null, domData = {}, domCards = {}, domPanelEl = null, DOM_SHOW = 500, DOM_PART = 4000; function domSvc(id) { var r = null; (data.services || []).forEach(function(x) { if (x.id === id) r = x; }); return r; } function domWord(n) { return plural(n, 'домен', 'домена', 'доменов'); } function domNum(n) { return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ' '); } function domIcon(s) { if (!s.dom) return null; var b = E('button', { 'type': 'button', 'class': 'zm-svc-dom' + (domOpen === s.id ? ' zm-active' : ''), 'title': 'Домены списка', 'aria-label': 'Домены списка «' + s.name + '»', 'click': function(ev) { ev.stopPropagation(); ev.preventDefault(); domToggle(s.id); } }, [ E('i'), E('i'), E('i') ]); b._zmDom = s.id; return b; } function domLoad(id) { stC('svc_dom', id + ':0:' + DOM_SHOW).then(function(r) { if (r.error) { domData[id] = { error: String(r.error) }; domPlace(); return; } var n = parseInt(r.count, 10) || 0; domData[id] = { count: n, ips: parseInt(r.ips, 10) || 0, src: String(r.src || ''), list: String(r.domains || '').split(/\s+/).filter(function(x) { return x; }) }; if (!n) { var sv = domSvc(id); if (sv) sv.dom = false; if (domOpen === id) domOpen = null; zm.toast('В списке «' + (sv ? sv.name : id) + '» только IP-подсети' + (domData[id].ips ? ' (' + domNum(domData[id].ips) + ')' : '') + ' — доменов нет', 'info'); renderLists(); return; } domPlace(); }).catch(function() { domData[id] = { error: 'Роутер не ответил' }; domPlace(); }); } function domToggle(id) { domOpen = domOpen === id ? null : id; if (domOpen && domData[domOpen] && domData[domOpen].error) delete domData[domOpen]; domPlace(); } function domDownload(id) { var d = domData[id], sv = domSvc(id); if (!d || d.dl) return; d.dl = true; domPlace(); var parts = []; function done(err) { d.dl = false; domPlace(); if (err) zm.toast(err, 'error'); } var ips = [], ipTotal = 0; function save() { var name = (sv && sv.name || id), head = '# ' + name + ': ' + domNum(d.count) + ' ' + domWord(d.count) + (ipTotal ? ', ' + domNum(ipTotal) + ' ' + plural(ipTotal, 'IP-подсеть', 'IP-подсети', 'IP-подсетей') : ''); var text = head + '\n\n# Домены (вместе с поддоменами)\n' + parts.join('\n') + '\n' + (ips.length ? '\n# IP-подсети\n' + ips.join('\n') + '\n' : ''); var url = URL.createObjectURL(new Blob([ text ], { type: 'text/plain;charset=utf-8' })); var a = E('a', { 'href': url, 'download': (ips.length ? 'list-' : 'domains-') + id + '.txt', 'style': 'display:none' }); document.body.appendChild(a); a.click(); setTimeout(function() { URL.revokeObjectURL(url); if (a.parentNode) a.parentNode.removeChild(a); }, 1500); done(); } function nextIp(off) { stC('svc_dom', id + ':' + off + ':' + DOM_PART + ':ip').then(function(r) { if (r.error) { done(String(r.error)); return; } var list = String(r.domains || '').split(/\s+/).filter(function(x) { return x; }); if (list.length) ips = ips.concat(list); ipTotal = parseInt(r.ips, 10) || 0; if (list.length && off + DOM_PART < ipTotal) { nextIp(off + DOM_PART); return; } ipTotal = ips.length; save(); }).catch(function() { done('Роутер не ответил'); }); } function next(off) { stC('svc_dom', id + ':' + off + ':' + DOM_PART).then(function(r) { if (r.error) { done(String(r.error)); return; } var list = String(r.domains || '').split(/\s+/).filter(function(x) { return x; }); if (list.length) parts.push(list.join('\n')); var total = parseInt(r.count, 10) || 0; if (list.length && off + DOM_PART < total) { next(off + DOM_PART); return; } if ((parseInt(r.ips, 10) || 0) > 0) nextIp(0); else save(); }).catch(function() { done('Роутер не ответил'); }); } next(0); } function domPanel(id) { var d = domData[id], sv = domSvc(id) || { name: id }, body = []; var sub = !d ? 'загружаем…' : d.error ? 'не удалось получить список' : domNum(d.count) + ' ' + domWord(d.count) + (d.ips ? ' · ' + domNum(d.ips) + ' ' + plural(d.ips, 'IP-подсеть', 'IP-подсети', 'IP-подсетей') : ''); var subTip = d && !d.error ? 'Домены действуют вместе с поддоменами' + (d.ips ? '; IP-подсети — адреса и диапазоны, которые идут тем же путём' : '') + (d.src ? '. Источник: ' + d.src : '') : ''; var btns = []; if (d && d.count) btns.push(E('button', { 'type': 'button', 'class': 'cbi-button', 'title': d.ips ? 'Домены и IP-подсети одним файлом .txt' : 'Все домены списка файлом .txt', 'disabled': d.dl ? '' : null, 'click': function() { domDownload(id); } }, d.dl ? 'Скачиваем…' : d.ips ? 'Скачать список доменов и IP' : 'Скачать список доменов')); btns.push(E('button', { 'type': 'button', 'class': 'cbi-button', 'click': function() { domToggle(id); } }, 'Свернуть')); if (!d) body.push(E('p', { 'class': 'zm-hint zm-dom-more' }, 'Читаем список с роутера…')); else if (d.error) body.push(E('div', { 'class': 'zm-refresh-banner zm-show', 'style': 'margin-top:12px' }, d.error)); else { body.push(E('div', { 'class': 'zm-dom-list' }, d.list.map(function(x) { return E('div', { 'class': 'zm-dom-item' }, x); }))); if (d.count > d.list.length) body.push(E('p', { 'class': 'zm-hint zm-dom-more' }, 'Показаны первые ' + domNum(d.list.length) + ' из ' + domNum(d.count) + '. Весь список' + (d.ips ? ' вместе с IP-подсетями' : '') + ' — по кнопке «' + (d.ips ? 'Скачать список доменов и IP' : 'Скачать список доменов') + '».')); } return E('div', { 'class': 'zm-dom-panel' }, [ E('div', { 'class': 'zm-dom-head' }, [ E('div', { 'class': 'zm-dom-title' }, [ E('b', {}, 'Домены · ' + sv.name), E('span', { 'class': 'zm-dom-sub', 'title': subTip || null }, sub) ]), E('div', { 'class': 'zm-dom-btns' }, btns) ]) ].concat(body)); } function domPlace() { if (domPanelEl && domPanelEl.parentNode) domPanelEl.parentNode.removeChild(domPanelEl); domPanelEl = null; Object.keys(domCards).forEach(function(k) { var b = domCards[k] && domCards[k].querySelector('.zm-svc-dom'); if (b) b.classList.toggle('zm-active', k === domOpen); }); var card = domOpen && domCards[domOpen]; if (!card || !card.parentNode || !document.body.contains(card)) return; if (domData[domOpen] === undefined) { domData[domOpen] = null; domLoad(domOpen); } domPanelEl = domPanel(domOpen); var n = card, top = card.offsetTop; while (n.nextElementSibling && n.nextElementSibling.offsetTop === top) n = n.nextElementSibling; card.parentNode.insertBefore(domPanelEl, n.nextSibling); } var domResizeT = null; window.addEventListener('resize', function() { if (!domOpen) return; clearTimeout(domResizeT); domResizeT = setTimeout(domPlace, 150); }); var findEl = null, findQ = '', findRes = null, findBusy = false, findT = null, findRetryT = null, findSeq = 0, findTries = 0, findAdding = false; function findSel() { return pick || currentPick(); } function findSvg(cls, svg) { var el = E('span', { 'class': cls, 'aria-hidden': 'true' }); el.innerHTML = svg; return el; } var FIND_LOUPE = ''; var FIND_CROSS = ''; function findKey(ev) { if (ev.key === 'Escape' && findEl) { ev.preventDefault(); findClose(); } } function findClose() { clearTimeout(findT); clearTimeout(findRetryT); document.removeEventListener('keydown', findKey); if (findEl && findEl.parentNode) findEl.parentNode.removeChild(findEl); findEl = null; } function findOpen() { if (findEl || data.blocker) return; var input = E('input', { 'type': 'text', 'class': 'zm-find-input', 'placeholder': 'Сайт или IP, например fast.com или 1.2.3.4', 'aria-label': 'Сайт или IP-адрес', 'autocomplete': 'off', 'autocapitalize': 'off', 'spellcheck': 'false', 'enterkeyhint': 'search' }); input.value = findQ; var body = E('div', { 'class': 'zm-find-body' }); var box = E('div', { 'class': 'zm-find zmw-modal', 'role': 'dialog', 'aria-modal': 'true', 'aria-label': 'Найти сайт или IP в списках' }, [ E('div', { 'class': 'zm-find-field' }, [ findSvg('zm-find-ico', FIND_LOUPE), input, E('button', { 'type': 'button', 'class': 'zm-find-x', 'title': 'Закрыть', 'aria-label': 'Закрыть', 'click': findClose }, [ findSvg('zm-find-x-ico', FIND_CROSS) ]) ]), body ]); findEl = E('div', { 'class': 'zm-find-wrap', 'click': function(ev) { if (ev.target === findEl) findClose(); } }, [ box ]); findEl._body = body; input.addEventListener('input', function() { findQ = input.value; clearTimeout(findT); findT = setTimeout(function() { findRun(false); }, 600); }); input.addEventListener('keydown', function(ev) { if (ev.key === 'Enter') { ev.preventDefault(); clearTimeout(findT); findRun(false); } }); document.body.appendChild(findEl); document.addEventListener('keydown', findKey); findDraw(); setTimeout(function() { try { input.focus(); input.select(); } catch (e) {} }, 0); } function findRun(auto) { var q = String(findQ || '').trim(); clearTimeout(findRetryT); if (!q) { findSeq++; findRes = null; findBusy = false; findDraw(); return; } if (!auto) findTries = 0; var seq = ++findSeq; if (!auto) { findBusy = true; findDraw(); } stC('svc_find', q).then(function(r) { if (seq !== findSeq) return; findBusy = false; findRes = r || {}; findDraw(); if (!findRes.error && (parseInt(findRes.missing, 10) || 0) > 0 && findTries < 8 && findEl) { findTries++; findRetryT = setTimeout(function() { if (findEl && seq === findSeq) findRun(true); }, 5000); } }).catch(function() { if (seq !== findSeq) return; findBusy = false; findRes = { error: 'Роутер не ответил' }; findDraw(); }); } function findToggle(id) { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } var sel = findSel(); pick = {}; for (var k in sel) if (sel[k] && k !== 'custom') pick[k] = true; zm.svcPickToggle(pick, id); renderLists(); findDraw(); } function findAddCustom(q) { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } if (customDirty) { zm.toast('Сначала сохраните изменения в «Своём списке»', 'warning'); return; } var csvc = domSvc('custom'), cOnNow = !!currentPick().custom; if (!csvc || findAdding) return; findAdding = true; findDraw(); function fail(msg) { findAdding = false; findDraw(); zm.toast(msg, 'error'); } function go(cur) { var lines = String(cur || '').split('\n').map(function(x) { return x.trim(); }).filter(function(x) { return x; }); if (lines.indexOf(q) < 0) lines.push(q); stC('list_set', 'custom|' + lines.join('\n')).then(function(res) { findAdding = false; if (res.error) { fail(String(res.error)); return; } if (findRes && findRes.hits) findRes.hits.push({ id: 'custom', name: csvc.name, group: '', on: cOnNow, by: 'dom', line: q, size: lines.length }); findDraw(); loadCustom(); if (!res.saved) { zm.toast(q + ' добавлен в «Свой список» — применяем правила', 'info'); lastAction = 'domlist'; data.running = true; data.phase = 'rules'; follow(); return; } if (!cOnNow && data.installed && !data.stopped) { var sel = findSel(), ids = []; for (var k in sel) if (sel[k] && k !== 'custom') ids.push(k); ids.push('custom'); pick = null; act('lists', ids.join(','), q + ' добавлен в «Свой список» — включаем его'); return; } zm.toast(q + ' добавлен в «Свой список»' + (cOnNow ? '' : ' — включите его на карточке «Свой список»'), 'info'); refresh(); }).catch(function() { fail('Роутер не ответил'); }); } if (customData && !customLoading) go(customData.content); else stC('list_get', 'custom').then(function(r) { if (r.error) { fail(String(r.error)); return; } go(r.content); }).catch(function() { fail('Роутер не ответил'); }); } function findDraw() { if (!findEl) return; var body = findEl._body, q = String(findQ || '').trim(), r = findRes, out = []; body.innerHTML = ''; if (!q) { out.push(E('div', { 'class': 'zm-find-empty' }, [ E('b', {}, 'Какой сайт или IP нужен?'), E('span', {}, 'Впишите сайт или IPv4-адрес — покажем, какой пункт включить, чтобы он шёл через туннель.') ])); } else if (findBusy || !r) { out.push(E('div', { 'class': 'zm-find-empty' }, [ E('span', { 'class': 'zm-find-spin', 'aria-hidden': 'true' }), E('span', {}, 'Ищем в списках…') ])); } else if (r.error) { out.push(E('div', { 'class': 'zm-find-empty zm-find-err' }, [ E('span', {}, String(r.error)) ])); } else { var sel = findSel(), hits = (r.hits || []).filter(function(h) { return h.id !== 'custom'; }); var domOnly = hits.filter(function(h) { return h.by === 'dom'; }); if (domOnly.length) hits = domOnly; var WIDE_IDS = { cloudflare: 1, c_itdoginfo_cloudflare: 1, cloudfront: 1, c_itdoginfo_cloudfront: 1, digitalocean: 1, hetzner: 1, ovh: 1, discord: 1 }; function findWide(h) { var m = /\/(\d+)$/.exec(String(h.line || '')); return h.by === 'ip' && (!!WIDE_IDS[h.id] || (m && +m[1] <= 16)); } var inCustom = (r.hits || []).some(function(h) { return h.id === 'custom'; }); hits.sort(function(a, b) { return (a.by === b.by ? 0 : a.by === 'dom' ? -1 : 1) || ((+a.size || 0) - (+b.size || 0)); }); var best = null; hits.forEach(function(h) { if (findWide(h)) return; if (!best || (best.by !== 'dom' && h.by === 'dom') || (best.by === h.by && (+h.size || 0) < (+best.size || 0))) best = h; }); var anyOn = hits.some(function(h) { return !!sel[h.id] || (!!sel.russia_inside && h.id !== 'russia_inside' && !h.group && zm.riCovers(h.id)); }); var isIp = !!r.ip && !r.host; if (hits.length) { out.push(E('div', { 'class': 'zm-find-title' }, isIp ? (anyOn ? 'Уже включено — адрес ' + r.ip + ' идёт через туннель' : 'Адрес ' + r.ip + ' есть в подсетях — включите любой пункт') : (anyOn ? 'Уже включено — сайт идёт через туннель' : 'Включите любой — сайт пойдёт через туннель'))); var listEl = E('div', { 'class': 'zm-find-list' }); hits.forEach(function(h) { var on = !!sel[h.id], inc = !on && !!sel.russia_inside && h.id !== 'russia_inside' && !h.group && zm.riCovers(h.id); var size = +h.size || 0; var why = (h.by === 'dom' ? 'в списке есть ' + h.line : findWide(h) ? (isIp ? 'общая подсеть ' + h.line : 'общий адрес ' + (r.ip || '') + ' (' + h.line + ')') + ' — через туннель пойдут и другие сайты' : (isIp ? 'в подсети ' + h.line : 'адрес ' + (r.ip || '') + ' в подсети ' + h.line)) + (h.group ? ' · ' + String(h.group).replace(/\s*\(.*\)\s*$/, '') : '') + (size > 20000 ? ' · большой список' : ''); var ic = zm.svcIco ? zm.svcIco(h.name, h.id) : null; listEl.appendChild(E('div', { 'class': 'zm-find-row' + (on || inc ? ' zm-on' : ''), 'role': 'button', 'tabindex': '0', 'click': function() { findToggle(h.id); }, 'keydown': function(ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); findToggle(h.id); } } }, [ E('div', { 'class': 'zm-svc-ico', 'style': ic ? 'background:' + ic.color : '' }, ic ? ic.ico : String(h.name || '?').slice(0, 2).toUpperCase()), E('div', { 'class': 'zm-find-text' }, [ E('div', { 'class': 'zm-find-name' }, [ String(h.name), best === h && hits.length > 1 && !anyOn ? E('span', { 'class': 'zm-find-best' }, 'лучший выбор') : '' ]), E('div', { 'class': 'zm-find-why' }, inc ? 'входит во «Всё сразу» — уже идёт через туннель' : why) ]), E('span', { 'class': 'zm-find-tag zm-find-tag-' + (h.by === 'dom' ? 'dom' : 'ip') }, h.by === 'dom' ? 'домен' : 'IP-адрес'), E('div', { 'class': 'zm-switch' + (on || inc ? ' zm-switch-on' : '') + (inc ? ' zm-switch-inc' : '') }, [ E('span') ]) ])); }); out.push(listEl); } else { out.push(E('div', { 'class': 'zm-find-empty' }, isIp ? [ E('b', {}, 'Адреса ' + r.ip + ' нет в подсетях списков'), E('span', {}, 'Пункты из одних доменов по IP не проверить — попробуйте найти сайт по имени или добавьте адрес в «Свой список».') ] : [ E('b', {}, 'Сайта нет ни в одном списке'), E('span', {}, r.host ? 'Добавьте ' + r.host + ' в «Свой список» — он пойдёт через туннель.' : 'Добавьте адрес в «Свой список» — он пойдёт через туннель.') ])); } if ((parseInt(r.missing, 10) || 0) > 0) out.push(E('div', { 'class': 'zm-find-note' }, [ E('span', { 'class': 'zm-find-spin', 'aria-hidden': 'true' }), 'Докачиваем ещё ' + r.missing + ' ' + plural(+r.missing, 'список', 'списка', 'списков') + ' — результаты обновятся сами' ])); if (domSvc('custom')) out.push(E('div', { 'class': 'zm-find-foot' }, [ E('button', { 'type': 'button', 'class': 'cbi-button' + (inCustom ? '' : ' cbi-button-action'), 'disabled': inCustom || findAdding || busy ? '' : null, 'click': function() { findAddCustom(r.host || r.q || q); } }, inCustom ? 'Уже в «Своём списке»' : findAdding ? 'Добавляем…' : 'Добавить в «Свой список»') ])); } out.forEach(function(n) { body.appendChild(n); }); } document.addEventListener('keydown', function(ev) { if (!(ev.ctrlKey || ev.metaKey) || ev.altKey || ev.shiftKey || String(ev.key).toLowerCase() !== 'k' && ev.code !== 'KeyK') return; if (!listCard || !document.body.contains(listCard) || listCard.style.display === 'none' || data.blocker) return; ev.preventDefault(); if (findEl) findClose(); else findOpen(); }); function catalogEdit() { var c = data.catalog || {}; zm.askText({ title: 'Свой каталог списков', intro: 'Ссылка на lists.json в формате splify2-lists — например, из форка каталога.', placeholder: 'https://…/lists.json', hint: 'Оставьте поле пустым, чтобы вернуть каталог по умолчанию.', value: c.custom ? c.url : '', okText: 'Подключить' }).then(function(u) { 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, 1200); zm.catalogWatch(c, refresh); }).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() { var turnOn = !!c.off; if (turnOn) zm.toast('Включаем каталог списков — скачиваем его…', 'info'); zm.steerAction('catalog_src', turnOn ? 'on' : 'off').then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast(turnOn ? 'Каталог списков включён' : 'Каталог списков выключен — остались только встроенные сервисы', 'info'); 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; var cur = currentPick(), sel = pick || cur, changed = false, other = svcElse(); var all = data.services || []; var list = all.filter(function(s) { return s.id !== 'custom'; }).map(function(s) { if (!/^c_itdoginfo_/.test(s.id) && !/^itdoginfo/i.test(s.group || '')) return s; return { id: s.id, name: s.name, group: '', cat: true, on: s.on, skip: s.skip, dom: s.dom }; }); list.forEach(function(s) { if (!!sel[s.id] !== !!cur[s.id]) changed = true; }); domCards = {}; function tile(s) { var oth = other[s.id] ? (sel[s.id] ? 'ещё и в «' : 'в секции «') + secName(other[s.id]) + '»' : ''; var el = zm.svcCard({ name: s.name, key: s.id, sub: oth, on: !!sel[s.id], inc: !!sel.russia_inside && s.id !== 'russia_inside' && !s.group && zm.riCovers(s.id), extra: domIcon(s), click: function() { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } pick = {}; for (var k in sel) if (sel[k] && k !== 'custom') pick[k] = true; zm.svcPickToggle(pick, s.id); renderLists(); } }); if (s.dom) domCards[s.id] = el; return el; } var remote = list.filter(function(s) { return !!s.group; }); var svcs = list.filter(function(s) { return !s.group && !s.cat && CATEGORY_IDS.indexOf(s.id) < 0; }); var cats = list.filter(function(s) { return !s.group && (s.cat || CATEGORY_IDS.indexOf(s.id) >= 0); }); listCard.appendChild(E('div', { 'class': 'zm-find-head' }, [ E('h4', { 'style': 'margin:0' }, hasSecs() ? 'Сервисы секции «' + secName(secCur) + '»' : 'Сервисы'), E('button', { 'type': 'button', 'class': 'zm-find-open', 'title': 'Найти сайт или IP-адрес в списках (Ctrl+K)', 'aria-label': 'Найти сайт или IP-адрес в списках', 'click': findOpen }, [ findSvg('zm-find-ico', FIND_LOUPE), 'Найти сайт или IP' ]) ])); if (hasSecs()) listCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin:6px 0 10px' }, 'Отмеченное здесь идёт через подключение этой секции. Сервис, выбранный в нескольких секциях, пойдёт через первую по порядку: ' + secIds().map(secName).join(' → ') + '.')); 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 && secIds().some(function(id) { return secWork(id) && secExit(id) !== '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', '', 'Скачиваем и применяем списки'); } }, 'Обновить списки сейчас') ])); var bar = zm.saveBar({ saveLabel: data.installed && !data.stopped ? 'Сохранить и применить' : 'Сохранить', onCancel: function() { pick = null; renderLists(); }, busyHint: 'Ход работы — в окне вывода', async: true, onSave: function() { var ids = all.filter(function(s) { return s.id === 'custom' ? cur.custom : sel[s.id]; }).map(function(s) { return s.id; }); return new Promise(function(done) { act('lists', ids.join(','), 'Применяем выбор — скачиваем списки', done); }); } }); bar.set(changed, false); listCard.appendChild(bar); if (domOpen && !domCards[domOpen]) domOpen = null; domPlace(); if (findEl) findDraw(); } function loadCustom() { var gen = secGen; customLoading = true; renderCustom(); stC('list_get', 'custom').then(function(res) { if (gen !== secGen) return; customLoading = false; if (res.error) { customData = null; renderCustom(); zm.toast(res.error, 'error'); return; } customData = res; customDraft = null; customDirty = false; renderCustom(); }).catch(function() { if (gen !== secGen) return; customLoading = false; renderCustom(); }); } function saveCustom(action, arg, okText) { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } stC(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 saveOn(v) { saveCustom('list_set', 'custom+on|' + v, hasSecs() ? 'Свой список сохранён и включён в секции «' + secName(secCur) + '»' : 'Свой список сохранён и включён'); } 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; var cOn = !!currentPick().custom, cEx = secExit(secCur), multi = hasSecs(); customCard.appendChild(E('h3', {}, multi ? 'Свой список секции «' + secName(secCur) + '»' : 'Свой список')); customCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Домены и IP-адреса из этого списка пойдут через Steer. Впишите их и нажмите «Применить и включить» — список сохранится и сразу заработает. Переключатель — чтобы выключить, не стирая.' + (multi ? ' У каждой секции свой список: здесь — список секции «' + secName(secCur) + '», он идёт через её подключение.' : ''))); if (customLoading && !customData) { customCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Загружаем список…')); return; } var n = customData ? (parseInt(customData.count, 10) || 0) : 0, where = multi ? ' секцию «' + secName(secCur) + '»' : ' Steer'; customCard.appendChild(zm.swRow(cOn, multi ? 'Свой список секции «' + secName(secCur) + '»' : 'Свой список', cOn ? 'Включён: домены и IP-адреса из списка идут через' + where + '. Выключите — они пойдут напрямую, сам список останется.' : 'Выключен: список не используется. Включите — домены и IP-адреса из него пойдут через' + where + '.', function() { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } if (!cOn && customDirty && customEditor && customEditor.value.trim()) { saveOn(customEditor.value); return; } if (!cOn && !n) { zm.toast('Сначала впишите в список домены или IP', 'warning'); if (customEditor) customEditor.focus(); return; } if (customDirty) { zm.toast('Сначала примените или отмените изменения в списке', 'warning'); return; } var ids = secSvc(secCur).filter(function(x) { return x !== 'custom'; }); if (!cOn) ids.push('custom'); act('lists', ids.join(','), cOn ? 'Выключаем свой список' : 'Включаем свой список'); }, busy)); customCard.appendChild(row('Состояние', !n ? badge('zm-off', 'список пуст') : !cOn ? badge('zm-warn', 'выключен') : !data.installed ? badge('zm-warn', 'пойдёт через Steer после установки') : data.stopped ? badge('zm-off', 'Steer выключен') : cEx === 'vpn' ? badge('zm-ok', 'идёт через VPN') : cEx === 'warp' ? badge('zm-ok', 'идёт через ' + warpName()) : cEx === 'iface' ? badge('zm-ok', 'идёт через ' + (secObj(secCur) || {}).iface) : badge('zm-warn', multi && !isMain() ? 'настройте подключение секции' : 'подключите 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 live = data.installed && !data.stopped; var cbar = zm.editorBar(customEditor, { saveLabel: cOn ? (live ? 'Применить' : 'Сохранить') : (live ? 'Применить и включить' : 'Сохранить и включить'), text: cOn ? 'Свой список изменён' : 'Свой список изменён — применить и включить?', onSave: function(v) { if (!v.trim()) { zm.toast('Добавьте хотя бы один домен или IP, либо нажмите «Очистить»', 'warning'); return false; } if (cOn) saveCustom('list_set', 'custom|' + v, 'Свой список сохранён'); else saveOn(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() { return zm.ask(multi ? 'Очистить свой список секции «' + secName(secCur) + '»?\n\nДомены из него пойдут напрямую. Списки других секций останутся.' : 'Очистить свой список?\n\nДомены из него пойдут напрямую.').then(function(ok) { if (!ok) 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 loadExcl() { exclLoading = true; renderExcl(); zm.steerAction('list_get', 'exclude').then(function(res) { exclLoading = false; if (res.error) { exclData = null; renderExcl(); zm.toast(res.error, 'error'); return; } exclData = res; exclDraft = null; exclDirty = false; renderExcl(); if (hasSecs() && !busy) renderMain(); }).catch(function() { exclLoading = false; renderExcl(); }); } function saveExcl(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; } exclDraft = null; exclDirty = false; if (res.saved) { zm.toast(okText + (res.count ? ' (' + res.count + ')' : ''), 'info'); loadExcl(); refresh(); return; } zm.toast(okText + ' — применяем правила', 'info'); lastAction = 'excl'; data.running = true; data.phase = 'rules'; follow(); loadExcl(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } function renderExcl() { if (exclEditor && exclDirty) exclDraft = exclEditor.value; if (exclEditor && !exclDirty) exclDraft = null; exclCard.innerHTML = ''; exclCard.style.display = data.blocker ? 'none' : ''; if (data.blocker) return; exclCard.appendChild(E('h3', {}, 'Исключения')); exclCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Эти домены и IP всегда идут напрямую, мимо WARP и VPN — даже если входят в выбранный сервис.' + (hasSecs() ? ' Исключения общие — действуют во всех секциях.' : ''))); if (exclLoading && !exclData) { exclCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Загружаем список…')); return; } var nd = exclData ? (parseInt(exclData.domains, 10) || 0) : 0, np = exclData ? (parseInt(exclData.subnets, 10) || 0) : 0; exclCard.appendChild(row('Состояние', !(nd + np) ? badge('zm-off', 'исключений нет') : !data.installed ? badge('zm-warn', 'заработают после установки Steer') : data.stopped ? badge('zm-off', 'Steer выключен') : badge('zm-ok', 'действуют — идут напрямую'))); if (nd + np) exclCard.appendChild(row('В списке', E('span', {}, 'доменов: ' + nd + ' · IP и подсетей: ' + np))); exclEditor = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false', 'style': 'min-height:160px', 'placeholder': 'music.youtube.com\nbank.example.ru\n203.0.113.0/24' }); exclEditor.value = (exclData && exclData.content) || ''; exclCard.appendChild(exclEditor); var ebar = zm.editorBar(exclEditor, { saveLabel: data.installed && !data.stopped ? 'Сохранить и применить' : 'Сохранить', onSave: function(v) { if (!v.trim()) { zm.toast('Добавьте хотя бы один домен или IP, либо нажмите «Очистить»', 'warning'); return false; } saveExcl('list_set', 'exclude|' + v, 'Исключения сохранены'); return false; } }); exclEditor.addEventListener('input', function() { exclDirty = ebar.dirty(); exclDraft = exclEditor.value; }); if (exclDirty && exclDraft !== null) { exclEditor.value = exclDraft; exclEditor.dispatchEvent(new Event('input')); } if (nd + np) exclCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { return zm.ask('Очистить исключения?\n\nЭти адреса снова пойдут так, как велят сервисы.').then(function(ok) { if (!ok) return; saveExcl('list_reset', 'exclude', 'Исключения очищены'); }); } }, 'Очистить') ])); exclCard.appendChild(E('p', { 'class': 'zm-hint' }, 'По одному на строку: домен (поддомены включаются сами), IP-адрес или подсеть. Сайты исключайте доменом: подсеть не остановит сайт, который Steer ловит по имени — устройство тогда ходит на подменный адрес, а не на настоящий.')); exclCard.appendChild(ebar); } 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(); renderSet(); } }).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', 'Трафик через подписку не идёт', 'проверьте задержку узлов на вкладке «VPN» или выберите другой узел' ]); else if (tn.length) tn.forEach(function(t) { var who = tn.length > 1 ? 'Туннель ' + t.n + ': ' : ''; var ownW = data.warp_mode === 'own', extW = data.warp_mode === 'iface'; 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 через туннель не проходит', extW ? 'нажмите «Подобрать точку входа» в «Подключении»' : ownW ? 'замените конфиг в «Подключении»' : 'нажмите «Сменить точки входа» в «Подключении»' ]); else items.push([ tn.length > 1 && diagRes.warp === 'on' ? 'warn' : 'fail', who + 'трафик через ' + warpName() + ' не идёт', extW ? 'нажмите «Перезапустить туннель» или «Подобрать точку входа» в «Подключении»' : ownW ? 'нажмите «Перезапустить туннель» или замените конфиг в «Подключении»' : 'нажмите «Перезапустить туннели» или «Сменить точки входа» в «Подключении»' ]); }); 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(chkRow(E('span', { 'class': 'zm-label' }, 'Итог'), 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(chkRow(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 ? 'Проверяем…' : 'Проверить'), E('button', { 'class': 'cbi-button' + (viewKind === 'conns' ? ' cbi-button-action' : ''), 'disabled': viewBusy ? '' : null, 'click': function() { loadView('conns'); } }, 'Соединения'), E('button', { 'class': 'cbi-button' + (viewKind === 'dnslog' ? ' cbi-button-action' : ''), 'disabled': viewBusy ? '' : null, 'click': function() { loadView('dnslog'); } }, 'Недавние сайты'), viewKind ? E('button', { 'class': 'cbi-button', 'click': function() { viewKind = null; viewRes = null; renderCheck(); } }, 'Скрыть') : '' ])); if (!viewKind) return; checkCard.appendChild(E('p', { 'class': 'zm-hint' }, viewKind === 'conns' ? 'Что прямо сейчас идёт через туннель: устройство, адрес назначения и выход. Список берётся из памяти роутера в момент нажатия, никуда не сохраняется и не отправляется.' : 'Какие сайты недавно запрашивались и попали ли они в туннель. Журнал живёт только в памяти Steer, на диск не пишется, никуда не отправляется и обнуляется при перезапуске; кто именно спрашивал, в нём нет.')); if (viewBusy || !viewRes) { checkCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Загружаем…')); return; } if (viewRes.error) { checkCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show' }, viewRes.error)); return; } var lines = [], total = 0, LIMIT = 200; if (viewKind === 'conns') { var agg = {}, keys = []; (viewRes.conns || []).forEach(function(c) { var k = [ c.src, c.dst, c.dport || '', c.proto, c.out || '' ].join('|'); if (!agg[k]) { agg[k] = { c: c, n: 0 }; keys.push(k); } agg[k].n++; }); total = keys.length; keys.slice(0, LIMIT).forEach(function(k) { var a = agg[k], c = a.c, v6 = String(c.dst).indexOf(':') >= 0; lines.push([ badge(c.out ? 'zm-ok' : 'zm-off', outName(c.out) || 'выход убран'), String(c.src) + ' → ' + (c.dport ? (v6 ? '[' + c.dst + ']' : c.dst) + ':' + c.dport : c.dst) + ' · ' + String(c.proto) + (a.n > 1 ? ' · ×' + a.n : '') ]); }); if (!total) checkCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Через туннель сейчас ничего не идёт. Откройте на устройстве выбранный сервис и нажмите «Соединения» ещё раз.')); else checkCard.appendChild(row('Через туннель', E('span', {}, (viewRes.total || (viewRes.conns || []).length) + ' ' + plural(viewRes.total || (viewRes.conns || []).length, 'соединение', 'соединения', 'соединений') + (viewRes.truncated ? ' · показана часть' : '')))); } else { var names = viewRes.names || []; total = names.length; names.slice(0, LIMIT).forEach(function(x) { var ago = parseInt(x.ago, 10); lines.push([ badge(x.out ? 'zm-ok' : 'zm-off', outName(x.out) || 'мимо туннеля'), String(x.name) + (x.channel && x.out ? ' · ' + x.channel : '') + ' · ' + (isNaN(ago) ? '' : ago < 60 ? ago + ' с назад' : fmtAge(ago)) + (x.count > 1 ? ' · ×' + x.count : '') ]); }); if (viewRes.running === false) checkCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Резолвер Steer не запущен — он нужен только сервисам, выбранным по доменам.')); else if (!total) checkCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Журнал пока пуст — откройте на устройстве любой сайт и нажмите «Недавние сайты» ещё раз.')); } if (lines.length) { var box = E('div', { 'style': 'max-height:340px; overflow:auto; margin-top:8px; padding:4px 12px; border:1px solid rgba(127,127,127,.2); border-radius:10px' }); lines.forEach(function(l) { box.appendChild(E('div', { 'class': 'zm-row', 'style': 'flex-wrap:nowrap; align-items:flex-start' }, [ l[0], E('span', { 'style': 'min-width:0; overflow-wrap:anywhere; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:12px' }, l[1]) ])); }); checkCard.appendChild(box); if (total > LIMIT) checkCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Показаны первые ' + LIMIT + ' из ' + total + '.')); } } function loadView(kind) { if (viewBusy) return; viewKind = kind; viewRes = null; viewBusy = true; renderCheck(); zm.steerAction(kind, 'x').then(function(res) { viewBusy = false; viewRes = res || { error: 'Роутер не ответил' }; renderCheck(); }).catch(function() { viewBusy = false; viewRes = { error: 'Роутер не ответил' }; renderCheck(); }); } function mainKind() { return connPick || (data.exit === 'vpn' || (data.exit === 'none' && data.has_sub) ? 'vpn' : 'warp'); } function warpView() { if (!data.installed || data.blocker) return false; if (isMain()) return mainKind() === 'warp'; var s = secObj(secCur); return !!s && s.kind === 'warp' && !ifChoose; } function warpIdle() { return !!data.installed && !data.blocker && !!data.warp_on && !warpUsers().length; } function longAsk(title, intro, items, ok) { return zm.dialog({ title: title, okText: ok, intro: intro, blocks: [ { type: 'list', title: 'Что сделает роутер', items: items } ] }); } function warpAutoAsk(sw, ext) { var items = sw ? [ 'Включит прежние автоматические туннели, а если их ещё не было — получит ключи Cloudflare и подберёт 3 туннеля', ext ? 'Интерфейс ' + ext + ' останется на вкладке AmneziaWG как был' : 'Свой туннель выключится, но сохранится — вернуться к нему можно в один клик' ] : [ 'Поставит AmneziaWG, если его ещё нет', 'Получит бесплатные ключи Cloudflare WARP', 'Проверит точки входа и оставит 3 рабочих туннеля в разных странах — российские узлы не берёт', 'Пустит ' + (hasSecs() ? 'сервисы секций с WARP' : 'выбранные сервисы') + ' через WARP' ]; return longAsk(sw ? 'Перейти на автоматический WARP?' : 'Подключить WARP автоматически?', 'Займёт 5–10 минут. Интернет всё это время работает как обычно, страницу можно закрыть — роутер доделает сам.', items, sw ? 'Перейти' : 'Начать подбор'); } var WAYS = [ { id: 'auto', label: 'Автоматически', time: '5–10 минут', hint: 'Роутер сам получит ключи Cloudflare и подберёт 3 туннеля в разных странах. Рекомендуется.' }, { id: 'own', label: 'Свой конфиг', time: 'секунды', hint: 'Вставите готовый WARP.conf или конфиг AmneziaWG — ключи и точка входа берутся из него.' }, { id: 'iface', label: 'Интерфейс AmneziaWG', time: 'секунды', hint: 'Готовый интерфейс с вкладки AmneziaWG — там он чинится автопочинкой.' } ]; function renderWarp() { warpCard.innerHTML = ''; var show = warpView() || warpIdle(); warpCard.style.display = show ? '' : 'none'; if (!show) return; var tl = data.tunnels || []; var own = data.warp_on && data.warp_mode === 'own'; var ext = data.warp_mode === 'iface' && data.warp_iface ? data.warp_iface : ''; var extOn = ext && data.warp_on; var cur = ext ? 'iface' : data.warp_mode === 'own' ? 'own' : 'auto'; var choosing = !data.warp_on || wayOpen; warpCard.appendChild(E('h3', {}, choosing ? (data.warp_on ? 'Сменить способ WARP' : 'Как подключить WARP') : extOn ? 'Интерфейс ' + ext : own ? 'Свой WARP' : tl.length > 1 ? 'Туннели WARP' : 'Туннель WARP')); if (hasSecs()) { var wu = warpUsers(); warpCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Туннели WARP общие' + (wu.length > 1 ? ' для секций ' + wu.map(function(id) { return '«' + secName(id) + '»'; }).join(', ') + ' — изменения здесь действуют на все.' : wu.length ? ' — через них идёт секция «' + secName(wu[0]) + '», а могут и другие.' : ' — сейчас через них ни одна секция не идёт.'))); } if (warpIdle() && !warpView()) warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Сейчас через WARP ничего не идёт — туннели подключены и ждут. Заработают, как только ' + (hasSecs() ? 'какая-нибудь секция выберет' : 'вы выберете') + ' WARP в «Подключении».')); function extAlive(f) { return f && f.up && f.hs_age !== '' && +f.hs_age < 180; } function extPicker(onCancel) { var cancelFn = onCancel || null; var info = awgData; if (!info) { warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Загружаем интерфейсы AmneziaWG…')); awgLoad(); return; } var list = (info.ifaces || []).filter(function(f) { return f.owner !== 'steer'; }); var awgLink = E('a', { 'class': 'cbi-button', 'href': zm.pageHref('awg') }, 'Открыть AmneziaWG'); warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Сервисы Steer пойдут в интерфейс с вкладки AmneziaWG — WARP или ваш сервер. Интерфейс остаётся там: там же он чинится автопочинкой, меняется и удаляется. Steer его не трогает и при удалении оставит как есть.')); if (!info.installed || !list.length) { warpCard.appendChild(E('div', { 'class': 'zm-st-ext-empty' }, [ E('div', { 'class': 'zm-st-ext-empty-t' }, !info.installed ? 'AmneziaWG не установлен' : 'Интерфейсов AmneziaWG пока нет'), E('div', { 'class': 'zm-hint' }, 'Создайте интерфейс на вкладке AmneziaWG: из WARP.conf или своего .conf — и возвращайтесь сюда.'), E('div', { 'class': 'zm-actions' }, [ awgLink ].concat(cancelFn ? [ E('button', { 'class': 'cbi-button', 'click': cancelFn }, 'Отмена') ] : [])) ])); return; } if (!extPick || !list.some(function(f) { return f.name === extPick; })) { var best = list.filter(function(f) { return f.name === ext; })[0] || list.filter(extAlive)[0] || list[0]; extPick = best.name; } warpCard.appendChild(E('div', { 'class': 'zm-afix-list zm-st-ext-list', 'role': 'radiogroup' }, list.map(function(f) { var on = f.name === extPick, live = extAlive(f), sub = []; sub.push(f.warp ? 'WARP' : 'свой сервер'); if (f.endpoint) sub.push(zm.secret(f.endpoint)); if (f.forkozz) sub.push('используется в ' + (info.fk_name || 'Forkozz')); var st = []; st.push(!f.enabled ? badge('zm-off', 'выключен') : live ? badge('zm-ok', 'работает') : f.up ? badge('zm-warn', 'нет связи') : badge('zm-bad', 'не поднят')); if (f.name === ext && extOn) st.push(E('span', { 'class': 'zm-afix-note' }, 'сейчас в Steer')); else if (f.route_all) st.push(E('span', { 'class': 'zm-afix-note' }, 'весь трафик роутера')); return E('div', { 'class': 'zm-swrow zm-afix-row zm-st-ext-row' + (on ? ' zm-st-ext-on' : ''), 'role': 'radio', 'aria-checked': on ? 'true' : 'false', 'tabindex': '0', 'click': function() { if (extPick === f.name) return; extPick = f.name; renderWarp(); }, 'keydown': function(ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); extPick = f.name; renderWarp(); } } }, [ E('span', { 'class': 'zm-st-radio' }), E('div', { 'class': 'zm-swrow-text' }, [ E('div', { 'class': 'zm-swrow-label' }, f.name), E('div', { 'class': 'zm-hint' }, sub.reduce(function(a, x, k) { if (k) a.push(' · '); a.push(x); return a; }, [])) ]), E('div', { 'class': 'zm-afix-st' }, st) ]); }))); var pf = list.filter(function(f) { return f.name === extPick; })[0] || {}; var same = extOn && extPick === ext; var btns = [ E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': (busy || same) ? '' : null, 'click': function() { var go = function() { warpExitAfter = isMain() && data.exit === 'vpn'; act('warp_iface', pf.name, 'Подключаем Steer к ' + pf.name); }, wayPrompt = true; if (pf.route_all) return zm.ask('У ' + pf.name + ' включено «Весь трафик» — через него уже идёт весь интернет роутера, а не только сервисы Steer.\n\nВсё равно подключить? Выключить можно на вкладке AmneziaWG.').then(function(ok) { if (ok) go(); }); if (data.warp_on && !same && wayPrompt) return zm.ask('Пустить сервисы Steer через ' + pf.name + '?\n\n' + (extOn ? 'Интерфейс ' + ext + ' останется на вкладке AmneziaWG как был.' : own ? 'Свой туннель выключится, но сохранится — вернуться к нему можно в один клик.' : 'Автоматические туннели WARP выключатся, но сохранятся — вернуться к ним можно в один клик.')).then(function(ok) { if (ok) go(); }); go(); } }, same ? 'Сейчас через ' + ext : extOn ? 'Переключить на ' + (pf.name || '') : 'Подключить ' + (pf.name || '')) ]; if (cancelFn) btns.push(E('button', { 'class': 'cbi-button', 'click': cancelFn }, 'Отмена')); btns.push(awgLink); warpCard.appendChild(E('div', { 'class': 'zm-actions' }, btns)); } 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; } warpExitAfter = isMain() && data.exit === 'vpn' && !data.warp_on; act('warp_own', v, toastText); } }, btnText) ]; if (wayOpen || ownOpen) btns.push(E('button', { 'class': 'cbi-button', 'click': function() { warpPick = null; ownOpen = false; wayOpen = false; renderWarp(); renderWfix(); } }, 'Отмена')); warpCard.appendChild(E('div', { 'class': 'zm-actions' }, btns)); warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Подойдёт конфиг WireGuard или AmneziaWG: ключи, маскировка и точка входа берутся из него как есть.' + (data.warp_own_saved ? ' В поле — ваш сохранённый конфиг: поправьте его или вставьте новый. Он хранится отдельно и переживёт даже переустановку Steer.' : ''))); } if (choosing) { var sel = warpPick || (data.warp_on ? cur : 'auto'); var closeWays = data.warp_on ? function() { wayOpen = false; warpPick = null; extPick = null; ownOpen = false; renderWarp(); renderWfix(); } : null; warpCard.appendChild(E('div', { 'class': 'zm-afix-list zm-st-ext-list', 'role': 'radiogroup' }, WAYS.map(function(w) { var on = w.id === sel, now = data.warp_on && w.id === cur; function choose() { if (busy || sel === w.id) return; warpPick = w.id; if (w.id === 'iface') { extPick = null; awgLoad(true); } renderWarp(); } return E('div', { 'class': 'zm-swrow zm-afix-row zm-st-ext-row' + (on ? ' zm-st-ext-on' : ''), 'role': 'radio', 'aria-checked': on ? 'true' : 'false', 'tabindex': '0', 'click': choose, 'keydown': function(ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); choose(); } } }, [ E('span', { 'class': 'zm-st-radio' }), E('div', { 'class': 'zm-swrow-text' }, [ E('div', { 'class': 'zm-swrow-label' }, w.label), E('div', { 'class': 'zm-hint' }, w.hint) ]), E('div', { 'class': 'zm-afix-st' }, [ now ? badge('zm-ok', 'сейчас') : E('span', { 'class': 'zm-afix-note' }, w.time) ]) ]); }))); if (sel === 'auto') { var same = data.warp_on && cur === 'auto'; var ab = [ E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': busy || same ? '' : null, 'click': function() { warpAutoAsk(data.warp_on, extOn ? ext : '').then(function(v) { if (!v) return; warpExitAfter = isMain() && data.exit === 'vpn' && !data.warp_on; if (data.warp_on) act('warp_mode', 'auto', 'Переходим на автоматический WARP — это займёт несколько минут'); else act('warp_setup', '', 'Подключаем WARP — это займёт 5–10 минут'); }); } }, same ? 'Сейчас так' : data.warp_on ? 'Перейти на автоматический' : 'Подключить WARP') ]; if (closeWays) ab.push(E('button', { 'class': 'cbi-button', 'click': closeWays }, 'Отмена')); warpCard.appendChild(E('div', { 'class': 'zm-actions' }, ab)); if (!same) warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Перед подбором покажем, что и сколько будет делаться, — без подтверждения ничего не начнётся.')); } else if (sel === 'own') { if (!(data.warp_on && cur === 'own')) warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Один туннель по вашему конфигу. Роутер не будет получать ключи и искать точки входа сам.' + (data.warp_on ? ' Нынешние туннели выключатся, но сохранятся — вернуться к ним можно в один клик.' : ''))); ownEditor(data.warp_on && cur === 'own' ? 'Сохранить и подключить' : data.warp_on ? 'Перейти на свой конфиг' : 'Подключить', data.warp_on && cur === 'own' ? 'Меняем конфиг WARP' : 'Подключаем свой WARP'); } else { if (ext && !data.warp_on) warpCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show' }, 'Интерфейса ' + ext + ' больше нет на вкладке AmneziaWG — сервисам Steer некуда идти. Выберите другой интерфейс или способ.')); extPicker(closeWays); } return; } warpCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Способ'), E('span', { 'class': 'zm-st-line' }, [ E('b', {}, (WAYS.filter(function(w) { return w.id === cur; })[0] || WAYS[0]).label), extOn ? E('span', {}, ext) : own ? E('span', {}, 'один туннель') : E('span', {}, tl.length + ' ' + plural(tl.length, 'туннель', 'туннеля', 'туннелей')) ]), E('button', { 'class': 'cbi-button', 'style': 'margin-left:auto', 'disabled': busy ? '' : null, 'click': function() { wayOpen = true; warpPick = null; ownOpen = false; renderWarp(); renderWfix(); } }, 'Сменить способ') ])); if (extOn) { awgLoad(); var xf = ((awgData && awgData.ifaces) || []).filter(function(f) { return f.name === ext; })[0] || null; var xt = tl[0] || {}, xlive = tunnelLive(xt), xi = []; var xep = xt.host ? zm.secret(xt.host + ':' + xt.port) : null; if (xt.colo && xt.colo !== '?') xi.push('сервер ' + xt.colo + (xt.city ? ' (' + xt.city + ')' : '')); if (xt.seen) xi.push('сайты видят: ' + country(xt.seen)); if (xt.up) xi.push('связь ' + fmtAge(xt.hs_age)); if (xt.up) xi.push('↓ ' + zm.fmtSize(+xt.rx || 0) + ' / ↑ ' + zm.fmtSize(+xt.tx || 0)); warpCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Туннель'), badge(xlive ? 'zm-ok' : xt.up ? 'zm-warn' : 'zm-bad', xlive ? 'работает' : xt.up ? 'нет связи' : 'не поднят'), xt.ru ? badge('zm-bad', 'российский узел') : '', xf ? badge('zm-off', xf.warp ? 'WARP' : 'свой сервер') : '' ])); if (xep || xi.length) warpCard.appendChild(E('div', { 'class': 'zm-hint zm-st-ext-line' }, xep ? [ xep, xi.length ? ' · ' + xi.join(' · ') : '' ] : xi.join(' · '))); var fx = (awgData && awgData.fix) || null, fxBadge; if (!xf || !fx) fxBadge = badge('zm-off', '…'); else if (!xf.warp) fxBadge = badge('zm-off', 'только для WARP'); else if (!fx.mode || fx.mode === 'off') fxBadge = badge('zm-off', 'выключена'); else if ((fx.skip || []).indexOf(ext) >= 0) fxBadge = badge('zm-warn', 'включена, но ' + ext + ' не проверяется'); else fxBadge = badge('zm-ok', 'включена · ' + ({ '30': '30 минут', '60': '1 час', '180': '3 часа' }[fx.mode] || fx.mode + ' мин')); warpCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Автопочинка'), fxBadge, E('a', { 'class': 'zm-st-ext-link', 'href': zm.pageHref('awg') }, xf && xf.warp && (!fx || !fx.mode || fx.mode === 'off') ? 'включить на вкладке AmneziaWG' : 'настроить на вкладке AmneziaWG') ])); var fxOn = xf && xf.warp && fx && fx.mode && fx.mode !== 'off' && (fx.skip || []).indexOf(ext) < 0; if (xt.ru) warpCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show' }, ext + ' выводит трафик через российский узел Cloudflare — заблокированное через него не откроется. ' + (fxOn ? 'Автопочинка уведёт его на зарубежный узел сама, или нажмите «Подобрать точку входа» сейчас.' : xf && !xf.warp ? 'Смените точку входа на вкладке AmneziaWG.' : 'Нажмите «Подобрать точку входа» или включите автопочинку — она уводит туннели с российских узлов сама.'))); var xb = [ E('button', { 'class': 'cbi-button', 'disabled': busy ? '' : null, 'click': function() { act('warp_restart', '', 'Перезапускаем ' + ext); } }, 'Перезапустить туннель') ]; if (!xf || xf.warp) xb.push(E('button', { 'class': 'cbi-button', 'disabled': busy ? '' : null, 'click': function() { longAsk('Подобрать точку входа для ' + ext + '?', 'Займёт до пары минут. Пока идёт подбор, ' + ext + ' может ненадолго пропадать.', [ 'Проверит точки входа Cloudflare настоящим туннелем', 'Оставит зарубежную точку, через которую трафик реально идёт', 'Остальные настройки ' + ext + ' не тронет' ], 'Подобрать').then(function(v) { if (v) act('ext_pick', '', 'Подбираем точку входа для ' + ext + ' — это до пары минут'); }); } }, 'Подобрать точку входа')); xb.push(E('button', { 'class': 'cbi-button', 'disabled': busy ? '' : null, 'click': function() { wayOpen = true; warpPick = 'iface'; extPick = ext; awgLoad(true); renderWarp(); renderWfix(); } }, 'Сменить интерфейс')); warpCard.appendChild(E('div', { 'class': 'zm-actions' }, xb)); warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Интерфейс живёт на вкладке AmneziaWG: там он чинится, меняется и удаляется. Steer только направляет в него выбранные сервисы.')); return; } if (own) { 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 ? 'нет связи' : 'не поднят'), t0.ru ? badge('zm-bad', 'российский узел') : '', E('span', {}, ep0 ? [ ep0, info0.length ? ' · ' + info0.join(' · ') : '' ] : info0.join(' · ')) ])); if (t0.ru) warpCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show' }, 'Этот конфиг выводит трафик через российский узел Cloudflare — заблокированное через него не откроется. Замените конфиг или точку входа в нём.')); 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; } var byOrder = data.warp_pick === 'order'; tl = tl.slice().sort(function(x, y) { return ((x.pos || 99) - (y.pos || 99)) || (x.n - y.n); }); var activeT = data.warp_active && data.exit !== 'vpn' ? tl.filter(function(t) { return t.if === data.warp_active; })[0] : null; if (tl.length > 1) { warpCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Основной туннель'), E('div', { 'class': 'zm-seg' }, [ E('div', { 'class': 'zm-seg-item' + (!byOrder ? ' zm-active' : ''), 'click': function() { if (busy || !byOrder) return; act('warp_pick', 'latency', 'Основным будет самый быстрый туннель'); } }, 'Самый быстрый'), E('div', { 'class': 'zm-seg-item' + (byOrder ? ' zm-active' : ''), 'click': function() { if (busy || byOrder) return; act('warp_pick', 'order:' + tl.map(function(t) { return t.if; }).join(','), 'Основным будет первый живой туннель по порядку'); } }, 'По порядку') ]) ])); warpCard.appendChild(E('p', { 'class': 'zm-hint' }, (byOrder ? 'Трафик идёт через первый живой туннель в списке; порядок меняется стрелками. Упал верхний — Steer возьмёт следующий, а когда верхний оживёт — вернётся на него.' : 'Трафик идёт через самый быстрый живой туннель; упал один — Steer сам переключится на другой.') + (activeT ? ' Сейчас это WARP ' + activeT.n + '.' : '') + ' Туннели, попавшие в российский узел Cloudflare (Домодедово и другие), в работу не берутся: заблокированное через них не открывается.')); if (!byOrder && data.version && verLt(data.version, '2.0.3')) warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Ядро Steer ' + data.version + ' скорость туннелей не измеряет и берёт первый живой. Чтобы выбор шёл по скорости, нажмите «Обновить движок» на карточке Steer.')); } if (tl.length && tl.every(function(t) { return t.ru; })) warpCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show' }, 'Все туннели попали в российский узел Cloudflare — заблокированное через них не откроется. Нажмите «Сменить точки входа».')); function moveTunnel(i, d) { var o = tl.map(function(t) { return t.if; }), j = i + d; if (j < 0 || j >= o.length) return; var x = o[i]; o[i] = o[j]; o[j] = x; act('warp_pick', 'order:' + o.join(','), 'Меняем порядок туннелей'); } function tunnelBad(t) { return !tunnelLive(t) || !!t.ru; } var deadN = tl.filter(tunnelBad).length; var partial = deadN > 0 && deadN < tl.length; if (tl.length) { tl.forEach(function(t) { var live = tunnelLive(t); var fix = partial && tunnelBad(t) ? E('span', { 'style': 'display:inline-flex; gap:6px; margin-left:auto' }, [ E('button', { 'class': 'cbi-button cbi-button-action', 'disabled': busy ? '' : null, 'click': function() { longAsk('Переделать WARP ' + t.n + '?', 'Займёт 1–3 минуты. Остальные туннели работают как обычно.', [ 'Подберёт для WARP ' + t.n + ' новую зарубежную точку входа', 'Проверит, что трафик через неё реально идёт' ], 'Переделать').then(function(v) { if (v) act('warp_fix', String(t.n), 'Переделываем WARP ' + t.n); }); } }, 'Переделать'), E('button', { 'class': 'cbi-button', 'disabled': busy ? '' : null, 'click': function() { return zm.ask('Получить новые ключи для WARP ' + t.n + '?\n\nОстальные туннели не тронутся.').then(function(ok) { if (!ok) 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 = t.ru ? badge('zm-bad', 'российский узел — не используется') : data.warp_active && data.exit !== 'vpn' && tl.length > 1 ? (data.warp_active === t.if ? badge('zm-ok', 'трафик идёт здесь') : live ? badge('zm-off', 'запасной') : '') : ''; var idx = tl.indexOf(t); var move = byOrder && tl.length > 1 ? E('span', { 'style': 'display:inline-flex; gap:4px' }, [ E('button', { 'class': 'cbi-button', 'title': 'Выше', 'disabled': busy || idx === 0 ? '' : null, 'style': 'min-width:34px; padding:4px 8px', 'click': function() { moveTunnel(idx, -1); } }, '↑'), E('button', { 'class': 'cbi-button', 'title': 'Ниже', 'disabled': busy || idx === tl.length - 1 ? '' : null, 'style': 'min-width:34px; padding:4px 8px', 'click': function() { moveTunnel(idx, 1); } }, '↓') ]) : ''; 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), move, st, cur, fix, info.length ? E('span', { 'style': 'flex:1 1 100%; order:1; min-width:0; overflow-wrap:anywhere' }, info.join(' · ')) : '' ])); }); } 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() { longAsk('Сменить точки входа WARP?', 'Займёт 5–10 минут. Туннели в это время могут переподключаться, остальной интернет работает как обычно.', [ 'Заново проверит точки входа Cloudflare', 'Оставит рабочие и быстрые, российские узлы не берёт', 'Ключи останутся прежними' ], 'Сменить').then(function(v) { if (v) act('warp_endpoint', '', 'Ищем точки входа — это 5–10 минут'); }); } }, 'Сменить точки входа'), E('button', { 'class': 'cbi-button', 'click': function() { return zm.ask('Пересоздать WARP?\n\nБудут получены новые ключи, туннели переподключатся. Помогает, если Cloudflare перестал пускать старые ключи.').then(function(ok) { if (!ok) return; act('warp_recreate', '', 'Пересоздаём WARP'); }); } }, 'Новые ключи') ])); warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Не работает — «Перезапустить туннели». Медленно — «Сменить точки входа». Совсем не помогает — «Новые ключи».')); } function renderAuto() { autoCard.innerHTML = ''; var aOn = data.installed && !data.blocker && (data.warp_on || data.has_sub || secList().some(function(s) { return s.vpn && s.vpn.has; })); autoCard.style.display = aOn ? '' : 'none'; if (!aOn) return; autoCard.appendChild(E('h3', {}, 'Автоперезапуск')); autoCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Туннели и Steer перезапускаются по расписанию — помогает, если обход со временем «подвисает».')); var cur = data.autorestart || '', cp = cur.split(':'); var cn = parseInt(cp[1], 10); var am = 'off'; if (isNaN(cn)) am = 'off'; else if (cp[0] === 'every') am = (cn === 2 || cn === 6 || cn === 12) ? 'every' + cn : 'custom'; else if (cp[0] === 'everym') am = 'custom'; else if (cp[0] === 'daily') am = 'daily'; var curHour = am === 'daily' ? cn : 4, curMin = am === 'daily' ? parseInt(cp[2] || '0', 10) : 0; if (isNaN(curHour) || curHour < 0 || curHour > 23) curHour = 4; if (isNaN(curMin) || curMin < 0 || curMin > 59) curMin = 0; var p2 = function(n) { return (n < 10 ? '0' : '') + n; }; var curTime = p2(curHour) + ':' + p2(curMin); var isMin = am === 'custom' && cp[0] === 'everym'; var timeInp = E('input', { 'type': 'time', 'lang': 'ru', 'class': 'cbi-input-text zm-ab-time', 'value': curTime }); var dailyRow = E('div', { 'class': 'zm-actions', 'style': am === 'daily' ? '' : 'display:none' }, [ E('span', { 'class': 'zm-label' }, 'Время перезапуска'), timeInp, E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { var m = /^(\d{1,2}):(\d{2})/.exec(timeInp.value || ''); if (!m || +m[1] > 23 || +m[2] > 59) { zm.toast('Введите время от 00:00 до 23:59', 'warning'); return; } doAuto('daily:' + (+m[1]) + ':' + m[2]); } }, am === 'daily' ? 'Сохранить время' : 'Включить') ]); var UNIT = { h: { min: 1, max: 23, word: [ 'час', 'часа', 'часов' ], range: 'от 1 до 23 часов' }, m: { min: 10, max: 59, word: [ 'минуту', 'минуты', 'минут' ], range: 'от 10 до 59 минут' } }; var unit = isMin ? 'm' : 'h'; var numInp = E('input', { 'type': 'number', 'class': 'cbi-input-text zm-auto-num', 'step': '1', 'inputmode': 'numeric', 'aria-label': 'Интервал', 'value': am === 'custom' && !isNaN(cn) ? String(cn) : '3' }); var unitSeg = E('div', { 'class': 'zm-seg zm-unit-seg', 'role': 'radiogroup', 'aria-label': 'Единицы интервала' }); var rangeHint = E('p', { 'class': 'zm-hint zm-unit-hint' }); var everyWord = E('span', { 'class': 'zm-label' }, 'Каждые'); function unitWord(n, u) { var w = UNIT[u].word, a = n % 10, b = n % 100; return a === 1 && b !== 11 ? w[0] : a >= 2 && a <= 4 && (b < 12 || b > 14) ? w[1] : w[2]; } function syncUnit() { var n = parseInt(numInp.value, 10), U = UNIT[unit]; numInp.min = String(U.min); numInp.max = String(U.max); everyWord.textContent = !isNaN(n) && n % 10 === 1 && n % 100 !== 11 ? (unit === 'm' ? 'Каждую' : 'Каждый') : 'Каждые'; Array.prototype.forEach.call(unitSeg.children, function(c) { var on = c.getAttribute('data-u') === unit; c.classList.toggle('zm-active', on); c.setAttribute('aria-checked', on ? 'true' : 'false'); c.textContent = unitWord(isNaN(n) ? 5 : n, c.getAttribute('data-u')); }); var bad = isNaN(n) || n < U.min || n > U.max; rangeHint.textContent = (bad ? 'Можно ' : 'Допустимо ') + U.range + (unit === 'h' ? ' — нужно чаще, выберите «минут»' : ' — нужно реже, выберите «часов»') + '.'; rangeHint.classList.toggle('zm-ab-warn', bad); } [ 'h', 'm' ].forEach(function(u) { unitSeg.appendChild(E('div', { 'class': 'zm-seg-item', 'role': 'radio', 'tabindex': '0', 'data-u': u, 'click': function() { unit = u; syncUnit(); numInp.focus(); }, 'keydown': function(ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); unit = u; syncUnit(); } } })); }); numInp.addEventListener('input', syncUnit); function saveCustom() { var n = parseInt(numInp.value, 10), U = UNIT[unit]; if (isNaN(n) || n < U.min || n > U.max) { zm.toast('Интервал — ' + U.range, 'warning'); numInp.focus(); return; } doAuto((unit === 'm' ? 'everym:' : 'every:') + n); } numInp.addEventListener('keydown', function(ev) { if (ev.key === 'Enter') { ev.preventDefault(); saveCustom(); } }); var customRow = E('div', { 'style': am === 'custom' ? '' : 'display:none' }, [ E('div', { 'class': 'zm-actions zm-unit-row' }, [ everyWord, numInp, unitSeg, E('button', { 'class': 'cbi-button cbi-button-positive', 'click': saveCustom }, am === 'custom' ? 'Сохранить интервал' : 'Включить') ]), rangeHint ]); syncUnit(); 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('custom', am === 'custom' ? 'Каждые ' + cn + (isMin ? ' мин' : ' ч') : 'Свой интервал', function() { dailyRow.style.display = 'none'; customRow.style.display = ''; numInp.focus(); }), tile('daily', am === 'daily' ? 'Ежедневно в ' + curTime : 'Ежедневно в заданное время', function() { customRow.style.display = 'none'; dailyRow.style.display = ''; timeInp.focus(); }) ])); autoCard.appendChild(customRow); 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' && data.warp_mode !== 'iface' && !wayOpen && (warpView() || warpIdle()); 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' }, '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; var g = secGen; stA('sub_status', '').then(function(res) { if (g !== secGen) return; 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() { if (g !== secGen) return; subLoading = false; renderSub(); }); } function subAct(action, arg, toastText) { act(action, arg, toastText); } var PROTO_NAMES = { hysteria2: 'Hysteria2', trojan: 'Trojan', shadowsocks: 'Shadowsocks', socks: 'SOCKS', http: 'HTTP', vmess: 'VMess' }; 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|vmess|trojan|ss|socks4a?|socks5?|https?|hysteria2|hy2):\/\/\S+$/i, kinds: 'vless, trojan, ss, vmess, hysteria2, socks5, http', placeholder: 'vless://…\ntrojan://…\nhysteria2://…', empty: 'Серверов в списке нет — добавьте хотя бы одну ссылку на сервер.', 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('Оставьте хотя бы одну ссылку на сервер', '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; } stA('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); }); } function nodeLat(n) { if (lat[n.index]) return lat[n.index]; var v = ((subData && subData.ping) || {})[n.name]; if (v == null || v < 0) return null; if ((subData.ping_blk || []).indexOf(n.name) >= 0) return { ok: false, blocked: true, router: true, why: 'порт открыт, но запрос через узел не прошёл' }; if (v > 0 && (subData.ping_tcp || []).indexOf(n.name) >= 0) return { ok: true, ms: v, router: true, tcpOnly: true, why: 'TCP ' + v + ' мс — через узел роутер проверяет восемь самых быстрых по TCP' }; return v > 0 ? { ok: true, ms: v, router: true } : { ok: false, router: true, why: 'не ответил на пинг роутера' }; } function bestNode(ex) { var hm = subHidden(), best = null, bs = 0; subNodesAll().forEach(function(n) { if (hm[n.index] || ex.indexOf(n.name) >= 0) return; var l = nodeLat(n); if (!l || !l.ok || l.busy || l.pend || !(l.ms > 0)) return; var sc = (l.tcpOnly ? 5e7 : 0) + l.ms; if (!best || sc < bs) { best = { name: n.name, ms: l.tcpOnly ? 0 : l.ms }; bs = sc; } }); return best; } function askDead(n, l) { var b = bestNode([ n.name, subData.node || '' ]); zm.deadNodeDialog({ noun: 'узел', app: 'Steer', name: n.name, blocked: !!(l && l.blocked), why: l && l.why, pol: subData.node_pol, failText: data.onfail === 'drop' ? 'выбранные сервисы будут остановлены' : 'выбранные сервисы пойдут напрямую, через провайдера', best: b, autoText: subData.node ? 'Включить «Авто»' : '' }).then(function(v) { if (v === 'best') { if (b) subAct('sub_node', b.name, 'Выбираем узел ' + b.name); else subAct('sub_node', '', 'Выбираем узел автоматически'); } else if (v === 'force') subAct('sub_node', n.name, 'Выбираем узел ' + n.name); }); } var picking = false; function pickNode(n) { if (busy || picking || subData.node === n.name) return; var l = nodeLat(n); if (l && !l.busy && !l.pend && !l.ok && !l.router) { askDead(n, l); return; } picking = true; var bt = zm.busyToast('Проверяем узел «' + n.name + '»', 'запрос через узел — до 10 секунд'), g = secGen; var go = function() { subAct('sub_node', n.name, 'Выбираем узел ' + n.name); }; Promise.all([ stA('sub_tcp', String(n.index)).catch(function() { return null; }), stA('sub_probe', String(n.index)).catch(function() { return null; }) ]).then(function(rr) { picking = false; bt.close(); if (g !== secGen) return; var t = rr[0] && rr[0].results && rr[0].results[n.index], r = rr[1] && rr[1].results && rr[1].results[0]; if (!r) { go(); return; } if (r.ok) { lat[n.index] = { ok: true, ms: r.ttfb_ms > 0 ? r.ttfb_ms : r.handshake_ms, why: r.why, tcp: t && t.ok ? t.ms : 0 }; renderSub(); go(); return; } lat[n.index] = t && t.ok ? { ok: false, blocked: true, tcp: t.ms, why: r.why || '' } : { ok: false, why: r.why || '' }; renderSub(); askDead(n, lat[n.index]); }); } function mkHide() { return 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 }), 'Возвращаем все узлы'); } }); } var hidePick = mkHide(); 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 ids = nodes.map(function(n) { return n.index; }), chunks = [], core = []; for (var c = 0; c < ids.length; c += 8) chunks.push(ids.slice(c, c + 8)); var realMode = subData && subData.lat_mode !== 'fast', g = secGen; function one(i) { if (g !== secGen) return Promise.resolve(); var tcp = lat[i] && lat[i].pend ? lat[i].ms : 0; return stA('sub_probe', String(i)).then(function(res) { if (g !== secGen) return; var r = res && res.results && res.results[0]; if (r && r.ok) lat[i] = { ok: true, ms: r.ttfb_ms > 0 ? r.ttfb_ms : r.handshake_ms, why: r.why, tcp: tcp }; else if (tcp > 0 && r) lat[i] = { ok: false, blocked: true, tcp: tcp, why: r.why || '' }; else if (tcp > 0) lat[i] = { ok: true, ms: tcp, tcpOnly: true, why: 'только TCP' + (res && res.error ? ': ' + res.error : '') }; else lat[i] = r ? { ok: false, ms: r.ttfb_ms > 0 ? r.ttfb_ms : r.handshake_ms, why: r.why } : { ok: false, why: (res && res.error) || '' }; }).catch(function() { if (g === secGen) lat[i] = tcp > 0 ? { ok: true, ms: tcp, tcpOnly: true, why: 'только TCP' } : { ok: false }; }).then(function() { if (g !== secGen) return; probeDone++; renderSub(); }); } function nextChunk() { if (!chunks.length || g !== secGen) return Promise.resolve(); var part = chunks.shift(); return stA('sub_tcp', part.join(',')).then(function(res) { if (g !== secGen) return; var rs = (res && res.results) || {}; part.forEach(function(i) { var r = rs[i]; if (r && r.core) { core.push(i); return; } if (realMode && r && r.ok) { lat[i] = { ok: true, ms: r.ms, pend: true }; core.push(i); return; } lat[i] = r && r.ok ? { ok: true, ms: r.ms } : { ok: false, why: res && res.error ? res.error : 'нет TCP-соединения с сервером' }; probeDone++; }); }).catch(function() { if (g === secGen) part.forEach(function(i) { lat[i] = { ok: false }; probeDone++; }); }).then(function() { if (g !== secGen) return; renderSub(); return nextChunk(); }); } function nextCore() { return core.length ? one(core.shift()).then(nextCore) : Promise.resolve(); } Promise.all([ nextChunk(), nextChunk() ]).then(function() { return Promise.all(realMode ? [ nextCore(), nextCore(), nextCore() ] : [ nextCore(), nextCore() ]); }).then(function() { if (g !== secGen) return; probing = false; var ok = Object.keys(lat).filter(function(k) { return lat[k].ok; }).length; var blk = Object.keys(lat).filter(function(k) { return lat[k].blocked; }).length; zm.toast('Проверка закончена: отвечают ' + ok + ' из ' + probeTotal + (blk ? ', порт открыт, но не пускают — ' + blk : ''), ok ? 'info' : 'warning'); renderSub(); }); } function nodeCard(opts) { var cls = 'zm-node' + (opts.active ? ' zm-active' : '') + (opts.dead ? ' zm-node-dead' : '') + (opts.blk ? ' zm-node-blk' : ''); 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 subShow() { if (data.blocker || !data.installed) return false; if (!isMain()) return (secObj(secCur) || {}).kind === 'vpn'; return mainKind() === 'vpn'; } function renderSub() { subCard.innerHTML = ''; var show = subShow(); subCard.style.display = show ? '' : 'none'; if (!show) return; subCard.appendChild(E('h3', {}, 'VPN: подписка или свои серверы')); if (!subData) { subCard.appendChild(E('p', { 'class': 'zm-hint' }, subLoading ? 'Загружаем…' : 'Нет данных')); return; } if (!subData.has) { subCard.appendChild(E('p', { 'class': 'zm-hint' }, (isMain() ? 'Выбранные сервисы пойдут через ваш VPN.' : 'Сервисы секции «' + secName(secCur) + '» пойдут через свой VPN — отдельный от других секций.') + ' Вставьте ссылку на подписку или ссылки на серверы — VLESS, Hysteria2, Trojan, Shadowsocks, VMess, SOCKS или HTTP, по одной на строку.')); var ta = E('textarea', { 'class': 'zm-config-editor zm-sub-input', 'spellcheck': 'false', 'rows': '3', 'placeholder': 'https://… или vless:// trojan:// hysteria2:// …' }); 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|vmess|trojan|ss|socks4a?|socks5?|hysteria2|hy2):\/\//i.test(v)) { zm.toast('Нужна ссылка на подписку https://… или ссылки на серверы', 'warning'); return; } subInput = ''; subAct('sub_set', v, 'Подключаем подписку'); } }, 'Добавить'), !subData.ext ? E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Нужные модули ядра steer поставятся сами') : '' ])); return; } var q = subData.quota || {}, hasQ = q.up !== '' || q.down !== '' || q.total !== ''; var allNodes = (subData.list && subData.list.nodes) || [], hidMap = subHidden(); var nodes = allNodes.filter(function(n) { return !hidMap[n.index]; }); subCard.appendChild(zm.subMeta({ title: String(subData.title || (subData.kind === 'url' ? 'Подписка' : 'Свои серверы')), used: hasQ ? (+q.up || 0) + (+q.down || 0) : null, total: +q.total || null, unlimited: hasQ && !(+q.total > 0), expire: q.expire, updated: subData.updated, count: { label: 'Узлов', n: nodes.length, all: allNodes.length } })); if (subData.home) { var homeN = subNodesAll().filter(function(x) { return x.name === subData.home; })[0]; subCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show zm-home-banner', 'role': 'note' }, [ E('span', {}, [ 'Узел «', String(subData.home), '» не отвечал — Steer перешёл на ' + (subData.node ? '«' + subData.node + '»' : subData.vpn && subData.vpn.up && subData.vpn.node ? '«' + subData.vpn.node + '»' : 'рабочий узел') + ' и вернётся на него, когда он ответит (проверка раз в 10 минут).' ]), E('span', { 'class': 'zm-banner-acts' }, [ homeN ? E('button', { 'class': 'cbi-button cbi-button-action', 'disabled': busy ? '' : null, 'click': function() { pickNode(homeN); } }, 'Вернуть сейчас') : E([]), E('button', { 'class': 'cbi-button', 'disabled': busy ? '' : null, 'click': function() { stA('sub_home', 'forget').then(function(res) { if (!res || res.error) { zm.toast((res && res.error) || 'Роутер не ответил', 'error'); return; } subData.home = ''; subData.fell = ''; zm.toast('Возвращаться не будем — остаёмся на текущем узле', 'info'); renderSub(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } }, 'Не возвращаться') ]) ])); } else 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 = isMain() ? (data.exit || subData.exit) === 'vpn' : subData.exit === 'vpn'; if (vpn) { var v = subData.vpn || {}, pr = v.probe || {}, st; if (v.up) st = badge('zm-ok', 'подключено' + (v.node ? ' · ' + v.node : '')); 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 if (v.failed) st = badge('zm-bad', (subData.node ? 'узел «' + subData.node + '» не отвечает' : 'узел не отвечает') + (v.why ? ' — ' + v.why : '') + ' · ' + failText()); 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() { var own = subData.kind === 'links'; zm.dialog({ title: own ? 'Удалить свои серверы?' : 'Удалить подписку?', danger: true, okText: own ? 'Удалить серверы' : 'Удалить подписку', blocks: [ { type: 'list', title: 'Что произойдёт', items: [ own ? 'Все добавленные серверы удалятся' : 'Подписка и её серверы удалятся', !isMain() ? 'Сервисы секции «' + secName(secCur) + '» пойдут напрямую, пока не добавите подписку или не смените её подключение' : data.warp_on ? 'Выбранные сервисы пойдут через WARP' : 'Туннеля не останется — сервисы пойдут напрямую' ] } ] }).then(function(v) { if (v) subAct('sub_remove', '', own ? 'Удаляем серверы' : 'Удаляем подписку'); }); } }, subData.kind === 'links' ? 'Удалить серверы' : 'Удалить подписку')); 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 (!hideMode && !(linksOpen && subData.kind === 'links') && nodes.length) { var lm = subData.lat_mode === 'fast' ? 'fast' : 'real'; subCard.appendChild(row('Проверка узлов', E('div', { 'class': 'zm-seg' }, [ { id: 'fast', label: 'Только TCP' }, { id: 'real', label: 'Через узел' } ].map(function(o) { return E('div', { 'class': 'zm-seg-item' + (lm === o.id ? ' zm-active' : ''), 'click': function() { if (lm === o.id) return; if (probing || busy) { zm.toast('Дождитесь окончания проверки', 'warning'); return; } stA('lat_mode', o.id).then(function(res) { if (!res || res.error) { zm.toast((res && res.error) || 'Роутер не ответил', 'error'); return; } subData.lat_mode = res.mode; zm.toast(res.mode === 'fast' ? 'Узлы проверяются только по TCP' : 'Узлы проверяются запросом через узел', 'info'); renderSub(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } }, o.label); })))); subCard.appendChild(E('p', { 'class': 'zm-hint' }, lm === 'fast' ? 'Только TCP: открывается ли порт узла. Быстро и почти не нагружает роутер, но узел, который блокирует провайдер, может показывать хороший пинг. Подходит для слабых роутеров и больших подписок.' : 'Сначала проверяется, открыт ли порт узла (TCP), затем — настоящий запрос через него; цифра — задержка. «Порт открыт, не пускает» — узел отвечает, но трафик не проходит (обычно блокирует провайдер). По этой проверке выбирает узел и автовыбор «По пингу».')); } 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' }, 'Свои серверы'), E('p', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Здесь ваши ссылки на серверы: можно изменить любую, убрать лишние или добавить новые — по одной или сразу несколькими строками. Steer 2.0 умеет VLESS, Hysteria2, Trojan, Shadowsocks, VMess, SOCKS или HTTP.'), linksEd, linksBar ])); return; } var isPing = subData.pick === 'ping'; function latOf(n) { return nodeLat(n); } if (!hideMode && nodes.length > 1) { subCard.appendChild(E('h4', { 'style': 'margin:16px 0 4px' }, 'Автовыбор узла')); subCard.appendChild(row('Режим «Авто»', E('div', { 'class': 'zm-seg' }, [ { id: 'order', label: 'Первый рабочий' }, { id: 'ping', label: 'По пингу' } ].map(function(o) { return E('div', { 'class': 'zm-seg-item' + ((isPing ? 'ping' : 'order') === o.id ? ' zm-active' : ''), 'click': function() { if (busy || (isPing ? 'ping' : 'order') === o.id) return; subAct('sub_pick', o.id === 'ping' ? 'ping:' + (subData.pick_iv || 15) + ':' + (subData.pick_tol != null ? subData.pick_tol : 50) : 'order', o.id === 'ping' ? 'Включаем автовыбор по пингу — замеряем узлы' : 'Автовыбор: первый рабочий узел'); } }, o.label); })))); if (isPing) { var IVS = [ { id: 15, label: '15 мин' }, { id: 30, label: '30 мин' }, { id: 60, label: '1 ч' }, { id: 180, label: '3 ч' } ]; subCard.appendChild(row('Перепроверять', E('div', { 'class': 'zm-seg' }, IVS.map(function(o) { return E('div', { 'class': 'zm-seg-item' + (+subData.pick_iv === o.id ? ' zm-active' : ''), 'click': function() { if (busy || +subData.pick_iv === o.id) return; subAct('sub_pick', 'ping:' + o.id + ':' + (subData.pick_tol != null ? subData.pick_tol : 50), 'Перепроверка пинга: раз в ' + o.label); } }, o.label); })))); var tolIn = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'inputmode': 'numeric', 'style': 'width:80px', 'value': String(subData.pick_tol != null ? subData.pick_tol : 50) }); subCard.appendChild(row('Допуск', E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap' }, [ tolIn, E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'мс'), E('button', { 'class': 'cbi-button', 'disabled': busy ? '' : null, 'click': function() { var v = tolIn.value.trim(); if (!/^\d{1,5}$/.test(v) || +v > 10000) { zm.toast('Допуск — число миллисекунд от 0 до 10000', 'warning'); return; } subAct('sub_pick', 'ping:' + (subData.pick_iv || 15) + ':' + (+v), 'Сохраняем допуск'); } }, 'Сохранить') ]))); subCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-action', 'disabled': busy ? '' : null, 'click': function() { subAct('sub_ping', '', 'Замеряем пинг и выбираем самый быстрый узел'); } }, 'Замерить и выбрать сейчас'), subData.ping_at > 0 ? E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Последний замер: ' + fmtAge(Math.max(0, Math.floor(Date.now() / 1000) - subData.ping_at))) : E([]) ])); } subCard.appendChild(E('p', { 'class': 'zm-hint' }, isPing ? 'Роутер сам замеряет пинг всех узлов и ставит первым самый быстрый. Пока узел отвечает, Steer остаётся на нём (раз в ' + ((IVS.filter(function(o) { return o.id === +subData.pick_iv; })[0] || IVS[0]).label) + ' сверяет, нет ли заметно быстрее). Оборвался — ядро steer само переходит на следующий по скорости. Допуск — насколько другой узел должен быть быстрее, чтобы уйти с работающего. Ручной выбор узла отключает автовыбор до нажатия «Авто».' : 'Работает первый отвечающий узел по порядку подписки; если он оборвался, ядро steer само переходит на следующий. «По пингу» — первым встаёт самый быстрый.')); } if (!hideMode && nodes.length > 1) { var NP = [ { id: 'back', label: 'Перейти и вернуться' }, { id: 'switch', label: 'Перейти на рабочий' }, { id: 'hold', label: 'Держать выбранный' } ]; var np = subData.node_pol === 'switch' || subData.node_pol === 'hold' ? subData.node_pol : 'back'; subCard.appendChild(row('Если узел не отвечает', E('div', { 'class': 'zm-seg' }, NP.map(function(o) { return E('div', { 'class': 'zm-seg-item' + (np === o.id ? ' zm-active' : ''), 'click': function() { if (np === o.id) return; stA('node_pol', o.id).then(function(res) { if (!res || res.error) { zm.toast((res && res.error) || 'Роутер не ответил', 'error'); return; } subData.node_pol = res.pol; if (res.pol !== 'back') subData.home = ''; zm.toast(res.pol === 'hold' ? 'Выбранный узел держим, даже если он не отвечает' : res.pol === 'switch' ? 'Если выбранный узел не отвечает — переходим на рабочий' : 'Если выбранный узел не отвечает — переходим на рабочий и возвращаемся', 'info'); renderSub(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } }, o.label); })))); subCard.appendChild(E('p', { 'class': 'zm-hint' }, np === 'hold' ? 'Steer остаётся на выбранном вручную узле и ничего не перезапускает — пока узел не ответит, ' + (data.onfail === 'drop' ? 'выбранные сервисы остановлены' : 'выбранные сервисы идут напрямую') + '. Для узлов, которые ненадолго пропадают.' : np === 'switch' ? 'Если выбранный вручную узел перестал отвечать, Steer переходит на рабочий и остаётся на нём. Вернуться — нажать на узел в списке.' : 'Если выбранный вручную узел перестал отвечать, Steer переходит на рабочий, а выбранный проверяет раз в 10 минут и возвращается на него, как только он ответит. Перед выбором узел проверяется.')); } var grid = E('div', { 'class': 'zm-nodes' }); grid.appendChild(nodeCard({ name: 'Авто', foot: isPing ? 'самый быстрый по пингу' + (subData.order_head && !subData.node ? ' · ' + subData.order_head : '') : 'первый рабочий узел', active: !subData.node, click: function() { if (subData.node && !busy) subAct('sub_node', '', 'Выбираем узел автоматически'); } })); function pingRank(n) { var l = latOf(n); if (!l || l.busy || l.pend) return 1e8; if (l.blocked) return 1e9; if (!l.ok) return 1e9 + 1; if (l.tcpOnly && subData.lat_mode !== 'fast') return 5e7 + l.ms; 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 = latOf(n), txt = '', cls = 'zm-lat-none', dead = false, ttl = l && l.why ? l.why : ''; if (l && l.busy) txt = '…'; else if (l && l.pend) { txt = l.ms + ' мс…'; ttl = 'TCP ' + l.ms + ' мс — проверяем запросом через узел'; } else if (l && l.blocked) { txt = 'порт открыт, не\u00a0пускает'; cls = 'zm-lat-blk'; dead = true; ttl = 'Порт узла открыт' + (l.tcp > 0 ? ' (TCP ' + l.tcp + ' мс)' : '') + ', но запрос через узел не прошёл — скорее всего, узел блокирует провайдер' + (l.why ? ' (' + l.why + ')' : ''); } else if (l && l.ok && l.tcpOnly && subData.lat_mode !== 'fast') { txt = 'TCP ' + l.ms + ' мс'; } else if (l && l.ok) { txt = l.ms > 0 ? l.ms + ' мс' : '<1 мс'; cls = latClass(l.ms); if (l.tcp > 0) ttl = 'Запрос через узел: ' + l.ms + ' мс · TCP: ' + l.tcp + ' мс'; } else if (l) { txt = 'нет ответа'; cls = 'zm-lat-bad'; dead = true; } var foot = [ PROTO_NAMES[n.proto] || '', n.type !== 'quic' ? n.type : '', n.security && n.security !== 'none' ? n.security : '', n.vision ? 'vision' : '' ].filter(function(x) { return x; }).join(' · '); if (vpn && !subData.node && subData.vpn && subData.vpn.up && subData.vpn.node === n.name) foot = 'сейчас работает · ' + foot; grid.appendChild(nodeCard({ name: n.name || 'Узел ' + (ni + 1), foot: foot, lat: txt, latCls: cls, dead: dead, active: subData.node === n.name, title: ttl, blk: !!(l && l.blocked), click: function() { pickNode(n); } })); }); 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: [ PROTO_NAMES[n.proto] || '', n.type !== 'quic' ? n.type : '', n.security && n.security !== 'none' ? n.security : '' ].filter(Boolean).join(' · '), hidden: byName, locked: !!hidMap[n.index] && !byName }; }), { markers: subData.exclude || '', hideNames: hn }); subCard.appendChild(hidePick); 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 2.0 умеет VLESS, Hysteria2, Trojan, Shadowsocks, VMess, SOCKS или HTTP; TUIC, AnyTLS и WireGuard из подписки он не берёт.' ])); if (!vpn && isMain() && data.exit === 'warp') subCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Сейчас сервисы идут через ' + warpName() + ' — переключить можно выше, в «Куда идут сервисы». Выбор узла сохранится.')); 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' }, 'Обновлять подписку')); if (hasSecs()) subCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin:-4px 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 = []; var srcName = r.src ? (r.src_name ? r.src_name + ' · ' + r.src : r.src) : ''; var ssn = r.src_sec_name ? '«' + r.src_sec_name + '»' : ''; if (r.src) lines.push([ 'Устройство', r.src_mode === 'all' ? [ E('b', {}, srcName), ssn ? ' — весь трафик через секцию ' + ssn : ' — весь трафик через Steer' ] : r.src_mode === 'byp' ? [ E('b', {}, srcName), ' — мимо Steer' ] : r.src_mode === 'only' ? [ E('b', {}, srcName), ' — для него работает секция ' + ssn ] : [ srcName, ' — общие правила' ] ]); if (r.probe) lines.push([ 'Проверен', [ E('b', {}, r.probe), ' — у самого ' + r.target + ' нет адреса, а правило списка действует и на поддомены' ] ]); if (r.addr) lines.push([ 'Адрес', r.fake ? [ r.addr, ' (подменный — домен есть в списках Steer)' ] : r.verdict === 'exclude' ? [ r.addr, ' (настоящий — домен в исключениях)' ] : [ r.addr, ' (настоящий — домена нет в списках Steer)' ] ]); var wh = (r.where || []).map(function(x) { var y = {}; for (var k in x) y[k] = x[k]; return y; }); var winState = r.verdict === 'exclude' ? 'x' : (r.verdict === 'warp' || r.verdict === 'vpn' || r.verdict === 'iface' || r.verdict === 'other') ? 'on' : ''; var secLn = hasSecs() && (r.verdict === 'warp' || r.verdict === 'vpn' || r.verdict === 'iface') ? [ 'Секция', E('b', {}, r.sec_name || secName('main')) ] : null; for (var wi = 0; winState && wi < wh.length; wi++) if (wh[wi].state === winState) { wh[wi].win = true; break; } var whl = zm.routeWhere(wh, r.verdict === 'devbyp' ? { tags: { x: 'исключение', on: 'для других устройств', off: 'сервис не выбран' } } : null); if (whl) lines.push([ 'Списки', whl ]); else if (r.channel && !/^(zm_|s[2-9]_)/.test(r.channel)) lines.push([ 'Правило', r.channel ]); var offSvc = wh.filter(function(x) { return x.state === 'off'; }).map(function(x) { return x.name; }).filter(function(n, i, a) { return n && a.indexOf(n) === i; }); var onSvc = wh.filter(function(x) { return x.state === 'on'; }).length > 0; if (r.verdict === 'off') return { tone: 'off', cls: 'zm-off', label: 'Steer выключен', title: 'запрос пойдёт напрямую', lines: lines, note: 'Запустите Steer, чтобы сервисы шли через WARP или VPN.' }; if (r.verdict === 'devbyp') return { tone: 'warn', cls: 'zm-warn', label: 'напрямую · устройство мимо Steer', title: 'это устройство всегда ходит напрямую', lines: lines, note: 'Устройство в списке «Мимо Steer» на вкладке «Устройства» — этот выбор важнее выбранных сервисов. Уберите его оттуда, чтобы сервисы на нём шли через ' + (data.exit === 'vpn' ? 'VPN' : warpName()) + '.', raw: r.text }; if (r.verdict === 'devlan') return { tone: 'off', cls: 'zm-off', label: 'напрямую · локальная сеть', title: 'адрес в домашней сети — туннель не нужен', lines: lines, raw: r.text }; if (r.verdict === 'exclude') return { tone: 'warn', cls: 'zm-warn', label: 'напрямую · исключение', title: 'адрес в «Исключениях» Steer — идёт мимо WARP и VPN' + (r.src_mode === 'all' ? ', даже для этого устройства' : ''), lines: lines }; if (secLn) lines.push(secLn); if (r.verdict === 'iface') { lines.push([ 'Выход', 'интерфейс AmneziaWG ' + (r.dev || '') ]); return { tone: 'ok', cls: 'zm-ok', label: 'через ' + (r.dev || 'интерфейс AmneziaWG'), title: r.src_mode === 'all' ? 'весь трафик устройства идёт через интерфейс секции' : 'идёт через интерфейс секции', lines: lines, raw: r.text }; } if (r.verdict === 'vpn') { var vlab = r.sec ? r.sec_sub : data.sub_label; lines.push([ 'Выход', 'VPN' + (vlab ? ' · ' + vlab : '') ]); lines.push([ 'Сервер', r.node ? r.node : 'выбирается автоматически (первый рабочий)' ]); if (r.dev) lines.push([ 'Интерфейс', r.dev ]); return { tone: 'ok', cls: 'zm-ok', label: 'через VPN', title: r.src_mode === 'all' ? 'весь трафик устройства идёт через подписку' : 'идёт через подписку', 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: r.src_mode === 'all' ? 'весь трафик устройства идёт через туннель' : 'идёт через туннель', 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: onSvc ? 'адрес есть в выбранном сервисе, но ядро его сейчас не ведёт' : 'Steer этот адрес не трогает', lines: lines, note: onSvc ? 'Устройство могло запомнить настоящий адрес сайта: очистите DNS-кэш или подождите пару минут. Если не поможет — нажмите «Применить».' : offSvc.length ? 'Адрес есть в списке ' + (offSvc.length > 1 ? 'сервисов ' : 'сервиса ') + offSvc.map(function(n) { return '«' + n + '»'; }).join(', ') + ' — включите его, чтобы сайт шёл через ' + (data.exit === 'vpn' ? 'VPN' : warpName()) + '.' : 'Если сайт должен идти через ' + (data.exit === 'vpn' ? 'VPN' : warpName()) + ' — включите его сервис в списке или добавьте домен в «Свой список».', raw: r.text }; var exitName = data.exit === 'vpn' ? 'VPN' : warpName(); if (r.verdict === 'noaddr') return { tone: onSvc ? 'ok' : 'warn', cls: onSvc ? 'zm-ok' : 'zm-warn', label: 'у имени нет адреса', title: onSvc ? 'домен в списке — его поддомены пойдут через ' + exitName : 'по самому имени никто не ходит', lines: lines, note: 'У «' + r.target + '» нет собственного IP-адреса: сайты и приложения ходят на его поддомены (например, static.' + r.target + '). Введите поддомен, чтобы увидеть путь.' + (onSvc ? '' : offSvc.length ? ' Домен есть в списке ' + offSvc.map(function(n) { return '«' + n + '»'; }).join(', ') + ' — включите его.' : ''), raw: r.text }; if (r.verdict === 'nodns') return { tone: 'bad', cls: 'zm-bad', label: 'резолвер Steer не отвечает', title: 'доменные правила сейчас не работают ни для кого', lines: lines, note: 'Нажмите «Применить» в карточке Steer — служба перезапустится. Если не поможет, загляните в системный журнал.', raw: r.text }; if (r.verdict === 'nodomains') return { tone: 'off', cls: 'zm-off', label: 'нет правил по доменам', title: 'имена Steer сейчас не разбирает', lines: lines, note: 'Выберите сервис или добавьте домен в «Свой список» — либо проверьте IP-адрес вместо имени.', raw: r.text }; return { tone: 'bad', cls: 'zm-bad', label: 'не удалось определить', title: 'смотрите ответ роутера', lines: lines, raw: r.text }; } var routeDev = false, routeSource = '', routeDevSig = ''; function routeDevs() { var ips = {}, out = []; (devList || []).forEach(function(d) { if (d && d.mac && d.ip) ips[String(d.mac).toLowerCase()] = { ip: d.ip, name: d.name || '' }; }); var seen = {}; secIds().forEach(function(id) { secDevs(id).forEach(function(d) { if (!d || !d.key) return; var k = String(d.key).toLowerCase(), ip = IP4_RE.test(k) ? k : (ips[k] && ips[k].ip) || ''; if (!ip || seen[ip]) return; seen[ip] = true; out.push({ ip: ip, name: d.name || (ips[k] && ips[k].name) || '', mode: d.mode, sec: id }); }); }); return out; } 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 + (routeDev && routeSource.trim() ? '|' + routeSource.trim() : '')); }, verdict: routeVerdict }); var byDev = secIds().some(function(id) { return secDevs(id).length > 0; }), picks = byDev ? routeDevs() : [], sig = JSON.stringify(picks); if (byDev && devList === null && !devLoading) setTimeout(loadDevs, 0); if (routeBox.parentNode === routeCard && byDev === routeDev && sig === routeDevSig) return; if (routeDev && !byDev) { routeSource = ''; routeBox.reset(); } routeDev = byDev; routeDevSig = sig; routeCard.innerHTML = ''; routeCard.appendChild(E('h3', {}, 'Куда пойдёт запрос')); routeCard.appendChild(E('p', { 'class': 'zm-hint' }, byDev ? 'Введите сайт или IP-адрес — Steer покажет, пойдёт он через WARP/VPN или напрямую. У части устройств свои правила (вкладка «Устройства») — укажите IP устройства, чтобы увидеть путь именно с него. Пусто — общие правила.' : 'Введите сайт или IP-адрес — Steer покажет, пойдёт он через WARP/VPN или напрямую.')); if (byDev) { var sourceInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'placeholder': '192.168.1.100', 'inputmode': 'decimal', 'autocomplete': 'off', 'spellcheck': 'false', 'value': routeSource, 'style': 'width:200px; max-width:100%' }); sourceInput.addEventListener('input', function() { routeSource = sourceInput.value; paintPicks(); routeBox.reset(); }); var pickBox = E('div', { 'class': 'zm-route-chips zm-route-devs' }); var paintPicks = function() { pickBox.innerHTML = ''; if (!picks.length) return; picks.concat([ { ip: '', name: 'Общие правила' } ]).forEach(function(d) { var cur = routeSource.trim() === d.ip; var dt = d.mode === 'byp' ? 'мимо Steer' : d.mode === 'only' ? 'своя секция «' + secName(d.sec) + '»' : hasSecs() ? 'весь трафик через «' + secName(d.sec) + '»' : 'весь трафик через Steer'; pickBox.appendChild(E('button', { 'type': 'button', 'class': 'zm-route-chip' + (cur ? ' zm-route-chip-on' : ''), 'title': d.ip ? dt : 'без устройства', 'click': function() { routeSource = d.ip; sourceInput.value = d.ip; paintPicks(); routeBox.reset(); } }, d.ip ? (d.name ? d.name + ' · ' + d.ip : d.ip) : d.name)); }); }; paintPicks(); routeCard.appendChild(row('IP устройства', sourceInput)); routeCard.appendChild(pickBox); } routeCard.appendChild(routeBox); } function isCommon(id) { return STEER_TABS.some(function(t) { return t.id === id && t.common; }); } function draftDirty() { if (pick) { var c = currentPick(), ks = {}; Object.keys(c).concat(Object.keys(pick)).forEach(function(k) { ks[k] = true; }); if (Object.keys(ks).some(function(k) { return k !== 'custom' && !!c[k] !== !!pick[k]; })) return true; } if (customDirty) return true; return !!devPick && devSig(devPick) !== devSig(devSaved()); } function secTo(id, toConn) { function go() { if (id !== secCur) { secCur = id; secGen++; try { localStorage.setItem('zm.steer.sec', id); } catch (e) {} pick = null; devPick = null; subData = null; lat = {}; probing = false; picking = false; hideMode = false; hidePick = mkHide(); linksOpen = false; subInput = ''; connPick = null; ifChoose = false; ifSel = null; nameDraft = null; wayOpen = false; warpPick = null; ownOpen = false; extPick = null; customData = null; customDraft = null; customDirty = false; findRes = null; if (!data.blocker) loadCustom(); } if (toConn && isCommon(tab)) { tab = 'conn'; try { localStorage.setItem('zm.steer.tab', tab); } catch (e) {} } renderAll(); if (subShow()) loadSub(); if (tab === 'dev' && !devList) loadDevs(); } if (id === secCur || !draftDirty()) { go(); return; } zm.ask('Выбор в секции «' + secName(secCur) + '» не сохранён.\n\nПерейти в «' + secName(id) + '» без сохранения?').then(function(ok) { if (ok) go(); }); } function secAct(action, arg, okText, after) { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } zm.steerAction(action, arg).then(function(res) { if (!res || res.error) { zm.toast((res && res.error) || 'Роутер не ответил', 'error'); return; } if (after) after(res); if (res.started) { lastAction = action; data.running = true; data.phase = 'rules'; follow(); return; } if (okText) zm.toast(okText, 'info'); refresh().then(function() { if (subShow()) loadSub(); }); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } function awgIfs() { return ((awgData && awgData.ifaces) || []).filter(function(f) { return f.owner !== 'steer'; }); } function ifDesc(f) { var sub = [ f.warp ? 'WARP' : 'свой сервер' ]; if (!f.enabled) sub.push('выключен'); else sub.push(ifAlive(f) ? 'работает' : f.up ? 'нет связи' : 'не поднят'); if (f.forkozz) sub.push('используется в ' + ((awgData && awgData.fk_name) || 'Forkozz')); if (f.route_all) sub.push('весь трафик роутера'); return sub.join(' · '); } function secAddDialog() { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } var max = data.sec_max || 6, vmax = data.sec_vpn_max || 3; if (secIds().length >= max) { zm.toast('Секций может быть не больше ' + max, 'warning'); return; } var bt = awgData ? null : zm.busyToast('Готовим новую секцию', 'смотрим интерфейсы AmneziaWG'); (awgData ? Promise.resolve(awgData) : zm.awgStatus().catch(function() { return { ifaces: [] }; })).then(function(r) { if (bt) bt.close(); awgData = r || { ifaces: [] }; awgAt = Date.now(); var ifs = awgIfs(), vFull = secVpnCount() >= vmax; var best = (ifs.filter(ifAlive)[0] || ifs[0] || {}).name || ''; return zm.dialog({ title: 'Новая секция', okText: 'Создать секцию', intro: 'Секция ведёт свои сервисы через своё подключение. Сервисы и устройства для неё выберете следующим шагом.', blocks: [ { type: 'input', id: 'name', title: 'Название', placeholder: 'Например, Нейросети', value: '' }, { type: 'choice', id: 'kind', title: 'Куда пойдут её сервисы', value: vFull ? 'warp' : 'vpn', options: [ { id: 'vpn', label: 'VPN', disabled: vFull, hint: vFull ? 'Уже ' + vmax + ' ' + plural(vmax, 'секция', 'секции', 'секций') + ' с VPN — больше нельзя: каждая держит свой туннель и память роутера.' : 'Своя подписка или свои серверы — отдельный туннель только для этой секции.' }, { id: 'warp', label: 'WARP', hint: data.warp_on ? 'Общие туннели WARP Steer — через них уже идут другие секции с WARP.' : 'Общие туннели WARP Steer. Их ещё нет — подключите после создания секции, с подтверждением.' }, { id: 'iface', label: 'Интерфейс AmneziaWG', disabled: !ifs.length, hint: ifs.length ? 'Ваш интерфейс с вкладки AmneziaWG: WARP или свой сервер.' : 'Интерфейсов пока нет — создайте его на вкладке AmneziaWG.' } ] }, { type: 'choice', id: 'iface', title: 'Интерфейс', value: best, show: function(v) { return v.kind === 'iface'; }, options: ifs.map(function(f) { return { id: f.name, label: f.name, hint: ifDesc(f) }; }) } ], canOk: function(v) { return v.kind !== 'iface' || !!v.iface; } }); }).then(function(v) { if (!v) return; var nm = String(v.name || '').replace(/[|\r\n\t"\\]/g, ' ').trim().slice(0, 40); zm.steerAction('sec_add', v.kind + '|' + (v.kind === 'iface' ? v.iface : '') + '|' + nm).then(function(res) { if (!res || res.error) { zm.toast((res && res.error) || 'Роутер не ответил', 'error'); return; } refresh().then(function() { var noWarp = v.kind === 'warp' && !data.warp_on; tab = v.kind === 'vpn' || noWarp ? 'conn' : 'svc'; try { localStorage.setItem('zm.steer.tab', tab); } catch (e) {} secTo(res.id); zm.toast(v.kind === 'vpn' ? 'Секция создана — добавьте ей подписку, затем выберите сервисы' : noWarp ? 'Секция создана — выберите, как подключить WARP' : 'Секция создана — выберите, какие сервисы через неё пойдут', 'info'); }); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); }); } function renderSecs() { secCard.innerHTML = ''; var show = !!data.installed && !data.blocker; secCard.style.display = show ? '' : 'none'; if (!show) return; var common = isCommon(tab), ids = secIds(), n = ids.length; secCard.classList.toggle('zm-sec-dim', common); secCard.appendChild(E('h3', {}, [ 'Секции ', badge(n > 1 ? 'zm-ok' : 'zm-off', n + ' ' + plural(n, 'секция', 'секции', 'секций')) ])); secCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, common ? 'WARP, исключения и настройки — общие для всех секций. Нажмите на секцию, чтобы настроить её подключение, сервисы и устройства.' : n > 1 ? 'Каждая секция ведёт свои сервисы через своё подключение: VPN, WARP или интерфейс AmneziaWG.' : 'Секция — свои сервисы через своё подключение. Например, видео через VPN, нейросети через WARP, игры через интерфейс AmneziaWG.')); var tiles = ids.map(function(id) { var s = secObj(id), ex = secExit(id), c = secSvc(id).length, w = secWork(id); var foot = !s ? (ex === 'vpn' ? 'VPN' : ex === 'warp' ? warpName() : 'не подключена') : s.kind === 'vpn' ? (s.vpn && s.vpn.has ? 'VPN · ' + (s.vpn.label || 'подписка') : 'VPN · нет подписки') : s.kind === 'iface' ? (s.iface || 'интерфейс') + (s.live ? '' : ' · нет') : 'WARP'; var right = c ? c + ' ' + plural(c, 'сервис', 'сервиса', 'сервисов') : w ? 'устройства' : 'пусто', so = secOff(id); if (so) right = 'выключена'; return E('div', { 'class': 'zm-node zm-sec' + (id === secCur ? ' zm-active' : '') + (so ? ' zm-sec-off' : ''), 'title': 'Секция «' + secName(id) + '»' + (so ? ' — выключена' : ''), 'click': function() { secTo(id, common); } }, [ E('div', { 'class': 'zm-node-name' }, [ secName(id) ]), E('div', { 'class': 'zm-node-foot' }, [ E('span', {}, [ foot ]), E('span', { 'class': 'zm-lat ' + (so ? 'zm-lat-none' : ex === 'none' && w ? 'zm-lat-mid' : w ? 'zm-lat-good' : 'zm-lat-none'), 'style': 'flex:0 1 auto' }, right) ]) ]); }); if (n < (data.sec_max || 6)) tiles.push(E('div', { 'class': 'zm-node zm-sec zm-sec-add', 'role': 'button', 'tabindex': '0', 'title': 'Свои сервисы через другое подключение', 'click': secAddDialog, 'keydown': function(ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); secAddDialog(); } } }, [ E('span', { 'class': 'zm-sec-add-text' }, 'Создать новую секцию') ])); secCard.appendChild(E('div', { 'class': 'zm-nodes' }, tiles)); if (n > 1 && !common) secCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Порядок важен: сервис или устройство, выбранные в нескольких секциях, пойдут через первую из них — ' + ids.map(secName).join(' → ') + '. «Мимо Steer» и исключения главнее всех секций.')); } function ifPicker(box, cur, onPick, onCancel) { if (!awgData) { box.appendChild(E('p', { 'class': 'zm-hint' }, 'Загружаем интерфейсы AmneziaWG…')); awgLoad(); return; } var list = awgIfs(), awgLink = E('a', { 'class': 'cbi-button', 'href': zm.pageHref('awg') }, 'Открыть AmneziaWG'); if (!awgData.installed || !list.length) { box.appendChild(E('div', { 'class': 'zm-st-ext-empty' }, [ E('div', { 'class': 'zm-st-ext-empty-t' }, !awgData.installed ? 'AmneziaWG не установлен' : 'Интерфейсов AmneziaWG пока нет'), E('div', { 'class': 'zm-hint' }, 'Создайте интерфейс на вкладке AmneziaWG: из WARP.conf или своего .conf — и возвращайтесь сюда.'), E('div', { 'class': 'zm-actions' }, [ awgLink ].concat(onCancel ? [ E('button', { 'class': 'cbi-button', 'click': onCancel }, 'Отмена') ] : [])) ])); return; } if (!ifSel || !list.some(function(f) { return f.name === ifSel; })) ifSel = ((list.filter(function(f) { return f.name === cur; })[0]) || list.filter(ifAlive)[0] || list[0]).name; box.appendChild(E('div', { 'class': 'zm-afix-list zm-st-ext-list', 'role': 'radiogroup' }, list.map(function(f) { var on = f.name === ifSel, st = []; st.push(!f.enabled ? badge('zm-off', 'выключен') : ifAlive(f) ? badge('zm-ok', 'работает') : f.up ? badge('zm-warn', 'нет связи') : badge('zm-bad', 'не поднят')); if (f.name === cur) st.push(E('span', { 'class': 'zm-afix-note' }, 'сейчас здесь')); var sub = [ f.warp ? 'WARP' : 'свой сервер' ]; if (f.endpoint) sub.push(zm.secret(f.endpoint)); if (f.forkozz) sub.push('используется в ' + (awgData.fk_name || 'Forkozz')); function choose() { if (ifSel === f.name) return; ifSel = f.name; renderConn(); } return E('div', { 'class': 'zm-swrow zm-afix-row zm-st-ext-row' + (on ? ' zm-st-ext-on' : ''), 'role': 'radio', 'aria-checked': on ? 'true' : 'false', 'tabindex': '0', 'click': choose, 'keydown': function(ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); choose(); } } }, [ E('span', { 'class': 'zm-st-radio' }), E('div', { 'class': 'zm-swrow-text' }, [ E('div', { 'class': 'zm-swrow-label' }, f.name), E('div', { 'class': 'zm-hint' }, sub.reduce(function(a, x, k) { if (k) a.push(' · '); a.push(x); return a; }, [])) ]), E('div', { 'class': 'zm-afix-st' }, st) ]); }))); var pf = list.filter(function(f) { return f.name === ifSel; })[0] || {}, same = pf.name === cur; var btns = [ E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': busy || same ? '' : null, 'click': function() { if (pf.route_all) return zm.ask('У ' + pf.name + ' включено «Весь трафик» — через него уже идёт весь интернет роутера.\n\nВсё равно подключить секцию к нему?').then(function(ok) { if (ok) onPick(pf.name); }); onPick(pf.name); } }, same ? 'Сейчас через ' + cur : 'Подключить ' + (pf.name || '')) ]; if (onCancel) btns.push(E('button', { 'class': 'cbi-button', 'click': onCancel }, 'Отмена')); btns.push(awgLink); box.appendChild(E('div', { 'class': 'zm-actions' }, btns)); } function renderConn() { connCard.innerHTML = ''; var show = !!data.installed && !data.blocker; connCard.style.display = show ? '' : 'none'; if (!show) return; var main = isMain(), s = secObj(secCur), multi = hasSecs(); var kind = main ? mainKind() : s.kind; connCard.appendChild(E('h3', {}, multi ? 'Подключение · «' + secName(secCur) + '»' : 'Подключение')); if (multi) { var nameIn = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'maxlength': '40', 'placeholder': main ? 'Основная' : 'Секция', 'style': 'flex:1 1 180px; min-width:0; max-width:340px', 'aria-label': 'Название секции' }); nameIn.value = nameDraft !== null ? nameDraft : secName(secCur); var nameBtn = E('button', { 'class': 'cbi-button cbi-button-positive', 'style': nameDraft !== null && nameDraft.trim() !== secName(secCur) ? '' : 'display:none' }, 'Сохранить'); var saveName = function() { var v = nameIn.value.replace(/[|\r\n\t"\\]/g, ' ').trim().slice(0, 40); if (v === secName(secCur)) { nameDraft = null; renderConn(); return; } secAct('sec_name', secCur + '|' + v, 'Название сохранено', function() { nameDraft = null; }); }; nameIn.addEventListener('input', function() { nameDraft = nameIn.value; nameBtn.style.display = nameIn.value.trim() !== secName(secCur) ? '' : 'none'; }); nameIn.addEventListener('keydown', function(ev) { if (ev.key === 'Enter') { ev.preventDefault(); saveName(); } if (ev.key === 'Escape') { nameDraft = null; renderConn(); } }); nameBtn.addEventListener('click', saveName); connCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Название'), E('span', { 'class': 'zm-st-name' }, [ nameIn, nameBtn ]) ])); } if (hasSecs() && !data.stopped) { var thisOff = secOff(secCur); connCard.appendChild(zm.swRow(!thisOff, 'Секция включена', thisOff ? 'Секция выключена: её сервисы и устройства идут напрямую, остальные секции работают. Настройки секции сохранены.' : 'Выключите, чтобы её сервисы временно пошли напрямую, — остальные секции продолжат работать.', function() { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } secOffSet(secCur, !thisOff); }, busy)); if (thisOff) connCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show', 'style': 'margin:0 0 12px' }, 'Секция «' + secName(secCur) + '» выключена — её сервисы идут напрямую.')); } function opt(id, label, fn) { var on = kind === id; return E('div', { 'class': 'zm-seg-item' + (on ? ' zm-active' : ''), 'role': 'radio', 'aria-checked': on ? 'true' : 'false', 'tabindex': '0', 'click': function() { if (busy || on) return; fn(); }, 'keydown': function(ev) { if ((ev.key === 'Enter' || ev.key === ' ') && !busy && !on) { ev.preventDefault(); fn(); } } }, label); } var opts; if (main) opts = [ opt('vpn', 'VPN', function() { if (data.has_sub && data.ext) { act('sub_exit', 'vpn', 'Переключаем на VPN'); return; } if (!data.has_sub && secVpnCount() >= (data.sec_vpn_max || 3)) { zm.toast('VPN уже в ' + secVpnCount() + ' секциях — больше нельзя', 'warning'); return; } connPick = 'vpn'; renderAll(); loadSub(); }), opt('warp', 'WARP', function() { if (data.warp_on) { connPick = null; if (data.exit === 'vpn') act('sub_exit', 'warp', 'Переключаем на ' + warpName()); else renderAll(); return; } connPick = 'warp'; wayOpen = false; warpPick = null; renderAll(); }) ]; else opts = [ opt('vpn', 'VPN', function() { if (secVpnCount(secCur) >= (data.sec_vpn_max || 3)) { zm.toast('VPN уже в ' + secVpnCount(secCur) + ' секциях — больше нельзя: каждая держит свой туннель и память роутера', 'warning'); return; } secAct('sec_kind', secCur + '|vpn', 'Секция «' + secName(secCur) + '» теперь идёт через VPN — добавьте подписку'); }), opt('warp', 'WARP', function() { secAct('sec_kind', secCur + '|warp', data.warp_on ? 'Секция «' + secName(secCur) + '» теперь идёт через WARP' : 'Сохранено — выберите ниже, как подключить WARP'); }), opt('iface', 'Интерфейс AmneziaWG', function() { ifChoose = true; ifSel = null; awgLoad(true); renderConn(); }) ]; if (!main && ifChoose) opts.forEach(function(o, k) { o.classList.toggle('zm-active', k === 2); }); connCard.appendChild(E('div', { 'class': 'zm-row zm-st-mode' }, [ E('span', { 'class': 'zm-label' }, 'Куда идут сервисы'), E('div', { 'class': 'zm-seg' + (opts.length === 3 ? ' zm-seg-3' : '') }, opts) ])); if (!main && ifChoose) { connCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Сервисы секции пойдут в интерфейс с вкладки AmneziaWG — WARP или ваш сервер. Интерфейс остаётся там: там же он чинится автопочинкой, меняется и удаляется.')); ifPicker(connCard, s.kind === 'iface' ? s.iface : '', function(name) { secAct('sec_kind', secCur + '|iface|' + name, 'Секция «' + secName(secCur) + '» теперь идёт через ' + name); }, function() { ifChoose = false; ifSel = null; renderConn(); }); return; } if (kind === 'iface') { var f = s.ifs || {}, af = awgIfs().filter(function(x) { return x.name === s.iface; })[0]; if (!af && !awgData) awgLoad(); connCard.appendChild(row('Интерфейс', E('span', { 'class': 'zm-st-line' }, [ !s.live ? badge('zm-bad', 'не найден') : ifAlive(f) ? badge('zm-ok', 'работает') : f.up ? badge('zm-warn', 'нет связи') : badge('zm-bad', 'не поднят'), E('b', {}, String(s.iface || '—')), E('span', {}, f.warp ? 'WARP' : 'свой сервер'), af && af.endpoint ? E('span', {}, [ zm.secret(af.endpoint) ]) : E([]), f.up ? E('span', {}, f.hs_age !== '' ? 'связь ' + fmtAge(f.hs_age) : 'связи ещё не было') : E([]) ]))); if (!s.live) connCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show' }, 'Интерфейса ' + s.iface + ' больше нет на вкладке AmneziaWG — сервисам секции некуда идти, они идут напрямую. Выберите другой интерфейс или подключение.')); connCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'disabled': busy ? '' : null, 'click': function() { ifChoose = true; ifSel = s.iface; awgLoad(true); renderConn(); } }, 'Сменить интерфейс'), E('a', { 'class': 'cbi-button', 'href': zm.pageHref('awg') }, 'Открыть AmneziaWG') ])); connCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Интерфейс живёт на вкладке AmneziaWG: там он чинится автопочинкой, меняется и удаляется. Steer только направляет в него сервисы секции. Если он упадёт — ' + (data.onfail === 'drop' ? 'они остановятся' : 'они пойдут напрямую') + '.')); } else if (kind === 'vpn') { var vp = main ? null : s.vpn || {}; if (main ? !data.has_sub : !vp.has) connCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Подписки ещё нет — добавьте её ниже. ' + (main ? 'После этого сервисы пойдут через VPN.' : 'У каждой VPN-секции своя подписка и свой выбор сервера.'))); else connCard.appendChild(E('p', { 'class': 'zm-hint' }, (main ? 'Подписка, выбор сервера и проверка задержки — ниже.' : 'У этой секции своя подписка и свой выбор сервера — они ниже и не влияют на другие секции.'))); } if (!main) connCard.appendChild(E('div', { 'class': 'zm-actions zm-st-secdel' }, [ E('button', { 'class': 'cbi-button cbi-button-remove', 'disabled': busy ? '' : null, 'click': function() { var items = [ 'Её выбор сервисов и устройств удалится', 'Эти сервисы пойдут так, как велят остальные секции, или напрямую', 'Остальные секции не изменятся' ]; if (s.kind === 'vpn' && s.vpn && s.vpn.has) items.splice(1, 0, 'Её подписка и выбор сервера удалятся'); zm.dialog({ title: 'Удалить секцию «' + secName(secCur) + '»?', danger: true, okText: 'Удалить секцию', blocks: [ { type: 'list', title: 'Что произойдёт', items: items } ] }).then(function(v) { if (!v) return; var id = secCur; secAct('sec_del', id, 'Секция удалена', function() { secTo('main'); }); }); } }, 'Удалить секцию') ])); } function renderTabs() { tabBar.innerHTML = ''; var tabsOn = !data.blocker && !!data.installed, one = !hasSecs(); tabBar.style.display = tabsOn ? '' : 'none'; tabBar.className = 'zm-tabsplit' + (one ? ' zm-tabs-one' : ''); (one ? [ null ] : [ false, true ]).forEach(function(common) { tabBar.appendChild(E('div', { 'class': 'zm-actions zmw-tabs zm-tabgrp' }, STEER_TABS.filter(function(t) { return common === null || !!t.common === common; }).map(function(t) { return E('button', { 'class': 'cbi-button' + (t.id === tab ? ' cbi-button-positive' : ''), 'click': function() { var was = isCommon(tab); tab = t.id; try { localStorage.setItem('zm.steer.tab', tab); } catch (e) {} renderTabs(); if (was !== isCommon(tab)) renderSecs(); if (tab === 'conn' && subShow()) loadSub(); if (tab === 'dev' && !devList) loadDevs(); } }, t.label); }))); }); Object.keys(panes).forEach(function(k) { panes[k].style.display = tabsOn && k === tab ? '' : 'none'; }); } function placeWarp() { var inConn = warpView() || !warpIdle(); if (inConn) { if (warpCard.parentNode !== panes.conn || warpCard.nextSibling !== wfixCard || wfixCard.nextSibling !== subCard) { panes.conn.insertBefore(warpCard, subCard); panes.conn.insertBefore(wfixCard, subCard); } } else if (warpCard.parentNode !== panes.set) { panes.set.appendChild(warpCard); panes.set.appendChild(wfixCard); } } function renderAll() { secFix(); renderTabs(); renderSecs(); renderConn(); renderSub(); renderMain(); renderSet(); renderDns(); renderLists(); renderCustom(); renderExcl(); renderCheck(); renderRoute(); renderWarp(); renderAuto(); renderWfix(); renderDevs(); placeWarp(); } if (tab === 'dev' && !data.blocker && data.installed) loadDevs(); [ connCard, warpCard, wfixCard, subCard, checkCard, routeCard ].forEach(function(n) { panes.conn.appendChild(n); }); [ listCard, customCard ].forEach(function(n) { panes.svc.appendChild(n); }); [ setCard, autoCard ].forEach(function(n) { panes.set.appendChild(n); }); panes.dev.appendChild(devCard); panes.byp.appendChild(exclCard); [ zm.rebootSlot(), mainCard, hostsWarn, logEl, dnsCard, secCard, tabBar, panes.conn, panes.svc, panes.dev, panes.byp, panes.set ].forEach(function(n) { wrap.appendChild(n); }); renderAll(); if (!data.blocker) { loadCustom(); loadExcl(); if (subShow()) 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 meta|Meta (Threads, Quest, AI)|svc_v2meta.lst,svc_threads.lst,svc_oculus.lst||www.meta.com,www.threads.com|meta.ai,www.oculus.com|| instagram|Instagram|svc_instagram.lst,svc_instagram_extra.lst||www.instagram.com|i.instagram.com,graph.instagram.com,scontent.cdninstagram.com|| facebook|Facebook|svc_facebook.lst,svc_messenger.lst||www.facebook.com|m.facebook.com,www.messenger.com|| whatsapp|WhatsApp|svc_whatsapp.lst,svc_whatsapp_extra.lst||web.whatsapp.com|static.whatsapp.net,mmg.whatsapp.net|| x|X (Twitter)|svc_twitter.lst|twitter_x.lst|x.com|twitter.com,abs.twimg.com,video.twimg.com||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|svc_tiktok_extra.lst|||||tiktok hdrezka|HDRezka||||||hdrezka google_meet|Google Meet||||||google_meet google_ai|Google AI (Gemini)|svc_google_ai_extra.lst|||||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|H.O.D.C.A.||||||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 mkdir -p /usr/share/zm-redbtn/svc cat > '/usr/share/zm-redbtn/svc/instagram_extra.lst' << 'ZM_INSTALLER_EOF' fbcdn.net lookaside.fbsbx.com edge-mqtt.facebook.com mqtt-mini.facebook.com z-p3-chat-e2ee-ig.facebook.com z-p4-chat-e2ee-ig.facebook.com z-p42-chat-e2ee-ig.facebook.com ZM_INSTALLER_EOF cat > '/usr/share/zm-redbtn/svc/whatsapp_extra.lst' << 'ZM_INSTALLER_EOF' whatsapp.biz circlecrewpinkcrowd.com ZM_INSTALLER_EOF cat > '/usr/share/zm-redbtn/svc/google_ai_extra.lst' << 'ZM_INSTALLER_EOF' gemini.google.com robinfrontend-pa.googleapis.com proactivebackend-pa.googleapis.com signaler-pa.clients6.google.com waa-pa.clients6.google.com geminiweb-pa.clients6.google.com geminiweb-pa.clients.google.com gemini.gstatic.com ai.studio assistant-s3-pa.googleapis.com assistantfrontend-pa.googleapis.com taskassist-pa.googleapis.com lamssettings-pa.googleapis.com searchnotifications-pa.googleapis.com cloudaicompanion.googleapis.com cloudcode-pa.googleapis.com daily-cloudcode-pa.googleapis.com aicode.googleapis.com notebooklm-pa.googleapis.com notebooklm.googleapis.com labs.google.com flow.google flow.google.com opal.google opal.google.com ZM_INSTALLER_EOF cat > '/usr/share/zm-redbtn/svc/tiktok_extra.lst' << 'ZM_INSTALLER_EOF' ibyteimg.com byteimg.com byteeffecttos.com byteeffecttos-g.com bytegecko.com bytegecko-i18n.com byteoversea.net ipstatp.com sgpstatp.com isnssdk.com bytedapm.com ttlivecdn.com tiktokrow-cdn.com tiktokeu-cdn.com ttcdn-us.com tiktok-row.net ttoverseaus.net tiktokw.eu tiktok-minis.com tiktokminis.us tiktokglobalshopv.com ZM_INSTALLER_EOF rm -f /usr/share/zm-redbtn/svc/geoblock_extra.lst /opt/zapret-manager-luci/lists/svc_geoblock_extra.lst chmod 0644 /usr/share/zm-redbtn/svc/instagram_extra.lst /usr/share/zm-redbtn/svc/whatsapp_extra.lst /usr/share/zm-redbtn/svc/google_ai_extra.lst /usr/share/zm-redbtn/svc/tiktok_extra.lst cat > '/www/luci-static/resources/view/zapret-manager/hosts.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require view'; 'require zapret-manager.common as zm'; var E = (function(raw) { return function() { var a = Array.prototype.slice.call(arguments), i = a.length - 1; if (i >= 1 && (typeof a[i] === 'string' || typeof a[i] === 'number')) a[i] = [ String(a[i]) ]; return raw.apply(null, a); }; })(window.E); var LABELS = { nalog: [ 'nalog.ru', 'Налоговая' ], ntc: [ 'ntc.party', 'Форум про обход блокировок' ], instagram: [ 'Instagram', 'Instagram и Facebook' ], librusec: [ 'lib.rus.ec', 'Библиотека' ], ai_openai: [ 'ChatGPT', 'OpenAI, Sora' ], ai_claude: [ 'Claude', 'Anthropic' ], ai_gemini: [ 'Gemini', 'Google' ], ai_aistudio: [ 'Google AI Studio', 'AI Studio, NotebookLM, Labs' ], ai_grok: [ 'Grok', 'xAI' ], ai_copilot: [ 'Copilot', 'Microsoft, Bing' ], ai_deepl: [ 'DeepL', 'Переводчик' ], ai_elevenlabs: [ 'ElevenLabs', 'Озвучка' ], ai_windsurf: [ 'Windsurf', 'Codeium' ], ai_trae: [ 'Trae.ai', 'Редактор кода' ], ai_manus: [ 'Manus', 'AI-агент' ], ai_notion: [ 'Notion', 'Заметки' ], twitch: [ 'Twitch', 'Стримы' ], telegram: [ 'Telegram Web', 'Веб-версия' ], spotify: [ 'Spotify', 'Музыка' ], spotifyext: [ 'Spotify extended', 'Расширенный список' ], rutor: [ 'rutor.info', 'Торренты' ], rutracker: [ 'rutracker.org', 'Торренты' ], scell: [ 'Supercell', 'Clash, Brawl Stars' ], roblox: [ 'Картинки Roblox', 'tr.rbxcdn.com — аватары и превью' ], githubraw: [ 'GitHub Raw', 'githubusercontent.com' ], github: [ 'GitHub', 'Код и релизы' ], tapeop: [ 'tapeop.dev', 'Сайт' ], finland: [ 'Финские IP для Discord', 'Голосовые серверы Discord' ] }; function lbl(id) { return (LABELS[id] || [ id ])[0]; } function isAi(id) { return /^ai_/.test(id); } return view.extend({ load: function() { zm.injectCss(); return zm.hostsStatus(); }, render: function(data) { var wrap = E('div', { 'class': 'zm-wrap' }); var grid = E('div'); 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 }); var aiOpen = false; function renderGrid(items) { lastItems = items || []; grid.innerHTML = ''; var sel = pick || curOn(lastItems); function edit(fn) { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } var p = {}; for (var k in sel) if (sel[k]) p[k] = true; fn(p); pick = p; renderGrid(lastItems); } function tile(it) { var l = LABELS[it.id] || [ it.id, '' ]; return zm.svcCard({ name: l[0], key: it.id, on: !!sel[it.id], title: l[1] ? l[0] + ' — ' + l[1] : l[0], click: function() { edit(function(p) { if (p[it.id]) delete p[it.id]; else p[it.id] = true; }); } }); } function aiGroup(ai) { var on = ai.filter(function(it) { return sel[it.id]; }).length, all = on === ai.length; function setAll(v) { edit(function(p) { ai.forEach(function(it) { if (v) p[it.id] = true; else delete p[it.id]; }); }); } var sw = E('div', { 'class': 'zm-switch' + (on ? ' zm-switch-on' : '') + (on && !all ? ' zm-switch-inc' : ''), 'title': all ? 'Снять все' : 'Выбрать все', 'click': function(ev) { ev.stopPropagation(); setAll(!all); } }, [ E('span') ]); return zm.svcGroup({ name: 'AI-сервисы', sub: 'ChatGPT, Claude, Gemini', key: 'ai', open: aiOpen, badge: E('span', { 'style': 'display:inline-flex; align-items:center; gap:10px' }, [ E('span', { 'class': 'zm-badge ' + (on ? 'zm-ok' : 'zm-off') }, on + ' из ' + ai.length), sw ]), body: zm.svcGrid(ai.map(tile)), toggle: function(o) { aiOpen = o; } }); } var ai = lastItems.filter(function(it) { return isAi(it.id); }); grid.appendChild(E('h4', { 'style': 'margin:0' }, 'Сервисы')); grid.appendChild(zm.svcGrid(lastItems.filter(function(it) { return !isAi(it.id); }).map(tile))); if (ai.length) { grid.appendChild(E('h4', { 'style': 'margin:18px 0 0' }, 'AI-сервисы')); grid.appendChild(aiGroup(ai)); } var n = changes().length; bar.set(n > 0 || busy, busy, n ? 'Есть несохранённые изменения: ' + n : ''); } function hostsRisk(replace, info) { return zm.warnOnce('warn_hosts', function() { return zm.warnDialog({ info: info, title: 'Адреса из hosts важнее VPN и DoH', lead: 'Запись в hosts привязывает сайт к одному IP-адресу: роутер отдаёт его сразу, не спрашивая DNS. Поэтому:', items: [ [ 'Адрес перестал работать — сервис не откроется', 'даже через VPN, Forkozz, Steer или DoH' ], [ 'Адреса в списках устаревают', 'сегодня работает, завтра может перестать' ] ], tip: { title: 'Сервис не открывается?', text: 'Снимите его галочку на этой странице или нажмите «Восстановить hosts».' }, note: replace ? 'Замена удалит весь текущий hosts — отмеченные сервисы и ваши записи.' : '', noteKind: 'bad', check: 'Понимаю: с неработающим адресом в hosts сервис не откроется даже с VPN или DoH', okText: info ? '' : replace ? 'Заменить hosts' : 'Применить' }); }); } function applyPick() { var list = changes(); if (!list.length || busy) return; if (list.some(function(id) { return !!pick[id]; })) { return hostsRisk(false).then(function(ok) { if (ok) applyNow(list); return ok ? undefined : false; }); } applyNow(list); } function applyNow(list) { if (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); }); syncEditor(); 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); if ((data.items || []).some(function(it) { return it.enabled; }) || (data.geohide && data.geohide !== '') || data.extra) setTimeout(function() { if (document.body.contains(wrap)) hostsRisk(false, true); }, 400); 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' }); var HOSTS_SRC = { ru: 'GeoHide RU', eu: 'GeoHide EU', us: 'GeoHide US', malw: 'dns.malw.link' }; function renderGeoGrid() { geoGrid.innerHTML = ''; [ 'ru', 'eu', 'us', 'malw' ].forEach(function(r) { geoGrid.appendChild(E('div', { 'class': 'zm-tile' + (data.geohide === r ? ' zm-active' : ''), 'click': function() { replaceGeohide(r); } }, HOSTS_SRC[r])); }); } renderGeoGrid(); var geoCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Заменить весь hosts'), E('p', { 'class': 'zm-hint' }, 'Внимание: это ПОЛНОСТЬЮ заменит файл /etc/hosts на готовый список (GeoHide DNS или dns.malw.link) — все блоки выше и любые ваши собственные записи будут удалены.'), 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, dns.malw.link), и всё, что было добавлено вручную.'), E('div', { 'class': 'zm-actions' }, [ resetBtn ]), resetLogEl ]); wrap.appendChild(resetCard); function refreshAll(keepEditor) { zm.hostsStatus().then(function(res) { data = res; renderGrid(res.items); renderGeoGrid(); }); if (!keepEditor) syncEditor(); } function syncEditor() { if (!editorOpen) return; if (hostsBar.dirty()) zm.toast('Файл hosts изменился, а в редакторе остались несохранённые правки. Нажмите «Перечитать» — иначе при сохранении новые строки пропадут', 'warning'); else refreshHostsFile(); } function replaceGeohide(region) { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } hostsRisk(true).then(function(ok) { if (ok) replaceNow(region); }); } function replaceNow(region) { if (busy) return; busy = true; zm.toast('Заменяем hosts на ' + HOSTS_SRC[region], '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 заменён на ' + HOSTS_SRC[region] + '.'); zm.toast('hosts заменён на ' + HOSTS_SRC[region], '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(true); 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() { var zmGo = function() { refreshHostsFile(); zm.toast('Содержимое перечитано с диска', 'info'); }; if (hostsBar.dirty()) return zm.ask('Отменить несохранённые изменения и перечитать файл?').then(function(ok) { return ok ? zmGo() : undefined; }); return zmGo(); } }, 'Перечитать файл'), E('button', { 'class': 'cbi-button', 'click': function() { var zmGo = function() { editorOpen = false; hostsBar.reset(); renderEditorCard(); }; if (hostsBar.dirty()) return zm.ask('Есть несохранённые изменения. Свернуть без сохранения?').then(function(ok) { return ok ? zmGo() : undefined; }); return zmGo(); } }, 'Свернуть') ])); editorCard.appendChild(hostsBar); } renderEditorCard(); wrap.appendChild(editorCard); return wrap; } }); ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/hosts.js' cat > '/www/luci-static/resources/view/zapret-manager/adblock.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require view'; 'require zapret-manager.common as zm'; var E = (function(raw) { return function() { var a = Array.prototype.slice.call(arguments), i = a.length - 1; if (i >= 1 && (typeof a[i] === 'string' || typeof a[i] === 'number')) a[i] = [ String(a[i]) ]; return raw.apply(null, a); }; })(window.E); var LISTS = [ { id: 'light', name: 'Light', approx: 55, lvl: 1, text: 'Только самая явная реклама и трекеры. Почти ничего не ломает — всё работает как раньше, просто чище.' }, { id: 'multi', name: 'Multi', approx: 159, lvl: 2, text: 'Обычная защита: реклама, трекеры, счётчики, телеметрия и известные вредоносные сайты.' }, { id: 'pro', name: 'Pro', approx: 195, lvl: 3, rec: true, text: 'Заметно строже Multi, а сайты почти не ломаются. Лучший выбор для большинства.' }, { id: 'proplus', name: 'Pro++', approx: 208, lvl: 4, text: 'Ещё строже. Иногда перестают открываться ссылки из рекламы и редкие сервисы — их можно вернуть белым списком.' }, { id: 'ultimate', name: 'Ultimate', approx: 231, lvl: 5, text: 'Максимум. Понадобится белый список: часть сайтов и приложений может работать не полностью.' } ]; var LIST_BY_ID = {}; LISTS.forEach(function(l) { LIST_BY_ID[l.id] = l; }); function num(n) { return String(Math.round(+n || 0)).replace(/\B(?=(\d{3})+(?!\d))/g, ' '); } function doms(n) { var a = Math.abs(Math.round(+n || 0)), t = a % 100, o = a % 10; return num(n) + ' ' + (t >= 11 && t <= 14 ? 'доменов' : o === 1 ? 'домен' : o >= 2 && o <= 4 ? 'домена' : 'доменов'); } function badge(cls, text) { return E('span', { 'class': 'zm-badge ' + cls }, [ E('span', { 'class': 'zm-dot' }), text ]); } function two(n) { return (n < 10 ? '0' : '') + n; } function when(ts, now) { if (!ts) return '—'; var d = new Date(ts * 1000), n = new Date((now || ts) * 1000); var t = two(d.getHours()) + ':' + two(d.getMinutes()); var day = function(x) { return new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime(); }; var diff = Math.round((day(n) - day(d)) / 86400000); if (diff === 0) return 'сегодня в ' + t; if (diff === 1) return 'вчера в ' + t; return two(d.getDate()) + '.' + two(d.getMonth() + 1) + ' в ' + t; } return view.extend({ load: function() { zm.injectCss(); return zm.adblockStatus().catch(function() { return { error: 'роутер не ответил' }; }); }, render: function(data) { data = data || {}; var wrap = E('div', { 'class': 'zm-wrap' }); var heroCard = E('div', { 'class': 'zm-card zm-ab-hero' }); var levelCard = E('div', { 'class': 'zm-card' }); var allowCard = E('div', { 'class': 'zm-card' }); var blockCard = E('div', { 'class': 'zm-card' }); var checkCard = E('div', { 'class': 'zm-card' }); var logEl = E('pre', { 'class': 'zm-log' }); var busy = false, pick = null; function cur() { return { list: LIST_BY_ID[data.list] ? data.list : 'pro', tif: !!data.tif, doh: !!data.doh }; } function sel() { return pick || cur(); } function changed() { if (!pick || !data.on) return []; var c = cur(), out = []; if (pick.list !== c.list) out.push('список ' + LIST_BY_ID[c.list].name + ' → ' + LIST_BY_ID[pick.list].name); if (pick.tif !== c.tif) out.push(pick.tif ? 'защита от фишинга включается' : 'защита от фишинга выключается'); if (pick.doh !== c.doh) out.push(pick.doh ? 'запрет чужих DoH включается' : 'запрет чужих DoH выключается'); return out; } function modeStr(s) { return 'list=' + s.list + ' tif=' + (s.tif ? 1 : 0) + ' doh=' + (s.doh ? 1 : 0) + ' auto=' + (data.auto === false ? 0 : 1); } function edit(fn) { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } var s = sel(), p = { list: s.list, tif: s.tif, doh: s.doh }; fn(p); pick = p; var c = cur(); if (data.on && p.list === c.list && p.tif === c.tif && p.doh === c.doh) pick = null; renderHero(); renderLevel(); } var bar = zm.saveBar({ saveLabel: 'Применить', busyHint: 'Ход работы — в окне вывода выше', onCancel: function() { pick = null; renderLevel(); }, onSave: function() { var s = sel(); return job('apply', modeStr(s), 'Применяем новый список — ход работы виден в окне вывода'); } }); function refresh() { return zm.adblockStatus().then(function(d) { data = d || {}; if (!busy && data.job) { follow(); return; } if (data.on) pick = changed().length ? pick : null; renderAll(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } function follow() { busy = true; renderAll(); zm.pollJob('adblock', logEl, function(ok) { busy = false; if (ok) pick = null; zm.toast(ok ? 'Готово — реклама блокируется для всех устройств в сети' : 'Не получилось — подробности в окне вывода', ok ? 'info' : 'error'); refresh(); }); } function job(action, mode, text) { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return false; } busy = true; renderAll(); zm.adblockAction(action, mode || '').then(function(res) { if (res.error) { busy = false; renderAll(); zm.toast(res.error, 'error'); return; } zm.toast(text, 'warning'); follow(); }).catch(function() { busy = false; renderAll(); zm.toast('Роутер не ответил', 'error'); }); } function adWarn(info) { return zm.warnDialog({ info: info, title: 'Блокировка может сломать часть сервисов', lead: (info ? 'Блокировка включена: роутер не пускает' : 'Роутер перестанет пускать') + ' рекламные и следящие домены — сразу для всех устройств. Чаще всего страдают:', items: [ [ 'Игры', 'вход, награды за рекламу, магазин' ], [ 'Smart TV и приставки', 'приложения, обновления, кинотеатры' ], [ 'Кэшбэк и бонусы', 'переходы по ссылкам и рекламе' ], [ 'Некоторые сайты', 'вход через соцсети, встроенные видео' ] ], tip: { title: 'Что-то сломалось?', text: 'Разблокируйте домен в статистике, добавьте устройство в «Устройства без блокировки» или выключите блокировку.' }, check: 'Понимаю: часть игр и сервисов может работать неправильно', okText: info ? '' : 'Включить блокировку' }); } function turnOn() { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } var s0 = sel(); zm.warnOnce('warn_adblock', function() { return adWarn(false); }).then(function(ok) { if (ok) job('apply', modeStr(s0), 'Скачиваем списки и включаем блокировку — ход работы виден в окне вывода'); }); } function turnOff() { zm.dialog({ title: 'Выключить блокировку рекламы?', danger: true, okText: 'Выключить', blocks: [ { type: 'list', title: 'Что произойдёт', items: [ 'Реклама и трекеры снова будут загружаться на всех устройствах', 'Скачанные списки и статистика удалятся с роутера; белый список и настройки останутся', 'dnsmasq перезапустится — на пару секунд пропадут ответы DNS' ] } ] }).then(function(v) { if (!v) return; busy = true; renderAll(); zm.adblockAction('off', '').then(function(res) { busy = false; if (res.error) zm.toast(res.error, 'error'); else zm.toast('Блокировка рекламы выключена', 'info'); pick = null; refresh(); }).catch(function() { busy = false; zm.toast('Роутер не ответил', 'error'); refresh(); }); }); } function btn(cls, text, fn) { return E('button', { 'class': 'cbi-button ' + cls, 'disabled': busy ? '' : null, 'click': fn }, text); } function listTitle(s) { return LIST_BY_ID[s.list].name + (s.tif ? ' + защита от фишинга' : '') + (s.doh ? ' + запрет чужих DoH' : ''); } var stats = null, statsPer = 'all', statsBusy = false, statsLoading = false, statsTimer = 0; try { var sp = localStorage.getItem('zm.ab.statsPer'); if (sp === '1' || sp === '24' || sp === 'all') statsPer = sp === 'all' ? 'all' : +sp; } catch (e) {} function loadStats() { if (!data.on || !data.stats || statsLoading) return; statsLoading = true; zm.adblockAction('stats', String(statsPer)).then(function(r) { statsLoading = false; if (r && r.on) stats = r; else if (r && r.on === false) { data.stats = false; stats = null; } renderHero(); }).catch(function() { statsLoading = false; }); } function statsTick() { clearInterval(statsTimer); statsTimer = setInterval(function() { if (!document.body.contains(heroCard)) { clearInterval(statsTimer); return; } if (!document.hidden && data.on && data.stats && !busy) loadStats(); }, 60000); } function statsSet(on) { if (statsBusy || busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } statsBusy = true; renderHero(); zm.adblockAction('stats_set', on ? 'on' : 'off').then(function(r) { statsBusy = false; if (r && r.error) { zm.toast(r.error, 'error'); renderHero(); return; } data.stats = !!(r && r.on); stats = data.stats ? r : null; statsPer = 24; zm.toast(on ? 'Статистика включена — первые цифры появятся через пару минут' : 'Статистика выключена, её данные удалены', 'info'); renderHero(); if (on) statsTick(); }).catch(function() { statsBusy = false; zm.toast('Роутер не ответил', 'error'); renderHero(); }); } function pct(b, t) { if (!t) return '0'; var v = b * 100 / t; return (v >= 10 ? v.toFixed(0) : v.toFixed(1)).replace('.', ','); } function statsBlock() { var box = E('div', { 'class': 'zm-ab-stats' }); if (!data.stats) { box.appendChild(E('div', { 'class': 'zm-ab-stats-off' }, [ E('div', {}, [ E('b', {}, 'Статистика запросов'), E('p', { 'class': 'zm-hint', 'style': 'margin:4px 0 0' }, 'Сколько DNS-запросов было, сколько из них заблокировано, сколько устройств в сети и что режется чаще всего. Журнал запросов хранится в памяти роутера (не на флеше), панель сворачивает его в итоги раз в 2 минуты.') ]), E('button', { 'class': 'cbi-button cbi-button-action', 'disabled': statsBusy || busy ? '' : null, 'click': function() { statsSet(true); } }, statsBusy ? 'Включаем…' : 'Включить статистику') ])); return box; } var st = stats || { total: 0, blocked: 0, clients: 0, top: [], devs: [] }; var relayBy = st.via ? 'через ' + st.via : 'через роутер'; var seg = E('div', { 'class': 'zm-seg zm-ab-stats-seg', 'role': 'radiogroup', 'aria-label': 'Период' }); [ [ 1, 'За час' ], [ 24, 'За сутки' ], [ 'all', 'За всё время' ] ].forEach(function(o) { var on = statsPer === o[0]; var pick = function() { if (on) return; statsPer = o[0]; stats = null; try { localStorage.setItem('zm.ab.statsPer', String(o[0])); } catch (e) {} loadStats(); renderHero(); }; seg.appendChild(E('div', { 'class': 'zm-seg-item' + (on ? ' zm-active' : ''), 'role': 'radio', 'tabindex': '0', 'aria-checked': on ? 'true' : 'false', 'click': pick, 'keydown': function(ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); pick(); } } }, o[1])); }); var since = statsPer === 'all' ? (st.since ? 'считается с ' + when(st.since, data.now) : 'с момента включения') : st.since && (data.now - st.since) < statsPer * 3600 ? 'считается с ' + when(st.since, data.now) : (statsPer === 1 ? 'последний час' : 'последние 24 часа'); box.appendChild(E('div', { 'class': 'zm-ab-stats-head' }, [ E('b', {}, 'Статистика'), seg, E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, stats ? since : 'загружаем…') ])); function tile(cls, label, value, sub) { return E('div', { 'class': 'zm-ab-tile zm-ab-tile-' + cls }, [ E('div', { 'class': 'zm-ab-tile-body' }, [ E('span', { 'class': 'zm-ab-tile-label' }, label), E('b', { 'class': 'zm-ab-tile-num' }, stats ? value : '—'), E('span', { 'class': 'zm-ab-tile-sub' }, sub) ]) ]); } box.appendChild(E('div', { 'class': 'zm-ab-tiles' }, [ tile('q', 'DNS-запросы', num(st.total), statsPer === 1 ? 'за последний час' : statsPer === 'all' ? 'за всё время' : 'за сутки'), tile('b', 'Заблокировано', num(st.blocked), 'реклама и трекеры'), tile('p', 'Процент блокировки', pct(st.blocked, st.total) + '%', 'от всех запросов'), st.relay && !st.clients ? tile('d', 'Устройства', 'вся сеть', 'DNS идёт ' + relayBy) : tile('d', 'Устройства', num(st.clients), st.relay ? 'и остальные — ' + relayBy : 'спрашивали DNS') ])); if (stats && (st.top.length || st.devs.length)) { var topMax = st.top.length ? st.top[0].n : 1; var topList = E('div', { 'class': 'zm-ab-rank' }, [ E('h4', {}, 'Чаще всего блокируется') ]); if (!st.top.length) topList.appendChild(E('p', { 'class': 'zm-hint' }, 'Пока ничего не заблокировано.')); st.top.forEach(function(t) { topList.appendChild(E('div', { 'class': 'zm-ab-rank-row' }, [ E('div', { 'class': 'zm-ab-rank-main' }, [ E('div', { 'class': 'zm-ab-rank-line' }, [ E('span', { 'class': 'zm-ab-rank-name', 'title': t.d }, t.d), E('span', { 'class': 'zm-ab-rank-n' }, num(t.n)) ]), E('span', { 'class': 'zm-ab-bar' }, [ E('i', { 'style': 'width:' + Math.max(4, Math.round(t.n * 100 / topMax)) + '%' }) ]) ]), E('button', { 'class': 'cbi-button zm-ab-rank-btn', 'title': 'Добавить ' + t.d + ' в белый список', 'click': function() { allowAdd(t.d); } }, 'Разблокировать') ])); }); var devList = E('div', { 'class': 'zm-ab-rank' }, [ E('h4', {}, 'Кто спрашивает больше всего') ]); st.devs.forEach(function(d) { var v6 = /:/.test(d.ip || ''), who = d.relay ? (st.clients ? 'Остальные устройства' : 'Вся сеть') + ' · ' + relayBy : (d.name || 'Устройство без имени') + (d.ip && !v6 ? ' · ' + d.ip : ''); devList.appendChild(E('div', { 'class': 'zm-ab-rank-row' }, [ E('div', { 'class': 'zm-ab-rank-main' }, [ E('div', { 'class': 'zm-ab-rank-line' }, [ E('span', { 'class': 'zm-ab-rank-name', 'title': d.relay ? 'Устройства спрашивают DNS ' + relayBy + ' — роутер видит их запросы вместе' : [ d.name, d.ip ].filter(Boolean).join(' · ') }, who), E('span', { 'class': 'zm-ab-rank-n' }, num(d.q)) ]), E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, (!d.name && v6 ? 'в сети по IPv6 · ' : '') + 'заблокировано ' + num(d.b) + ' (' + pct(d.b, d.q) + '%)') ]) ])); }); box.appendChild(E('div', { 'class': 'zm-ab-ranks' }, [ topList, devList ])); } else if (stats) { box.appendChild(E('p', { 'class': 'zm-hint' }, 'Запросов пока нет — первые цифры появятся через пару минут после включения.')); } box.appendChild(E('div', { 'class': 'zm-ab-stats-foot' }, [ E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, st.relay ? (st.clients ? 'DNS части устройств' : 'DNS устройств') + ' проходит ' + relayBy + ', поэтому роутер видит их запросы вместе, а не по отдельности. Блокировка от этого не зависит и работает для всех. Устройства из «Без блокировки» в статистику не входят.' : 'Устройства из «Без блокировки» и служебные запросы самого роутера в статистику не входят.'), E('button', { 'class': 'cbi-button', 'disabled': statsBusy || busy ? '' : null, 'click': function() { statsSet(false); } }, statsBusy ? 'Выключаем…' : 'Выключить статистику') ])); return box; } function renderHero() { heroCard.innerHTML = ''; var st = busy || data.job ? badge('zm-warn', data.on ? 'обновляется…' : 'включается…') : data.on && data.applied ? badge('zm-ok', 'работает') : data.on ? badge('zm-bad', 'список не подключён') : badge('zm-off', 'выключена'); heroCard.appendChild(E('div', { 'class': 'zm-ab-head' }, [ E('h3', {}, 'Блокировка рекламы'), st ])); heroCard.appendChild(E('p', { 'class': 'zm-hint zm-ab-lead' }, 'Реклама, трекеры и счётчики отсекаются прямо в DNS роутера. Списки HaGeZi обновляются сами каждый день.')); if (data.error) { heroCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show zm-block-banner' }, [ E('span', {}, 'Не удалось узнать состояние: ' + data.error) ])); return; } if (!data.on) { heroCard.appendChild(E('div', { 'class': 'zm-ab-chips' }, [ 'Реклама в сайтах и приложениях', 'Трекеры и счётчики', 'Телеметрия', 'Фишинг и вирусы — по желанию' ].map(function(t, i) { return E('span', { 'class': 'zm-chip' + (i < 3 ? ' zm-chip-ok' : '') }, [ E('span', { 'class': 'zm-dot' }), t ]); }))); heroCard.appendChild(E('div', { 'class': 'zm-actions zm-ab-actions' }, [ btn('cbi-button-positive', 'Включить: ' + listTitle(sel()), turnOn), E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Уровень строгости можно выбрать ниже') ])); return; } if (data.err && data.errat >= (data.updated || 0)) { heroCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show zm-block-banner' }, [ E('span', {}, 'Последнее обновление не удалось (' + when(data.errat, data.now) + '): ' + data.err + '. ' + (data.applied ? 'Пока работает прежний список.' : 'Нажмите «Обновить сейчас».')) ])); } else if (!data.applied && !busy && !data.job) { heroCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show zm-block-banner' }, [ E('span', {}, 'Список сейчас не подключён к dnsmasq — так бывает сразу после перезагрузки роутера. Через пару минут панель подключит его сама, или нажмите «Обновить сейчас».') ])); } var c = cur(); heroCard.appendChild(E('div', { 'class': 'zm-sub-facts zm-ab-facts' }, [ E('div', { 'class': 'zm-sub-fact' }, [ E('span', {}, 'Список'), E('b', {}, listTitle(c)) ]), E('div', { 'class': 'zm-sub-fact' }, [ E('span', {}, 'Доменов в блокировке'), E('b', {}, data.count ? num(data.count) : '—') ]), E('div', { 'class': 'zm-sub-fact' }, [ E('span', {}, 'Обновлено'), E('b', {}, when(data.updated, data.now)) ]), E('div', { 'class': 'zm-sub-fact' }, [ E('span', {}, 'Автообновление'), E('b', {}, data.auto ? 'каждый день в 04:' + two(+data.minute || 0) : 'выключено') ]) ])); if (data.applied) { heroCard.appendChild(statsBlock()); if (data.stats && !stats && !statsLoading) loadStats(); } heroCard.appendChild(E('div', { 'class': 'zm-actions zm-ab-actions' }, [ btn('cbi-button-action', busy ? 'Обновляем…' : 'Обновить сейчас', function() { job('update', '', 'Скачиваем свежие списки — ход работы виден в окне вывода'); }), btn('cbi-button-remove', 'Выключить', turnOff) ])); } function pips(n) { var out = []; for (var i = 1; i <= 5; i++) out.push(E('i', { 'class': i <= n ? 'zm-on' : '' })); return E('span', { 'class': 'zm-ab-pips', 'title': 'Строгость: ' + n + ' из 5', 'aria-label': 'Строгость: ' + n + ' из 5' }, out); } function renderLevel() { levelCard.innerHTML = ''; var s = sel(), l = LIST_BY_ID[s.list], src = data.src || {}; levelCard.appendChild(E('h3', {}, 'Насколько строго')); levelCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Чем строже список, тем больше рекламы и слежки он режет — и тем чаще может понадобиться белый список.')); var seg = E('div', { 'class': 'zm-seg zm-ab-seg', 'role': 'radiogroup', 'aria-label': 'Список HaGeZi' }); LISTS.forEach(function(it) { var on = it.id === s.list; var choose = function() { if (!on) edit(function(p) { p.list = it.id; }); }; seg.appendChild(E('div', { 'class': 'zm-seg-item' + (on ? ' zm-active' : ''), 'role': 'radio', 'tabindex': '0', 'aria-checked': on ? 'true' : 'false', 'click': choose, 'keydown': function(ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); choose(); } } }, it.name)); }); levelCard.appendChild(seg); var n = src[l.id] && src[l.id].n ? doms(src[l.id].n) : '≈ ' + l.approx + ' тыс. доменов'; levelCard.appendChild(E('div', { 'class': 'zm-ab-desc' }, [ E('div', { 'class': 'zm-ab-desc-head' }, [ E('b', {}, l.name), pips(l.lvl), l.rec ? badge('zm-ok', 'рекомендуем') : E([]), E('span', { 'class': 'zm-ab-desc-n' }, n) ]), E('div', { 'class': 'zm-hint' }, l.text) ])); var tifN = src.tif && src.tif.n ? doms(src.tif.n) : '≈ 240 тыс. доменов'; levelCard.appendChild(zm.swRow(s.tif, 'Защита от фишинга и вирусов', 'Добавляет список TIF mini: ещё ' + tifN + ' мошенников, фишинга и вредоносных сайтов. Нужна свободная память роутера.', function() { edit(function(p) { p.tif = !p.tif; }); }, busy)); levelCard.appendChild(zm.swRow(s.doh, 'Не давать устройствам обходить блокировку', E('span', {}, [ 'Закрывает чужие серверы зашифрованного DNS (DoH/DoT), чтобы браузеры и приложения не спрашивали адреса в обход роутера. Серверы самой панели (Forkozz, DNS over HTTPS) и TG WS Proxy остаются открытыми — они нужны роутеру. ', E('span', { 'class': 'zm-ab-warn' }, 'Если на телефоне вручную задан «Частный DNS» (например, dns.google), выключите его — иначе на телефоне пропадёт интернет.') ]), function() { edit(function(p) { p.doh = !p.doh; }); }, busy)); var autoOn = data.auto !== false; levelCard.appendChild(zm.swRow(autoOn, 'Обновлять списки каждый день', (data.minute !== '' && data.minute != null ? 'В 04:' + two(+data.minute || 0) + ' по времени роутера. ' : 'Ночью, по времени роутера. ') + 'После перезагрузки роутера список подключается сам.', function() { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } zm.adblockAction('auto', autoOn ? '0' : '1').then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return; } data.auto = !!res.auto; data.minute = res.minute; zm.toast(res.auto ? 'Списки будут обновляться каждый день' : 'Автообновление выключено', 'info'); renderHero(); renderLevel(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); }, busy)); var ch = changed(); levelCard.appendChild(bar); bar.set(ch.length > 0, busy && ch.length > 0, ch.length ? 'Изменения: ' + ch.join(', ') : ''); } var allowTa = E('textarea', { 'class': 'zm-config-editor zm-ab-editor', 'spellcheck': 'false', 'autocapitalize': 'off', 'rows': '9', 'aria-label': 'Белый список' }); allowTa.value = data.allow || ''; var allowBar = zm.editorBar(allowTa, { saveLabel: 'Сохранить', onSave: function(v) { zm.toast(data.on && data.applied ? 'Сохраняем и пересобираем список' : 'Сохраняем белый список', 'warning'); return zm.adblockAction('allow_set', v).then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return false; } zm.toast('Белый список сохранён' + (res.count ? ' — в блокировке ' + doms(res.count) : ''), 'info'); if (res.count) data.count = res.count; data.allow = v; renderHero(); return true; }).catch(function() { zm.toast('Роутер не ответил', 'error'); return false; }); } }); function allowAdd(dom) { if (allowBar.dirty()) { zm.toast('Сначала сохраните или отмените правки белого списка', 'warning'); return; } var lines = allowTa.value.split('\n').map(function(x) { return x.trim().toLowerCase(); }); if (lines.indexOf(dom) >= 0) { zm.toast(dom + ' уже в белом списке', 'info'); return; } var v = allowTa.value.replace(/\s+$/, '') + (allowTa.value.trim() ? '\n' : '') + dom + '\n'; zm.toast('Добавляем ' + dom + ' в белый список', 'warning'); zm.adblockAction('allow_set', v).then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return; } allowBar.reset(v); data.allow = v; if (res.count) data.count = res.count; zm.toast(dom + ' больше не блокируется', 'info'); renderHero(); runCheck(dom); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } var blockTa = E('textarea', { 'class': 'zm-config-editor zm-ab-editor', 'spellcheck': 'false', 'autocapitalize': 'off', 'rows': '9', 'aria-label': 'Чёрный список', 'placeholder': 'например:\nads.example.com\nupdate.example.net' }); blockTa.value = data.block || ''; function blockCount(v) { return String(v || '').split('\n').map(function(x) { return x.replace(/#.*/, '').trim(); }).filter(function(x) { return /^[a-z0-9_*.-]+\.[a-z0-9_-]+$/i.test(x.replace(/^[a-z]+:\/\//i, '').replace(/[\/:?].*$/, '')); }).length; } function blockSave(v, okText) { zm.toast(data.on && data.applied ? 'Сохраняем и пересобираем список' : 'Сохраняем чёрный список', 'warning'); return zm.adblockAction('block_set', v).then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return false; } zm.toast((okText || 'Чёрный список сохранён') + (res.count ? ' — в блокировке ' + doms(res.count) : ''), 'info'); if (res.count) data.count = res.count; data.block = v; renderHero(); renderBlock(); return true; }).catch(function() { zm.toast('Роутер не ответил', 'error'); return false; }); } var blockBar = zm.editorBar(blockTa, { saveLabel: 'Сохранить', onSave: function(v) { return blockSave(v); } }); function blockRemove(dom) { if (blockBar.dirty()) { zm.toast('Сначала сохраните или отмените правки чёрного списка', 'warning'); return; } var v = blockTa.value.split('\n').filter(function(x) { return x.replace(/#.*/, '').trim().toLowerCase().replace(/^\*\./, '').replace(/^\./, '') !== dom; }).join('\n'); blockSave(v, dom + ' убран из чёрного списка').then(function(ok) { if (ok) { blockBar.reset(v); runCheck(dom); } }); } function renderBlock() { blockCard.innerHTML = ''; var n = blockCount(data.block); blockCard.appendChild(E('h3', {}, [ 'Чёрный список ', n ? E('span', { 'class': 'zm-badge zm-ok' }, [ E('span', { 'class': 'zm-dot' }), doms(n) ]) : E('span', { 'class': 'zm-badge zm-off' }, [ E('span', { 'class': 'zm-dot' }), 'пусто' ]) ])); blockCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Эти домены блокируются всегда — вместе со всеми поддоменами, даже если их нет в выбранном списке. Пригодится, например, чтобы приставка или телевизор не скачивали обновления. По одному в строке, после # — комментарий.')); blockCard.appendChild(blockTa); if (!data.on) blockCard.appendChild(E('p', { 'class': 'zm-hint zm-ab-warn' }, 'Сейчас блокировка рекламы выключена — чёрный список заработает, когда вы её включите.')); blockCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Если домен есть и в белом, и в чёрном списке — он блокируется. Поддомен из белого списка (store.example.com) при этом открывается, даже если в чёрном весь example.com.')); blockCard.appendChild(blockBar); } function renderAllow() { allowCard.innerHTML = ''; allowCard.appendChild(E('h3', {}, 'Белый список')); allowCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Эти домены не блокируются никогда — вместе со всеми поддоменами. По одному в строке, после # — комментарий.')); allowCard.appendChild(allowTa); allowCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'click': function() { if (allowTa.value === (data.allow_default || '')) { zm.toast('Уже стоит список по умолчанию', 'info'); return; } allowTa.value = data.allow_default || ''; allowTa.dispatchEvent(new Event('input')); zm.toast('Подставлен список по умолчанию — нажмите «Сохранить»', 'info'); } }, 'Вернуть по умолчанию') ])); allowCard.appendChild(E('p', { 'class': 'zm-hint' }, 'По умолчанию здесь ссылки, без которых не открываются переходы из поисковой рекламы, писем и кешбэк-сервисов.')); allowCard.appendChild(allowBar); allowCard.appendChild(exBox); renderExempt(); } var exBox = E('div', { 'class': 'zm-ab-auto' }), exData = null, exOpen = false, exLoading = false; var EX_GROUPS = [ { id: 'dns', title: 'Серверы DNS', sub: 'DoH и DoT из вкладки DNS over HTTPS и Forkozz, плюс выбранные сейчас — в том числе свои' }, { id: 'tg', title: 'TG WS Proxy', sub: 'Telegram и домены прокси Cloudflare — обновляются вместе со списками блокировки' }, { id: 'never', title: 'Проверка интернета и точное время', sub: 'без них телевизоры и телефоны решают, что интернета нет' } ]; function loadExempt() { if (exLoading) return; exLoading = true; zm.adblockAction('exempt', '').then(function(r) { exLoading = false; if (r && !r.error) exData = r; renderExempt(); }).catch(function() { exLoading = false; renderExempt(); }); } function renderExempt() { exBox.innerHTML = ''; exBox.appendChild(E('div', { 'class': 'zm-ab-auto-title' }, 'Ещё всегда в исключениях — добавляет сама панель')); exBox.appendChild(E('div', { 'class': 'zm-ab-auto-sub' }, 'Эти домены вписывать не нужно: без них перестанут работать DNS, Telegram через прокси и проверка интернета на устройствах. Убрать их нельзя.')); var own = {}; ((exData && exData.dns_own) || []).forEach(function(d) { own[d] = true; }); exBox.appendChild(E('div', { 'class': 'zm-ab-auto-head' }, [ E('div', { 'class': 'zm-ab-auto-chips' }, EX_GROUPS.map(function(g) { var n = exData && exData[g.id] ? exData[g.id].length : null; return E('span', { 'class': 'zm-ab-chip' }, [ g.title + ' ', E('b', {}, n == null ? '…' : String(n)) ]); })), exData ? E('button', { 'class': 'cbi-button', 'click': function() { exOpen = !exOpen; renderExempt(); } }, exOpen ? 'Скрыть домены' : 'Показать домены') : E([]) ])); if (!exData || !exOpen) return; EX_GROUPS.forEach(function(g) { var list = exData[g.id] || []; if (!list.length) return; exBox.appendChild(E('div', { 'class': 'zm-ab-grp' }, [ E('div', { 'class': 'zm-ab-grp-h' }, [ g.title + ' · ' + list.length + ' ', E('span', {}, '— ' + g.sub) ]), E('div', { 'class': 'zm-ab-doms' }, list.map(function(d) { return E('span', own[d] ? { 'class': 'zm-ab-own', 'title': 'Выбран сейчас (Forkozz или DNS роутера)' } : {}, d); })) ])); }); } loadExempt(); var freeCard = E('div', { 'class': 'zm-card' }); var freeInfo = null, freeLoading = false, freeErr = '', freePick = null; var FREE_MAC = /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/; function devWord(n) { var a = n % 10, b = n % 100; return n + ' ' + (a === 1 && b !== 11 ? 'устройство' : a >= 2 && a <= 4 && (b < 12 || b > 14) ? 'устройства' : 'устройств'); } function freeSaved() { var m = {}; ((freeInfo && freeInfo.free) || []).forEach(function(x) { m[x.mac] = x.name || ''; }); return m; } function freeSig(m) { return Object.keys(m).sort().join(','); } function loadFree() { if (freeLoading) return; freeLoading = true; freeErr = ''; renderFree(); zm.adblockAction('free_get', '').then(function(r) { freeLoading = false; if (r && !r.error) freeInfo = r; else freeErr = (r && r.error) || 'роутер не ответил'; renderFree(); }).catch(function() { freeLoading = false; freeErr = 'роутер не ответил'; renderFree(); }); } var freeBar = zm.saveBar({ saveLabel: 'Применить', onCancel: function() { freePick = null; renderFree(); }, onSave: function() { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return false; } var cur = freePick || freeSaved(); var lines = Object.keys(cur).map(function(k) { return k + '|' + String(cur[k] || '').replace(/[|\r\n\t"\\]/g, ' ').slice(0, 48); }); return zm.adblockAction('free_set', lines.join('\n')).then(function(r) { if (r && r.error) { zm.toast(r.error, 'error'); return false; } freeInfo = r; freePick = null; renderFree(); zm.toast(lines.length ? 'Для выбранных устройств реклама больше не блокируется' : 'Блокировка снова работает для всех устройств', 'info'); }, function() { zm.toast('Роутер не ответил', 'error'); return false; }); } }); function renderFree() { freeCard.innerHTML = ''; freeCard.style.display = data.on && !data.error ? '' : 'none'; if (freeCard.style.display) { freeBar.set(false); return; } if (!freeInfo && !freeLoading && !freeErr) { loadFree(); return; } var saved = freeSaved(), cur = freePick || saved, nSaved = Object.keys(saved).length; var changed = freeSig(cur) !== freeSig(saved); var head = [ 'Устройства без блокировки ' ]; if (nSaved) head.push(badge(freeInfo && freeInfo.running ? 'zm-ok' : 'zm-warn', devWord(nSaved))); freeCard.appendChild(E('h3', {}, head)); freeCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Для отмеченных устройств реклама не блокируется — например, для ТВ-приставки, если с блокировкой у неё перестали работать IPTV или приложения. Forkozz и Steer для них работают как раньше: YouTube через VPN не пропадёт. Нажмите на устройство, чтобы отметить его.')); if (freeErr) freeCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Не удалось получить список устройств: ' + freeErr)); var rows = [], known = {}; ((freeInfo && freeInfo.devices) || []).forEach(function(d) { if (!d.mac || known[d.mac]) return; known[d.mac] = true; rows.push({ mac: d.mac, ip: d.ip || '', name: d.name || '' }); }); Object.keys(cur).forEach(function(k) { if (!known[k]) { known[k] = true; rows.push({ mac: k, ip: '', name: cur[k] || '', away: true }); } }); function toggle(r) { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } var m = {}; Object.keys(cur).forEach(function(k) { m[k] = cur[k]; }); if (m[r.mac] != null) delete m[r.mac]; else m[r.mac] = r.name || r.ip || ''; freePick = freeSig(m) === freeSig(saved) ? null : m; renderFree(); } var g = E('div', { 'class': 'zm-nodes' }); rows.forEach(function(r) { var on = cur[r.mac] != null; var foot = [ r.ip, r.mac ].filter(Boolean).join(' · ') + (r.away ? ' · сейчас не в сети' : ''); g.appendChild(E('div', { 'class': 'zm-node' + (on ? ' zm-active' : ''), 'role': 'button', 'tabindex': '0', 'aria-pressed': on ? 'true' : 'false', 'click': function() { toggle(r); }, 'keydown': function(ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); toggle(r); } } }, [ E('div', { 'class': 'zm-node-name' }, [ String(r.name || r.ip || r.mac) ]), E('div', { 'class': 'zm-node-foot' }, [ E('span', {}, [ foot ]), E('span', {}, [ on ? 'без блокировки' : '' ]) ]) ])); }); if (!rows.length) g.appendChild(E('p', { 'class': 'zm-hint', 'style': 'grid-column: 1 / -1; margin: 0' }, freeLoading ? 'Ищем устройства…' : 'Устройства не найдены — добавьте MAC-адрес вручную.')); freeCard.appendChild(g); var inp = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'placeholder': 'MAC: aa:bb:cc:dd:ee:ff', 'aria-label': 'MAC-адрес устройства', 'style': 'flex:1; min-width:200px' }); function add() { var v = inp.value.trim().toLowerCase().replace(/-/g, ':'); if (!FREE_MAC.test(v)) { zm.toast('Введите MAC-адрес устройства, например aa:bb:cc:dd:ee:ff', 'warning'); return; } if (cur[v] != null) { zm.toast('Это устройство уже отмечено', 'info'); return; } inp.value = ''; toggle(rows.filter(function(x) { return x.mac === v; })[0] || { mac: v, ip: '', name: '' }); } inp.addEventListener('keydown', function(ev) { if (ev.key === 'Enter') { ev.preventDefault(); add(); } }); freeCard.appendChild(E('div', { 'class': 'zm-actions', 'style': 'margin-top:14px' }, [ inp, E('button', { 'class': 'cbi-button', 'click': add }, 'Добавить'), E('button', { 'class': 'cbi-button', 'disabled': freeLoading ? '' : null, 'click': loadFree }, freeLoading ? 'Ищем…' : 'Обновить список') ])); if (nSaved && freeInfo && !freeInfo.running && !changed) freeCard.appendChild(E('p', { 'class': 'zm-hint zm-ab-warn' }, 'DNS без блокировки сейчас не запущен — эти устройства получают общий DNS. Нажмите «Обновить список»: сторож панели запускает его заново в течение пары минут.')); var add_ = Object.keys(cur).filter(function(k) { return saved[k] == null; }).length, del_ = Object.keys(saved).filter(function(k) { return cur[k] == null; }).length; freeCard.appendChild(freeBar); freeBar.set(changed, false, changed ? 'Изменения: ' + [ add_ ? 'без блокировки — ещё ' + devWord(add_) : '', del_ ? 'снова с блокировкой — ' + devWord(del_) : '' ].filter(Boolean).join(', ') : ''); } var chkInp = E('input', { 'type': 'text', 'class': 'cbi-input-text zm-route-input', 'spellcheck': 'false', 'autocapitalize': 'off', 'autocomplete': 'off', 'inputmode': 'url', 'placeholder': 'например, doubleclick.net', 'aria-label': 'Домен для проверки' }); var chkBtn = E('button', { 'type': 'button', 'class': 'cbi-button cbi-button-action', 'click': function() { runCheck(); } }, 'Проверить'); var chkOut = E('div', { 'class': 'zm-route-out', 'aria-live': 'polite' }); var chkBusy = false; chkInp.addEventListener('keydown', function(ev) { if (ev.key === 'Enter') { ev.preventDefault(); runCheck(); } }); function chkRes(tone, cls, label, what, lines, note) { var box = E('div', { 'class': 'zm-route-res zm-route-' + tone }, [ E('div', { 'class': 'zm-route-head' }, [ badge(cls, label), E('span', { 'class': 'zm-route-what' }, what) ]) ]); (lines || []).forEach(function(l) { box.appendChild(E('div', { 'class': 'zm-route-line' }, [ E('span', { 'class': 'zm-route-k' }, l[0]), E('span', { 'class': 'zm-route-v' }, l[1]) ])); }); if (note) box.appendChild(E('div', { 'class': 'zm-route-note' }, note)); chkOut.innerHTML = ''; chkOut.appendChild(box); } function runCheck(v) { v = String(v != null ? v : chkInp.value).trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/[\/?#:].*$/, '').toLowerCase(); if (chkBusy) return; if (!v) { zm.toast('Введите домен, например doubleclick.net', 'warning'); chkInp.focus(); return; } chkInp.value = v; chkBusy = true; chkBtn.disabled = true; chkBtn.textContent = 'Ищем…'; chkOut.innerHTML = ''; chkOut.appendChild(E('div', { 'class': 'zm-hint zm-route-wait' }, 'Ищем ' + v + ' в списках…')); zm.adblockAction('check', v).then(function(r) { r = r || {}; var d = E('b', {}, r.domain || v); if (r.error) chkRes('bad', 'zm-bad', 'ошибка', r.error); else if (r.state === 'off') chkRes('off', 'zm-off', 'выключено', [ d, ' — блокировка выключена, домен открывается' ]); else if (r.state === 'clean') chkRes('ok', 'zm-ok', 'не блокируется', [ d, ' — его нет в выбранных списках' ], null, E('span', { 'class': 'zm-hint' }, 'Если сайт всё равно не открывается — дело не в блокировке рекламы.')); else if (r.state === 'allowed') chkRes('ok', 'zm-ok', 'разрешён', [ d, ' — есть в списке ' + r.source + ', но стоит в белом списке' ], [ [ 'Правило списка', r.by ], [ 'Белый список', r.allow ] ]); else if (r.mine) chkRes('bad', 'zm-bad', 'блокируется', [ d, ' — есть в вашем чёрном списке' ], [ [ 'Правило', r.by === r.domain ? r.by : r.by + ' и все его поддомены' ] ], E('div', { 'class': 'zm-actions', 'style': 'margin:8px 0 0' }, [ E('button', { 'class': 'cbi-button', 'click': function() { blockRemove(r.by); } }, 'Убрать ' + r.by + ' из чёрного списка') ])); else chkRes('bad', 'zm-bad', 'блокируется', [ d, ' — есть в списке ' + r.source ], [ [ 'Правило', r.by === r.domain ? r.by : r.by + ' и все его поддомены' ] ], E('div', { 'class': 'zm-actions', 'style': 'margin:8px 0 0' }, [ E('button', { 'class': 'cbi-button', 'click': function() { allowAdd(r.by); } }, 'Разблокировать ' + r.by), E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'если из-за этого не работает нужный сайт') ])); }).catch(function() { chkRes('bad', 'zm-bad', 'ошибка', 'роутер не ответил'); }).then(function() { chkBusy = false; chkBtn.disabled = false; chkBtn.textContent = 'Проверить'; }); } function renderCheck() { checkCard.innerHTML = ''; checkCard.appendChild(E('h3', {}, 'Проверить домен')); checkCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Сайт или приложение перестали работать? Проверьте, не режет ли их список, и разблокируйте одной кнопкой.')); var chips = E('div', { 'class': 'zm-route-chips' }); [ 'doubleclick.net', 'mc.yandex.ru', 'an.yandex.ru', 'googlesyndication.com' ].forEach(function(x) { chips.appendChild(E('button', { 'type': 'button', 'class': 'zm-route-chip', 'click': function() { runCheck(x); } }, x)); }); checkCard.appendChild(E('div', { 'class': 'zm-route' }, [ E('div', { 'class': 'zm-route-form' }, [ chkInp, chkBtn ]), chips, chkOut ])); } function renderAll() { renderHero(); renderLevel(); renderFree(); } wrap.appendChild(heroCard); wrap.appendChild(logEl); wrap.appendChild(levelCard); wrap.appendChild(freeCard); wrap.appendChild(E('div', { 'class': 'zm-cards zm-cards-2 zm-ab-pair' }, [ allowCard, blockCard ])); wrap.appendChild(checkCard); renderAll(); renderAllow(); renderBlock(); renderCheck(); if (data.job) follow(); if (data.stats) statsTick(); if (data.on && !data.error) setTimeout(function() { if (document.body.contains(wrap)) zm.warnOnce('warn_adblock', function() { return adWarn(true); }); }, 400); return wrap; } }); ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/adblock.js' cat > '/www/luci-static/resources/view/zapret-manager/mixomo.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require view'; 'require zapret-manager.common as zm'; var E = (function(raw) { return function() { var a = Array.prototype.slice.call(arguments), i = a.length - 1; if (i >= 1 && (typeof a[i] === 'string' || typeof a[i] === 'number')) a[i] = [ String(a[i]) ]; return raw.apply(null, a); }; })(window.E); 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 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 ? 'Стоит ' + (d.fk_name || 'Forkozz') + ' — он сам направляет сервисы в туннель. Чтобы поставить Mixomo, удалите ' + (d.fk_name || '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() { zm.dialog({ title: 'Удалить Mixomo?', danger: true, okText: 'Удалить Mixomo', blocks: [ { type: 'list', title: 'Что произойдёт', items: [ 'Mihomo, MagiTrickle и hev-socks5-tunnel удалятся', 'Их настройки, подписка и WARP.conf удалятся', 'Интерфейс, зона файрвола и задания расписания уберутся', 'Сервисы, которые шли через Mixomo, пойдут напрямую' ] } ] }).then(function(v) { if (v) 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)); if (d.mt_all) statusCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Через туннель'), zm.badge(true, 'весь трафик', '') ])); 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); renderSub(d); }); } var subInput = E('input', { 'type': 'text', 'placeholder': 'https://...', 'class': 'cbi-input-text' }); var subBusy = false, subOn = !!(data && data.subscription); var subHead = E('h3', {}); var subClearBtn = E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { if (subBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } zm.dialog({ title: 'Очистить подписку?', danger: true, okText: 'Очистить подписку', blocks: [ { type: 'list', title: 'Что произойдёт', items: [ 'Подписка уберётся из конфигурации Mihomo вместе с её серверами и списком исключённых серверов', 'Если подписку ставили поверх другой конфигурации (например, с WARP) — вернётся та, что была до подписки', 'Если подписка была в вашей конфигурации — уберётся только она, остальное останется; группы без серверов пойдут напрямую (DIRECT)', 'Текущая конфигурация сохранится в config.yaml.bak, Mihomo перезапустится' ] } ] }).then(function(v) { if (!v) return; subBusy = true; zm.toast('Убираем подписку', 'warning'); zm.mixomoClearSubscription().then(function(res) { subBusy = false; if (!res || res.error) { zm.toast((res && res.error) || 'Роутер не ответил', 'error', 10000); return; } zm.toast(res.mode === 'restored' ? 'Подписка убрана — вернули конфигурацию, которая была до неё' + (res.warp ? ' (с WARP)' : '') : 'Подписка убрана из конфигурации' + (res.warp ? '' : '. Нужен WARP — на вкладке WARP нажмите «Интегрировать в Mihomo»'), 'info', 8000); subInput.value = ''; refreshAll(); setTimeout(loadMxSrv, 2000); refreshConfig(); }).catch(function() { subBusy = false; zm.toast('Роутер не ответил', 'error'); }); }); } }, 'Очистить подписку'); function renderSub(d) { subOn = !!(d && d.subscription); subHead.innerHTML = ''; subHead.appendChild(document.createTextNode('Подписка ')); subHead.appendChild(zm.badge(subOn, 'настроена', 'не настроена')); subClearBtn.style.display = subOn ? '' : 'none'; } subCard.appendChild(subHead); subCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Вставьте ссылку на VPN-подписку — если в конфигурации уже настроен провайдер подписки, обновится только ссылка; если нет, будет создана готовая конфигурация с раздельной маршрутизацией YouTube и остального трафика через подписку. Прежняя конфигурация (например, с WARP) запоминается — «Очистить подписку» вернёт её.')); 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; } (subOn ? Promise.resolve(true) : zm.dialog({ title: 'Применить подписку?', okText: 'Применить подписку', blocks: [ { type: 'list', title: 'Что произойдёт', items: [ 'Подписка не добавляется рядом, а заменяет текущую конфигурацию Mihomo — например, с WARP', 'Будет готовая конфигурация: YouTube и остальной трафик через серверы подписки', 'Прежняя конфигурация запомнится — вернуть её можно кнопкой «Очистить подписку»' ] } ] })).then(function(v) { if (!v) 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'); }); }); } }, 'Применить подписку'), subClearBtn ])); renderSub(data); 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() { var zmGo = function() { refreshConfig(); zm.toast('Конфигурация перечитана с диска', 'info'); }; if (configBar.dirty()) return zm.ask('Отменить несохранённые изменения и перечитать файл?').then(function(ok) { return ok ? zmGo() : undefined; }); return zmGo(); } }, 'Перечитать файл') ])); configCard.appendChild(configBar); panels.config.appendChild(configCard); var mtStatusCard = E('div', { 'class': 'zm-card' }); var allCard = 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)); renderAll(d); } var allBusy = false; function renderAll(d) { allCard.innerHTML = ''; allCard.appendChild(E('h3', {}, 'Весь трафик')); if (d.magitrickle !== 'installed') { allCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Установите Mixomo на вкладке «Mixomo», чтобы включить.')); return; } var on = !!d.mt_all; allCard.appendChild(zm.swRow(on, allBusy ? 'Пустить весь трафик через Mixomo — применяем…' : 'Пустить весь трафик через Mixomo', on ? 'Включено: в MagiTrickle есть группа «Весь трафик через Mixomo» с правилом 0.0.0.0/0 — весь IPv4-трафик устройств идёт в туннель. Выключите — снова пойдут только сайты из списков.' : 'Добавит в MagiTrickle группу с правилом 0.0.0.0/0: в туннель пойдёт весь IPv4-трафик устройств, а не только сайты из списков. Локальная сеть и сам роутер работают как обычно.', function() { if (allBusy || presetBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } allBusy = true; renderAll(d); zm.toast(on ? 'Убираем правило 0.0.0.0/0 из MagiTrickle' : 'Добавляем правило 0.0.0.0/0 в MagiTrickle', 'warning'); zm.mixomoMagitrickleListSet(on ? 'all_off' : 'all_on').then(function(res) { allBusy = false; if (!res || res.error) { zm.toast((res && res.error) || 'Роутер не ответил', 'error'); renderAll(d); return; } d.mt_all = !!res.mt_all; renderAll(d); zm.toast(res.mt_all ? 'Весь трафик идёт через Mixomo' + (res.running ? '' : ' — заработает после запуска MagiTrickle') : 'Через Mixomo снова идут только сайты из списков', 'info'); refreshAll(); setTimeout(refreshMtEmbed, 2500); }).catch(function() { allBusy = false; zm.toast('Роутер не ответил', 'error'); renderAll(d); }); }, allBusy)); } 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(allCard); 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 как прокси-выход. «Сгенерировать WARP» — быстро, стандартная точка входа и маскировка под SIP-звонок. «С подбором точки входа»: с AmneziaWG роутер проверит точки входа настоящим туннелем и выберет зарубежную — через российские (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() { var zmGo = function() { zm.mixomoWarpStatus().then(function(w) { renderWarpConf(w); }); zm.toast('Содержимое перечитано с диска', 'info'); }; if (warpBar.dirty()) return zm.ask('Отменить несохранённые изменения и перечитать файл?').then(function(ok) { return ok ? zmGo() : undefined; }); return zmGo(); } }, 'Перечитать файл') ])); 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' cat > '/www/luci-static/resources/view/zapret-manager/strategy.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require view'; 'require zapret-manager.common as zm'; var E = (function(raw) { return function() { var a = Array.prototype.slice.call(arguments), i = a.length - 1; if (i >= 1 && (typeof a[i] === 'string' || typeof a[i] === 'number')) a[i] = [ String(a[i]) ]; return raw.apply(null, a); }; })(window.E); var tsChecking = false; function tsCheck(spec) { return zm.testAction('ts_check', spec).then(function(r) { return r && !r.error && r.ts ? (r.parts || []) : null; }, function() { return null; }); } function tsGate(spec, go, ask, note) { if (tsChecking) return; tsChecking = true; tsCheck(spec).then(function(parts) { tsChecking = false; var plain = function() { if (ask) return zm.ask(ask).then(function(ok) { if (ok) go(); }); go(); }; if (!parts) return plain(); return zm.warnSeen('warn_ts').then(function(seen) { if (seen) return plain(); return zm.tsWarn({ parts: parts, note: note }).then(function(ok) { if (ok) { zm.uiFlagSet('warn_ts'); go(); } }); }); }); } 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: 'По домену', custom: 'Свои', custom_yt: 'Свои · YouTube', auto: 'Автоподбор' }; var TEST_RESULT_MODES = ['current', 'domain', 'v', 'flowseal', 'v_flowseal', 'youtube', 'custom', 'custom_yt']; var ZONE_RU = { 'Europe/Kaliningrad': 'Калининград', 'Europe/Moscow': 'Москва', 'Europe/Samara': 'Самара', 'Asia/Yekaterinburg': 'Екатеринбург', 'Asia/Omsk': 'Омск', 'Asia/Krasnoyarsk': 'Красноярск', 'Asia/Irkutsk': 'Иркутск', 'Asia/Yakutsk': 'Якутск', 'Asia/Vladivostok': 'Владивосток', 'Asia/Magadan': 'Магадан', 'Asia/Kamchatka': 'Камчатка', 'UTC': 'UTC' }; var AUTO_MODES = [ { id: 'v', label: 'v1–v10' }, { id: 'flowseal', label: 'Flowseal' }, { id: 'v_flowseal', label: 'v + Flowseal' }, { id: 'youtube', label: 'Только YouTube' } ]; var CUSTOM_SCRIPTS = [ { id: 'off', label: 'Нет' }, { id: 'stun4all', label: '50-stun4all', hint: 'обход для STUN на всех портах — голос и видео в Discord, Telegram, WhatsApp и других' }, { id: 'quic4all', label: '50-quic4all', hint: 'обход для QUIC на всех портах' }, { id: 'discord-media', label: '50-discord-media', hint: 'голосовые серверы Discord (discord.media)' }, { id: 'discord', label: '50-discord', hint: 'подсети голосовых серверов Discord (старый вариант из zapret 70.5)' } ]; 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(), Promise.resolve({}) ]); }, render: function(all) { 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() { zm.dialog({ title: 'Удалить Zapret?', danger: true, okText: 'Удалить Zapret', blocks: [ { type: 'list', title: 'Что произойдёт', items: [ 'Zapret остановится, его правила файрвола снимутся', 'Пакеты, стратегии и списки удалятся', 'Задания Zapret уберутся из расписания' ] }, { type: 'note', kind: 'warn', text: 'Сайты, которые открывались благодаря Zapret, перестанут открываться.' } ] }).then(function(v) { if (v) 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 ? 'Остановить' : 'Запустить')); if (d.zapret_running) zActions.push(E('button', { 'class': 'cbi-button', 'click': function() { doZapretAction('restart'); } }, 'Перезапустить')); } 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', restart: 'Перезапускаем 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(action === 'install' ? zm.refreshBanner('В меню LuCI добавлен пункт Zapret — выйдите и зайдите заново, чтобы он открывался.') : zm.refreshBanner('Пункт Zapret убран из меню LuCI — обновите страницу.', false)); } }); } else if (res) { zBusy = false; if (action === 'restart') zm.toast(res.zapret_running ? 'Zapret перезапущен' : 'Zapret не запустился — проверьте стратегию', res.zapret_running ? 'info' : 'error'); 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' }), fBusy = false; var fBtn = E('button', { 'class': 'cbi-button', 'click': function() { fLoad(); } }, 'Загрузить список'); var fCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Стратегии Flowseal'), E('p', { 'class': 'zm-hint' }, 'Стратегия Flowseal полностью заменяет текущую. В ней уже есть основной блок, игровой, блоки для YouTube и Discord.'), E('div', { 'class': 'zm-actions' }, [ fBtn ]), fGrid ]); function fBtnPaint() { fBtn.disabled = fBusy; fBtn.textContent = fBusy ? 'Загружаем…' : (lastFlowseal && lastFlowseal.items && lastFlowseal.items.length ? 'Обновить список' : 'Загрузить список'); } function fFollow() { fBusy = true; fBtnPaint(); if (!lastFlowseal || !lastFlowseal.items || !lastFlowseal.items.length) { fGrid.innerHTML = ''; fGrid.appendChild(E('p', { 'class': 'zm-hint' }, 'Скачиваем список с GitHub… Пока он грузится, можно выбирать другие стратегии.')); } zm.pollJob('flowseal_download', logEl, function(ok) { zm.toast(ok ? 'Список Flowseal загружен' : 'Список Flowseal не загрузился — похоже, GitHub сейчас недоступен', ok ? 'info' : 'error'); zm.strategyListFlowseal('cached').then(function(r) { fBusy = false; renderFlowseal(r || {}); }, function() { fBusy = false; renderFlowseal(lastFlowseal || {}); }); }); } function fLoad() { if (fBusy) return; fBusy = true; fBtnPaint(); zm.toast('Загружаем список Flowseal', 'warning'); zm.strategyListFlowseal('refresh').then(function(res) { if (res && res.started) { fFollow(); return; } fBusy = false; if (res && res.error) { zm.toast(res.error, 'error'); renderFlowseal(lastFlowseal || {}); return; } renderFlowseal(res || {}); }).catch(function() { fBusy = false; renderFlowseal(lastFlowseal || {}); zm.toast('Роутер не ответил', 'error'); }); } 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) ])); if (status.testing) currentBanner.appendChild(E('span', { 'class': 'zm-badge zm-warn', 'style': 'margin-left:10px', 'title': 'Пока идёт тест, роутер по очереди пробует стратегии. Потом вернётся ваша' }, [ E('span', { 'class': 'zm-dot' }), 'идёт тест' + (status.test_now ? ': сейчас ' + status.test_now : '') ])); if (status.rkn_on) currentBanner.appendChild(E('span', { 'class': 'zm-badge zm-ok', 'style': 'margin-left:10px', 'title': 'Стратегия работает только для сайтов из списка РКН и вашего списка' }, [ E('span', { 'class': 'zm-dot' }), 'по спискам РКН' ])); else if (status.rkn) currentBanner.appendChild(E('span', { 'class': 'zm-badge zm-warn', 'style': 'margin-left:10px', 'title': 'Списки РКН включены, но с этой стратегией не работают — выберите другую' }, [ E('span', { 'class': 'zm-dot' }), 'списки РКН ждут другую стратегию' ])); else if (status.ulist_on) currentBanner.appendChild(E('span', { 'class': 'zm-badge zm-ok', 'style': 'margin-left:10px', 'title': 'Стратегия работает только для сайтов из вашего списка хостов' }, [ E('span', { 'class': 'zm-dot' }), 'по вашему списку' ])); else if (status.ulist) currentBanner.appendChild(E('span', { 'class': 'zm-badge zm-warn', 'style': 'margin-left:10px', 'title': 'Обход по своему списку включён, но с этой стратегией не работает — выберите другую' }, [ E('span', { 'class': 'zm-dot' }), 'свой список ждёт другую стратегию' ])); } 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; } tsGate('v:' + it.id, function() { 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; } tsGate('flowseal:' + it.id, function() { 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' }, fBusy ? 'Скачиваем список с GitHub… Пока он грузится, можно выбирать другие стратегии.' : 'Список Flowseal ещё не загружен. Нажмите «Загрузить список» — он скачается с GitHub. Остальные стратегии можно выбирать и без него.')); fBtnPaint(); } var extraCard = E('div', { 'class': 'zm-card' }), extraBusy = ''; function renderExtra() { extraCard.innerHTML = ''; extraCard.appendChild(E('h3', {}, 'Дополнения к стратегии')); if (status.zapret !== 'installed') { extraCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Станут доступны после установки Zapret.')); return; } var dohDoms = String(status.zdoh_doms || '').split(',').filter(Boolean).join(', '); var dohHint = status.zdoh && !status.zdoh_on ? '⚠ Стратегия помечена «Не изменять» — блок не добавлен. Снимите пометку в «Редактировать текущую стратегию».' : 'Пробивает блокировку шифрованного DNS: блок встаёт первым в стратегии. ' + (status.zdoh_own ? 'Домены берутся из DoH роутера и меняются вместе с ним: ' + dohDoms + '.' : 'DoH на роутере не настроен — взяты популярные серверы: ' + dohDoms + '.'); extraCard.appendChild(zm.swRow(!!(status.zdoh || status.zdoh_on), extraBusy === 'doh' ? 'Обход блокировки DoH — применяем…' : 'Обход блокировки DoH', dohHint, function() { toggleExtra('doh', !(status.zdoh || status.zdoh_on)); }, !!extraBusy)); var rknHint = status.rkn && !status.rkn_on ? '⚠ Текущая стратегия не подходит для списков РКН. Они включатся сами, когда вы выберете подходящую.' : 'Основная стратегия будет обходить блокировку только для сайтов из списка РКН' + (status.rkn_on && status.rkn_count > 0 ? ' (' + status.rkn_count + ' доменов)' : '') + ' и вашего списка хостов, а не для всех сайтов, кроме исключений. Помогает, если стратегия мешает обычным сайтам. Список скачивается из проекта zapret4rocket и сохраняется при смене стратегии.'; extraCard.appendChild(zm.swRow(!!status.rkn, extraBusy === 'rkn' ? 'Обход по спискам РКН — применяем…' : 'Обход по спискам РКН', rknHint, function() { toggleExtra('rkn', !status.rkn); }, !!extraBusy)); var ulN = +status.ulist_count || 0; var ulHint = status.ulist && !status.ulist_on ? '⚠ Текущая стратегия не подходит для обхода по своему списку. Он включится сам, когда вы выберете подходящую.' : 'Основная стратегия будет обходить блокировку только для сайтов из вашего списка /opt/zapret/ipset/zapret-hosts-user.txt' + (status.ulist_on ? ' (' + (ulN ? ulN + ' ' + (ulN % 10 === 1 && ulN % 100 !== 11 ? 'домен' : ulN % 10 >= 2 && ulN % 10 <= 4 && (ulN % 100 < 12 || ulN % 100 > 14) ? 'домена' : 'доменов') : 'список пуст') + ')' : '') + ', а не для всех сайтов, кроме исключений. Список правится на вкладке «Редактор списков». Включается что-то одно: со списками РКН ваш список уже учитывается.'; extraCard.appendChild(zm.swRow(!!status.ulist, extraBusy === 'userlist' ? 'Обход по пользовательским спискам — применяем…' : 'Обход по пользовательским спискам', ulHint, function() { toggleExtra('userlist', !status.ulist); }, !!extraBusy)); if (status.ulist_on && !ulN) extraCard.appendChild(E('p', { 'class': 'zm-hint zm-ab-warn', 'style': 'margin:-4px 0 10px 52px' }, 'Список пуст — основная стратегия сейчас не применяется ни к одному сайту. Добавьте домены на вкладке «Редактор списков».')); extraCard.appendChild(zm.swRow(!!status.wssize, extraBusy === 'wssize' ? 'Блок --wssize 1:6 — применяем…' : 'Блок --wssize 1:6', 'Отдельный блок в конце стратегии: для HTTPS уменьшает окно TCP в начале соединения. Помогает с некоторыми блокировками, но может снизить скорость и стабильность. Сохраняется при смене стратегии.', function() { toggleExtra('wssize', !status.wssize); }, !!extraBusy)); } function toggleExtra(what, on) { if (busy || extraBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } extraBusy = what; renderExtra(); zm.toast(what === 'doh' ? (on ? 'Добавляем блок обхода DoH и перезапускаем Zapret' : 'Убираем блок обхода DoH') : what === 'rkn' ? (on ? 'Скачиваем список РКН и перезапускаем Zapret' : 'Выключаем списки РКН') : what === 'userlist' ? (on ? 'Переводим стратегию на ваш список и перезапускаем Zapret' : 'Возвращаем обход для всех сайтов, кроме исключений') : (on ? 'Добавляем блок --wssize' : 'Убираем блок --wssize'), 'warning'); zm.zapretOptSet(what + ':' + (on ? 'on' : 'off')).then(function(res) { extraBusy = ''; if (!res || res.error) { zm.toast((res && res.error) || 'Роутер не ответил', 'error'); renderExtra(); return; } status = res; if (!zBusy) renderZCard(res); renderExtra(); renderBanner(); renderV(); if (lastFlowseal) renderFlowseal(lastFlowseal); zm.toast(what === 'doh' ? (on ? 'Обход блокировки DoH включён' : 'Обход блокировки DoH выключен') : what === 'rkn' ? (on ? (res.rkn_on ? 'Списки РКН включены' : 'Списки РКН включатся с подходящей стратегией') : 'Списки РКН выключены') : what === 'userlist' ? (on ? 'Стратегия работает только по вашему списку' : 'Стратегия снова работает для всех сайтов, кроме исключений') : (on ? 'Блок --wssize добавлен' : 'Блок --wssize убран'), 'info'); refreshAll(); }).catch(function() { extraBusy = ''; zm.toast('Роутер не ответил', 'error'); renderExtra(); }); } function refreshState() { zm.status().then(function(s) { status = s; if (!zBusy) renderZCard(s); renderBanner(); renderV(); if (!extraBusy) renderExtra(); if (lastFlowseal) renderFlowseal(lastFlowseal); }); } tabHooks.strategy = refreshState; refreshers.strategy = refreshState; renderBanner(); renderV(); renderExtra(); panels.strategy.appendChild(vCard); panels.strategy.appendChild(fCard); panels.strategy.appendChild(extraCard); panels.strategy.appendChild(logEl); renderFlowseal({}); zm.strategyListFlowseal('cached').then(function(res) { renderFlowseal(res || {}); if (res && res.running) fFollow(); }, function() { 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) { return tsCheck('text:' + v).then(function(parts) { return parts ? zm.warnOnce('warn_ts', function() { return zm.tsWarn({ parts: parts, okText: 'Сохранить стратегию' }); }) : true; }).then(function(ok) { if (!ok) return false; 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, игры, тест, переустановка), включите «Не изменять стратегию».')); nfqwsCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Свою стратегию поверх выбранной впишите между строками #CustomStart и #CustomEnd — при смене стратегии, YouTube, Discord, игр и QUIC панель её сохранит и поставит в самый верх, как консольный Zapret Manager.')); nfqwsEl.style.display = ''; nfqwsCard.appendChild(nfqwsEl); nfqwsCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'click': function() { var zmGo = function() { refreshNfqwsOpt(); zm.toast('Содержимое перечитано с диска', 'info'); }; if (nfqwsBar.dirty()) return zm.ask('Отменить несохранённые изменения и перечитать файл?').then(function(ok) { return ok ? zmGo() : undefined; }); return zmGo(); } }, 'Перечитать файл'), E('button', { 'class': 'cbi-button', 'click': function() { var zmGo = function() { nfqwsOpen = false; nfqwsBar.reset(); renderNfqwsCard(); }; if (nfqwsBar.dirty()) return zm.ask('Есть несохранённые изменения. Свернуть без сохранения?').then(function(ok) { return ok ? zmGo() : undefined; }); return zmGo(); } }, 'Свернуть') ])); 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: 'Проверить' } ] }, { id: 'custom', title: 'Свои стратегии', sub: 'Ваш список стратегий: на заблокированных сайтах или на YouTube. Список хранится в /root/custom_test.txt — так же, как в консольном Zapret Manager.', custom: true } ]; var customPick = 'custom', customOpen = false, customText = testData.custom || ''; var customEl = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false', 'wrap': 'off', 'placeholder': '#Моя1\n--filter-tcp=443\n--dpi-desync=fake\n#Моя2\n--filter-tcp=443\n--dpi-desync=multidisorder' }); customEl.value = customText; var customBar = zm.editorBar(customEl, { onSave: function(v) { return zm.testAction('custom_set', v).then(function(r) { if (!r || r.error) { zm.toast((r && r.error) || 'Роутер не ответил', 'error'); return false; } customText = v.trim() ? v : ''; zm.toast(r.count ? 'Список сохранён: ' + r.count + ' ' + (r.count % 10 === 1 && r.count % 100 !== 11 ? 'стратегия' : r.count % 10 >= 2 && r.count % 10 <= 4 && (r.count % 100 < 12 || r.count % 100 > 14) ? 'стратегии' : 'стратегий') : 'Список очищен', 'info'); renderLaunch(); return true; }).catch(function() { zm.toast('Роутер не ответил', 'error'); return false; }); } }); var customCard = E('div', { 'class': 'zm-card', 'style': 'display:none' }); function customCount() { return (customText.match(/^#/mg) || []).length; } function renderCustomCard() { customCard.style.display = customOpen ? '' : 'none'; customCard.innerHTML = ''; if (!customOpen) return; customCard.appendChild(E('h3', {}, 'Свои стратегии для теста')); customCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Каждая стратегия начинается со строки #Название, дальше её параметры nfqws — по одному в строке, с --. Одинарные кавычки нельзя.')); customCard.appendChild(customEl); customCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'click': function() { var zmGo = function() { customOpen = false; customBar.reset(customText); renderCustomCard(); renderLaunch(); }; if (customBar.dirty()) return zm.ask('Есть несохранённые изменения. Свернуть без сохранения?').then(function(ok) { return ok ? zmGo() : undefined; }); return zmGo(); } }, 'Свернуть') ])); customCard.appendChild(customBar); } 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.custom) { var cn = customCount(); ctl = E('div', { 'class': 'zm-tt-ctl' }, [ E('div', { 'class': 'zm-seg' }, [ { id: 'custom', label: 'Сайты' }, { id: 'custom_yt', label: 'YouTube' } ].map(function(c) { return E('div', { 'class': 'zm-seg-item' + (c.id === customPick ? ' zm-active' : ''), 'click': function() { if (!busy && c.id !== customPick) { customPick = c.id; renderLaunch(); } } }, c.label); })), E('button', { 'class': 'cbi-button', 'click': function() { customOpen = !customOpen; if (customOpen) customBar.reset(customText); renderCustomCard(); renderLaunch(); } }, customOpen ? 'Скрыть список' : (cn ? 'Список · ' + cn : 'Добавить стратегии')), E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': busy || !cn ? '' : null, 'click': function() { doStart(customPick); } }, 'Проверить') ]); } else 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)) || (t.id === 'custom' && curMode === 'custom_yt')); var had = t.id === 'main' ? (status.has_results_v || status.has_results_flowseal || status.has_results_v_flowseal) : t.id === 'custom' ? (status.has_results_custom || status.has_results_custom_yt) : status['has_results_' + t.id]; return E('div', { 'class': 'zm-tt-tile' + (run ? ' zm-tt-active' : '') + (t.custom ? ' zm-tt-wide' : '') }, [ E('div', { 'class': 'zm-tt-head' }, [ E('b', {}, t.title), run ? zm.badge(true, 'идёт', '') : (had ? E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'есть результат') : E([])) ]), E('p', { 'class': 'zm-hint' }, t.sub), ctl ]); }))); } var autoCard = E('div', { 'class': 'zm-card' }), autoInfo = testData.auto || {}, autoBusy = false, timeInfo = testData, autoOwn = false; zm.warnSeen('warn_ts').then(function(seen) { if (seen) return; return tsCheck('current').then(function(parts) { if (!parts || !parts.length) return; var auto = autoInfo.ts_at > 0; return zm.tsWarn({ info: true, parts: parts, title: auto ? 'Автоподбор поставил стратегию с меткой ts' : 'В вашей стратегии есть метка ts', lead: auto ? 'Ночью автоподбор нашёл стратегию, которая открывает больше сайтов, и применил её.' : '' }).then(function() { zm.uiFlagSet('warn_ts'); }); }); }); var AUTO_TIMES = [ '01:00', '02:00', '03:00', '04:00', '05:00', '06:00' ]; function p2(n) { return (n < 10 ? '0' : '') + n; } function routerNow() { if (!timeInfo.time) return null; var off = String(timeInfo.utc_offset || '+0000'), sg = off.charAt(0) === '-' ? -1 : 1; return new Date((timeInfo.time + sg * ((+off.slice(1, 3)) * 3600 + (+off.slice(3, 5)) * 60)) * 1000); } function whenText(ts) { var now = routerNow(), off = String(timeInfo.utc_offset || '+0000'), sg = off.charAt(0) === '-' ? -1 : 1; var d = new Date((ts + sg * ((+off.slice(1, 3)) * 3600 + (+off.slice(3, 5)) * 60)) * 1000); var hm = p2(d.getUTCHours()) + ':' + p2(d.getUTCMinutes()); if (now) { var days = Math.round((Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) - Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())) / 86400000); if (days === 0) return 'сегодня в ' + hm; if (days === 1) return 'вчера в ' + hm; } return p2(d.getUTCDate()) + '.' + p2(d.getUTCMonth() + 1) + '.' + d.getUTCFullYear() + ' в ' + hm; } function nextText(t) { var now = routerNow(); if (!now || !/^\d\d:\d\d$/.test(t)) return t; var mins = (+t.slice(0, 2)) * 60 + (+t.slice(3, 5)), cur = now.getUTCHours() * 60 + now.getUTCMinutes(); return (mins > cur ? 'сегодня в ' : 'завтра в ') + t; } function autoSet(when, mode, okText, yt) { if (autoBusy) return; autoBusy = true; renderAuto(); zm.testAction('auto_set', when + '|' + mode + (yt ? '|' + yt : '')).then(function(r) { autoBusy = false; if (!r || r.error) { zm.toast((r && r.error) || 'Роутер не ответил', 'error'); renderAuto(); return; } autoInfo = r.auto || {}; timeInfo = r; zm.toast(okText || (autoInfo.time ? 'Автоподбор: каждый день в ' + autoInfo.time : 'Автоподбор выключен'), 'info'); renderAuto(); }).catch(function() { autoBusy = false; zm.toast('Роутер не ответил', 'error'); renderAuto(); }); } function renderAuto() { var on = !!autoInfo.time, mode = autoInfo.mode || 'v_flowseal', t = autoInfo.time || autoInfo.pref || '04:00', yt = !!autoInfo.yt; var preset = AUTO_TIMES.indexOf(t) >= 0 && !autoOwn; autoCard.innerHTML = ''; autoCard.appendChild(E('h3', {}, [ 'Автоподбор стратегии ', on ? zm.badge(true, 'ежедневно в ' + autoInfo.time, '') : E([]) ])); autoCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Роутер проверяет стратегии на списке заблокированных сайтов и сравнивает лучшую с текущей. Новая стратегия применяется, только если открывает больше сайтов. Настройки Discord, игр, QUIC и дополнения к стратегии сохраняются, YouTube — тоже, если не включён подбор для него.')); autoCard.appendChild(zm.swRow(on, 'Запускать ежедневно', on ? 'Следующий запуск: ' + nextText(autoInfo.time) + '. Проверка длится 5–10 минут, Zapret в это время перезапускается.' : 'Выключено — стратегия меняется только вручную.', function() { if (autoBusy) return; autoSet(on ? 'off' : t, mode); }, autoBusy)); if (on) { var ctl = [ E('div', { 'class': 'zm-seg' + (autoBusy ? ' zm-seg-busy' : '') }, AUTO_TIMES.map(function(x) { return E('div', { 'class': 'zm-seg-item' + (preset && x === t ? ' zm-active' : ''), 'click': function() { if (autoBusy || (preset && x === t)) return; autoOwn = false; autoSet(x, mode); } }, x); }).concat([ E('div', { 'class': 'zm-seg-item' + (!preset ? ' zm-active' : ''), 'click': function() { if (!autoOwn && preset) { autoOwn = true; renderAuto(); } } }, 'Другое') ])) ]; if (!preset) { var inp = E('input', { 'type': 'time', 'lang': 'ru', 'class': 'cbi-input-text zm-ab-time', 'value': t, 'step': '300', 'disabled': autoBusy ? '' : null }); ctl.push(inp, E('button', { 'class': 'cbi-button', 'disabled': autoBusy ? '' : null, 'click': function() { if (!/^\d\d:\d\d$/.test(inp.value)) { zm.toast('Укажите время в формате ЧЧ:ММ', 'warning'); return; } autoOwn = AUTO_TIMES.indexOf(inp.value) < 0; autoSet(inp.value, mode); } }, 'Сохранить')); } autoCard.appendChild(E('div', { 'class': 'zm-row zm-ab-row' }, [ E('span', { 'class': 'zm-label' }, 'Время запуска'), E('div', { 'class': 'zm-ab-ctl' }, ctl) ])); } autoCard.appendChild(E('div', { 'class': 'zm-row zm-ab-row' }, [ E('span', { 'class': 'zm-label' }, 'Стратегии'), E('div', { 'class': 'zm-seg' + (autoBusy ? ' zm-seg-busy' : '') }, AUTO_MODES.map(function(m) { return E('div', { 'class': 'zm-seg-item' + (m.id === mode ? ' zm-active' : ''), 'click': function() { if (!autoBusy && m.id !== mode) autoSet(autoInfo.time || 'off', m.id, 'Автоподбор будет проверять: ' + (m.id === 'youtube' ? 'только стратегии YouTube (Yv)' : m.label + (yt ? ' и YouTube' : ''))); } }, m.label); })) ])); if (mode !== 'youtube') autoCard.appendChild(zm.swRow(yt, 'Подбирать и стратегию для YouTube', yt ? 'Сначала подбирается основная стратегия, затем для YouTube (Yv) — на адресах YouTube. Новая ставится, только если открывает больше.' : 'Выключено — настройка YouTube остаётся как есть.', function() { if (!autoBusy) autoSet(autoInfo.time || 'off', mode, yt ? 'Подбор для YouTube выключен' : 'Включено: после основной стратегии — подбор для YouTube', yt ? 'off' : 'on'); }, autoBusy)); else autoCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Меняется только стратегия YouTube (Yv), основная стратегия остаётся как есть.')); function lastRes(l) { var yv = l.mode === 'youtube', unit = yv ? ' адресов YouTube' : ' сайтов', cur = l.cur || (yv ? 'из основной стратегии' : 'текущая'); return l.result === 'applied' ? [ E('b', { 'class': 'zm-ab-ok' }, 'применена ' + l.best), ' — ' + l.best_ok + ' из ' + l.total + unit + ' (было ' + cur + ' — ' + l.cur_ok + ' из ' + l.total + ')' ] : l.result === 'kept' ? [ E('b', {}, 'оставлена ' + cur), ' — ' + l.cur_ok + ' из ' + l.total + unit + '; лучшая из проверенных ' + l.best + ' — ' + l.best_ok + ' из ' + l.total ] : l.result === 'nochange' ? [ 'пропущен — включено «Не изменять стратегию»' ] : l.result === 'yoff' ? [ 'пропущен — YouTube выключен в стратегии' ] : l.result === 'none' ? [ 'лучшую стратегию определить не удалось, оставлена текущая' ] : [ E('b', { 'class': 'zm-ab-bad' }, 'ошибка'), ' — подробности в журнале теста' ]; } var last = autoInfo.last; if (last && last.at) { var ly = autoInfo.last_yt, lastEl = E('div', { 'class': 'zm-ab-last' }, [ E('span', { 'class': 'zm-label' }, 'Последний запуск ' + whenText(last.at) + (last.mode === 'youtube' ? ', YouTube: ' : ': ')) ].concat(lastRes(last))); if (ly && ly.at && last.mode !== 'youtube') lastEl.appendChild(E('div', { 'style': 'margin-top:6px' }, [ E('span', { 'class': 'zm-label' }, 'YouTube: ') ].concat(lastRes(ly)))); autoCard.appendChild(lastEl); } var yr = timeInfo.time ? new Date(timeInfo.time * 1000).getFullYear() : 0; if (on && yr && yr < 2024) autoCard.appendChild(E('p', { 'class': 'zm-hint' }, '⚠ Время на роутере неверное — расписание не сработает. Настройте его на странице «Система».')); var now = routerNow(); autoCard.appendChild(E('div', { 'class': 'zm-actions zm-ab-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': busy || !zapretInstalled ? '' : null, 'click': function() { if (busy) { zm.toast('Дождитесь окончания текущего теста', 'warning'); return; } return zm.ask('Запустить автоподбор сейчас?\n\nПроверка займёт 5–10 минут. Zapret в это время перезапускается со стратегиями по очереди.').then(function(ok) { if (!ok) return; busy = true; curMode = 'auto'; renderLaunch(); renderAuto(); zm.testAction('auto_run', '').then(function(res) { if (res && res.error) { busy = false; curMode = ''; zm.toast(res.error, 'error'); renderLaunch(); renderAuto(); return; } zm.toast('Автоподбор запущен', 'info'); startPolling(); }).catch(function() { busy = false; curMode = ''; renderLaunch(); renderAuto(); zm.toast('Роутер не ответил', 'error'); }); }); } }, busy && curMode === 'auto' ? 'Идёт автоподбор…' : 'Запустить сейчас'), last && last.at ? E('button', { 'class': 'cbi-button', 'disabled': busy ? '' : null, 'click': function() { zm.testAction('auto_clear', '').then(function(r) { if (r && !r.error) { autoInfo = r.auto || {}; timeInfo = r; renderAuto(); } }); } }, 'Удалить результат') : E([]), E('span', { 'class': 'zm-hint zm-ab-clock' }, [ 'Время роутера: ' + (now ? p2(now.getUTCHours()) + ':' + p2(now.getUTCMinutes()) : '—') + (timeInfo.zone ? ' · ' + (ZONE_RU[timeInfo.zone] || timeInfo.zone.replace(/^.*\//, '').replace(/_/g, ' ')) : '') + ' · ', E('a', { 'href': document.body.classList.contains('zmw-body') ? '#/system' : L.url('admin/services/zapret-manager/system') }, 'настроить') ]) ])); } 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'); refreshAll(); zm.testStatus().then(function(res) { status = res; autoInfo = res.auto || {}; timeInfo = res; renderAuto(); renderLaunch(); if (done === 'auto') done = autoInfo.mode || 'v_flowseal'; 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 customBlock(name) { var out = [], on = false; String(customText || '').split('\n').forEach(function(l) { l = l.trim(); if (!l) return; if (l.charAt(0) === '#') { on = l.slice(1).trim() === name; if (on) out.push(l); return; } if (on) out.push(l); }); return out.length > 1 ? out.join('\n') : ''; } function applyStrategy(mode, name) { var call; if (mode === 'custom' || mode === 'custom_yt') { var block = customBlock(name); if (!block) { zm.toast('Стратегии «' + name + '» уже нет в вашем списке', 'warning'); return; } return tsGate('text:' + block, function() { zm.toast('Применяем ' + name + '…', 'warning'); zm.nfqwsOptSet(block).then(function(r) { if (r && r.error) { zm.toast(r.error, 'error'); return; } zm.toast('Стратегия ' + name + ' применена', 'info'); refreshAll(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); }, 'Применить свою стратегию «' + name + '»?\n\nОна заменит весь блок стратегий Zapret (NFQWS_OPT) — вместе с частями для YouTube, Discord и игр. Вернуть их можно выбором стратегии на вкладке «Стратегии».', 'Своя стратегия заменит весь блок стратегий Zapret (NFQWS_OPT) — вместе с частями для YouTube, Discord и игр.'); } var spec; if (/^v([1-9]|10)$/.test(name)) { call = function() { return zm.strategySetV(name); }; spec = 'v:' + name; } else if (mode === 'youtube') { call = function() { return zm.strategySetYoutube(name); }; spec = 'youtube:' + name; } else { call = function() { return zm.strategySetFlowseal(name); }; spec = 'flowseal:' + name; } return tsGate(spec, function() { zm.toast('Применяем ' + name + '…', 'warning'); call().then(function(r) { if (r && r.error) { zm.toast(r.error, 'error'); return; } zm.toast('Стратегия ' + name + ' применена', 'info'); refreshAll(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); }, 'Применить стратегию ' + name + '?'); } 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 resPad(s, n) { s = String(s); while (s.length < n) s += ' '; return s; } function resSave() { var d = resData[resMode], mode = resMode; if (!d) return; var bt = zm.busyToast('Готовим файл с результатами…'); Promise.all([ zm.systemInfo().catch(function() { return {}; }), zm.status().catch(function() { return statusData || {}; }) ]).then(function(rr) { bt.close(); var si = rr[0] || {}, st = rr[1] || {}, rows = d.rows.slice(), L = [], now = new Date(); if (mode !== 'current') rows.sort(function(a, b) { return b.ok - a.ok; }); var tot = rows.length ? rows[0].tot : (d.control ? d.control.tot : 0), w = 10; rows.forEach(function(r) { w = Math.max(w, String(r.name).length); }); w = Math.min(w + 2, 42); function pct(r) { return r.tot ? Math.round(r.ok * 100 / r.tot) + '%' : '—'; } L.push('Zapret Manager — результаты теста стратегий'); L.push(''); function kv(k, v) { L.push(resPad(k + ':', 16) + v); } kv('Тест', mode === 'current' ? 'Текущая стратегия' : (TEST_MODE_LABELS[mode] || mode)); if (d.ts) kv('Проверено', d.ts); kv('Роутер', [ si.model, si.hostname && si.hostname !== si.model ? '(' + si.hostname + ')' : '' ].filter(Boolean).join(' ') || '—'); if (si.openwrt) kv('OpenWrt', si.openwrt + (si.arch ? ' · ' + si.arch : '')); if (st.strategy) kv('Сейчас стоит', st.strategy); if (tot) kv('Адресов', String(tot)); if (mode === 'domain' && status.domains) kv('Сайты', status.domains); L.push(''); if (d.control) { kv('Без Zapret', d.control.ok + ' / ' + d.control.tot + ' (' + pct(d.control) + ')'); L.push(''); } L.push(resPad('№', 4) + resPad('Стратегия', w) + 'Открылось'); L.push(new Array(4 + w + 18).join('-')); rows.forEach(function(r, i) { L.push(resPad(i + 1, 4) + resPad(r.name, w) + resPad(r.ok + ' / ' + r.tot, 10) + pct(r)); }); var det = rows.filter(function(r) { return r.doms && r.doms.length; }); if (d.control && d.control.doms && d.control.doms.length) det.unshift(d.control); if (det.length) { L.push(''); L.push('Подробно — какие адреса не открылись'); det.forEach(function(r) { var bad = r.doms.filter(function(x) { return !x.ok; }).map(function(x) { return x.name; }); L.push(''); L.push((r === d.control ? 'Без Zapret' : r.name) + ' — ' + r.ok + ' из ' + r.tot + ':'); L.push(bad.length ? ' не открылись: ' + bad.join(', ') : ' открылось всё'); }); } L.push(''); L.push('Файл сохранён ' + now.toLocaleString('ru-RU')); var m = /(\d{1,2})\.(\d{1,2})\.(\d{4})\D+(\d{1,2}):(\d{2})/.exec(String(d.ts || '')), p2 = function(n) { return ('0' + n).slice(-2); }; var stamp = m ? m[3] + '-' + p2(m[2]) + '-' + p2(m[1]) + '_' + p2(m[4]) + '-' + m[5] : now.getFullYear() + '-' + p2(now.getMonth() + 1) + '-' + p2(now.getDate()) + '_' + p2(now.getHours()) + '-' + p2(now.getMinutes()); var who = String(si.model || si.hostname || 'router').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'router'; var what = { v_flowseal: 'v+flowseal', custom_yt: 'custom-youtube' }[mode] || String(mode).replace(/[^A-Za-z0-9+.-]+/g, '-'); var a = E('a', { 'href': URL.createObjectURL(new Blob([ '\ufeff' + L.join('\r\n') + '\r\n' ], { type: 'text/plain;charset=utf-8' })), 'download': who + '_' + stamp + '_zapret-test-' + what + '.txt', 'style': 'display:none' }); document.body.appendChild(a); a.click(); setTimeout(function() { URL.revokeObjectURL(a.href); if (a.parentNode) a.parentNode.removeChild(a); }, 2000); zm.toast('Результаты сохранены в файл', 'info'); }).catch(function() { bt.close(); zm.toast('Не удалось подготовить файл', 'error'); }); } 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('div', { 'class': 'zm-tt-btns' }, [ E('button', { 'class': 'cbi-button', 'disabled': resData[resMode] && !loading ? null : '', 'title': 'Скачать результаты этого теста текстовым файлом', 'click': resSave }, 'Сохранить в файл'), E('button', { 'class': 'cbi-button', 'click': function() { zm.dialog({ title: 'Удалить результаты «' + (TEST_MODE_LABELS[resMode] || resMode) + '»?', danger: true, okText: 'Удалить результаты', blocks: [ { type: 'list', title: 'Что произойдёт', items: [ 'Результаты этого теста удалятся', 'Результаты других тестов останутся' ] } ] }).then(function(ok) { if (ok) 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(); renderAuto(); renderCustomCard(); loadResults(TEST_RESULT_MODES.filter(function(m) { return status['has_results_' + m]; })[0]); if (busy) startPolling(); panels.test.appendChild(launchCard); panels.test.appendChild(customCard); panels.test.appendChild(logEl); panels.test.appendChild(resCard); panels.test.appendChild(autoCard); })(); (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]+z?$/.test(words[i])) return words[i].replace(/z$/, ''); } 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; tsGate('youtube:' + it.id, function() { 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 ? 'Выключить' : 'Включить') ])); } var ipidCard = E('div', { 'class': 'zm-card' }), ipidBusy = false; var ipid = { on: !!status.ipid, want: status.ipid_want || '', yt: !!status.yt_tcp }; function renderIpid() { ipidCard.innerHTML = ''; var on = ipid.yt ? ipid.on : ipid.want === 'on'; ipidCard.appendChild(E('h3', {}, 'Маскировка подменных пакетов')); ipidCard.appendChild(zm.swRow(on, ipidBusy ? 'Блок --ip-id=zero — применяем…' : 'Блок --ip-id=zero', [ 'Добавляет строку --ip-id=zero в стратегию YouTube и сохраняется при её смене.', E('br'), 'Попробуйте включить, если YouTube плохо грузится.' ], function() { toggleIpid(!on); }, ipidBusy)); if (!ipid.yt) ipidCard.appendChild(E('p', { 'class': 'zm-hint zm-ab-warn' }, on ? 'Стратегии для YouTube сейчас нет — маскировка включится сама, когда вы её выберете.' : 'Стратегии для YouTube сейчас нет — сначала выберите её выше.')); } function toggleIpid(on) { if (busy || ipidBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } ipidBusy = true; renderIpid(); zm.toast(on ? 'Включаем маскировку и перезапускаем Zapret' : 'Выключаем маскировку и перезапускаем Zapret', 'warning'); zm.zapretOptSet('ipid:' + (on ? 'on' : 'off')).then(function(res) { ipidBusy = false; if (!res || res.error) { zm.toast((res && res.error) || 'Роутер не ответил', 'error'); renderIpid(); return; } ipid = { on: !!res.ipid, want: res.ipid_want || '', yt: !!res.yt_tcp }; renderIpid(); zm.toast(on ? (ipid.yt ? 'Маскировка включена' : 'Маскировка включится вместе со стратегией YouTube') : 'Маскировка выключена', 'info'); refreshAll(); }).catch(function() { ipidBusy = false; zm.toast('Роутер не ответил', 'error'); renderIpid(); }); } function refreshState() { zm.status().then(function(s) { current = currentYv(s); yvOff = !!s.yv_off; quicOn = !!s.quic_yt; if (!ipidBusy) { ipid = { on: !!s.ipid, want: s.ipid_want || '', yt: !!s.yt_tcp }; renderIpid(); } 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(); renderIpid(); 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(ipidCard); 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; tsGate('gv:' + n, function() { 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; tsGate('dv:' + dv.replace('Dv', ''), function() { 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)); }); } var scriptCard = E('div', { 'class': 'zm-card' }), scriptBusy = ''; function renderScripts() { scriptCard.innerHTML = ''; scriptCard.appendChild(E('h3', {}, 'Скрипты и hosts для Discord')); var cur = data.script || 'off', on = CUSTOM_SCRIPTS.filter(function(s) { return s.id === cur; })[0]; scriptCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Скрипт custom.d'), E('div', { 'class': 'zm-seg' + (scriptBusy ? ' zm-seg-busy' : '') }, CUSTOM_SCRIPTS.map(function(s) { return E('div', { 'class': 'zm-seg-item' + (s.id === cur ? ' zm-active' : ''), 'click': function() { if (s.id === cur || scriptBusy) return; if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } scriptBusy = 'script'; renderScripts(); zm.toast(s.id === 'off' ? 'Убираем скрипт и перезапускаем Zapret' : 'Скачиваем ' + s.label + ' и перезапускаем Zapret', 'warning'); zm.zapretOptSet('script:' + s.id).then(function(r) { scriptBusy = ''; if (!r || r.error) { zm.toast((r && r.error) || 'Роутер не ответил', 'error'); renderScripts(); return; } zm.toast(s.id === 'off' ? 'Скрипт убран' : 'Скрипт ' + s.label + ' установлен', 'info'); refreshState(); }).catch(function() { scriptBusy = ''; zm.toast('Роутер не ответил', 'error'); renderScripts(); }); } }, s.label); })) ])); scriptCard.appendChild(E('p', { 'class': 'zm-hint' }, cur === 'other' ? 'Сейчас стоит свой скрипт 50-script.sh — панель его не узнаёт. Выбор другого варианта заменит его.' : (on && on.hint ? 'Сейчас: ' + on.hint + '. ' : '') + 'Готовые скрипты из примеров zapret добавляют свои правила обхода поверх стратегии. Ставится один скрипт: выбор другого заменяет прежний.')); scriptCard.appendChild(zm.swRow(!!data.finland, scriptBusy === 'fin' ? 'Финские IP для Discord — применяем…' : 'Финские IP для Discord', 'Добавляет в hosts адреса голосовых серверов finland10000–10199.discord.media → 104.25.158.178. Помогает, если голос в Discord не подключается.', function() { if (scriptBusy) return; scriptBusy = 'fin'; renderScripts(); zm.hostsToggle('finland').then(function(r) { scriptBusy = ''; if (!r || r.error) { zm.toast((r && r.error) || 'Роутер не ответил', 'error'); renderScripts(); return; } data.finland = !!r.enabled; zm.toast(r.enabled ? 'Финские IP добавлены в hosts' : 'Финские IP убраны из hosts', 'info'); renderScripts(); }).catch(function() { scriptBusy = ''; zm.toast('Роутер не ответил', 'error'); renderScripts(); }); }, !!scriptBusy)); } function refreshState() { zm.discordStatus().then(function(res) { data = res; renderDv(); renderFake(); if (!scriptBusy) renderScripts(); }); } refreshers.discord = refreshState; tabHooks.discord = refreshState; renderDv(); renderFake(); renderScripts(); 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); panels.discord.appendChild(scriptCard); })(); (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() { var zmGo = function() { 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(); }); }; if (st.open && dirty()) return zm.ask('В «' + L.title + '» есть несохранённые изменения. Свернуть и отменить их?').then(function(ok) { return ok ? zmGo() : undefined; }); return zmGo(); } 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() { var zmGo = function() { load(false); }; if (dirty()) return zm.ask('Отменить несохранённые изменения и перечитать «' + L.title + '» из файла?').then(function(ok) { return ok ? zmGo() : undefined; }); return zmGo(); }); if (btnRestore) btnRestore.addEventListener('click', function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } return zm.ask('Заменить список исключений исходным из репозитория Zapret Manager? Ваши правки в нём пропадут.').then(function(ok) { if (!ok) 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' cat > '/www/luci-static/resources/view/zapret-manager/zapret2.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require view'; 'require zapret-manager.common as zm'; var E = (function(raw) { return function() { var a = Array.prototype.slice.call(arguments), i = a.length - 1; if (i >= 1 && (typeof a[i] === 'string' || typeof a[i] === 'number')) a[i] = [ String(a[i]) ]; return raw.apply(null, a); }; })(window.E); 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' && d.z2_luci) { var web = document.body.classList.contains('zmw-body'); z2Actions.push(E('a', { 'class': 'cbi-button cbi-button-action', 'href': web ? location.protocol + '//' + location.hostname + '/cgi-bin/luci/' + d.z2_luci : L.url(d.z2_luci), 'target': web ? '_blank' : null, 'rel': web ? 'noopener' : null, 'title': 'Настройки Zapret2 в LuCI: стратегия, списки, режимы' }, 'Перейти в настройки')); } if (d.zapret2 === 'installed') { z2Actions.push(E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { zm.dialog({ title: 'Удалить Zapret2?', danger: true, okText: 'Удалить Zapret2', blocks: [ { type: 'list', title: 'Что произойдёт', items: [ 'Zapret2 остановится, его правила файрвола снимутся', 'Пакеты и настройки удалятся', 'Задания Zapret2 уберутся из расписания' ] }, { type: 'note', kind: 'warn', text: 'Сайты, которые открывались благодаря Zapret2, перестанут открываться.' } ] }).then(function(v) { if (v) view.doAction2('remove'); }); } }, 'Удалить')); z2Actions.push(E('button', { 'class': 'cbi-button', 'click': function() { view.doAction2(d.zapret2_running ? 'stop' : 'start'); } }, d.zapret2_running ? 'Остановить' : 'Запустить')); if (d.zapret2_running) z2Actions.push(E('button', { 'class': 'cbi-button', 'click': function() { view.doAction2('restart'); } }, 'Перезапустить')); } 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', restart: 'Перезапускаем 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(action === 'install' ? zm.refreshBanner('В меню LuCI добавлен пункт Zapret2 — выйдите и зайдите заново, чтобы он открывался.') : zm.refreshBanner('Пункт Zapret2 убран из меню LuCI — обновите страницу.', false)); } }); } else if (res) { view.busy2 = false; if (action === 'restart') zm.toast(res.zapret2_running ? 'Zapret2 перезапущен' : 'Zapret2 не запустился', res.zapret2_running ? 'info' : 'error'); view.renderCards(res); } }).catch(function() { view.busy2 = false; }); } }); ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/zapret2.js' 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: #0b6fa4; background: rgba(34,158,217,.1); border: 1px solid rgba(34,158,217,.25); border-radius: 999px; padding: 5px 13px; transition: background .15s; } html.zm-theme-dark .zm-header-links a { color: #5cc3f5; } .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-row.zm-chk { flex-wrap: nowrap; align-items: baseline; } .zm-row.zm-chk > .zm-chk-h { flex: 0 0 104px; min-width: 0; } .zm-row.zm-chk > .zm-chk-b { flex: 1 1 auto; min-width: 0; line-height: 1.5; } .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; } html.zm-theme-dark .zm-ok { background: rgba(63,185,80,.15); color: #3fb950; } html.zm-theme-dark .zm-ok .zm-dot { background: #3fb950; } html.zm-theme-dark .zm-bad { background: rgba(248,81,73,.15); color: #ff7b72; } html.zm-theme-dark .zm-bad .zm-dot { background: #f85149; } html.zm-theme-dark .zm-warn { background: rgba(210,153,34,.16); color: #e3b341; } html.zm-theme-dark .zm-warn .zm-dot { background: #d29922; } html.zm-theme-dark .zm-off { background: rgba(139,148,158,.16); color: #a3acb7; } html.zm-theme-dark .zm-off .zm-dot { background: #8b949e; } html.zm-theme-dark .zm-lat-good { color: #3fb950; } html.zm-theme-dark .zm-lat-mid { color: #e3b341; } html.zm-theme-dark .zm-lat-bad { color: #ff7b72; } html.zm-theme-dark .zm-tile.zm-active { color: #3fb950; } html.zm-theme-dark .zm-tile.zm-tile-off { color: #ff7b72; } html.zm-theme-dark .zm-refresh-banner { color: #e3b341; } .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-th-hero { display: flex; align-items: center; gap: 16px; padding: 18px 20px; border-radius: 16px; background: linear-gradient(135deg, rgba(124,92,255,.10), rgba(34,211,238,.10)); } .zm-th-heart { flex: 0 0 auto; width: 52px; height: 52px; border-radius: 50%; display: grid; place-items: center; font-size: 26px; line-height: 1; color: #e11d48; background: rgba(225,29,72,.12); } .zm-th-text { flex: 1 1 auto; min-width: 0; } .zm-th-text h4 { margin: 0 0 4px; font-size: 17px; } .zm-th-text p { margin: 0; font-size: 13.5px; line-height: 1.55; opacity: .8; } .zm-th-btn { flex: 0 0 auto; align-self: center; margin: 0 !important; white-space: nowrap; } .zm-th-caret { display: inline-block; transition: transform .2s; } .zm-th-caret.zm-th-up { transform: rotate(180deg); } .zm-th-group { margin-top: 16px; } .zm-th-ghead { margin: 0 0 6px; font-size: 12px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; opacity: .65; } .zm-th-ghead span { opacity: .75; } .zm-th-rows { border: 1px solid rgba(110,118,129,.22); border-radius: 12px; overflow: hidden; } .zm-th-row { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(0, .8fr) minmax(0, 1.3fr); gap: 12px; align-items: center; padding: 9px 14px; font-size: 13px; } .zm-th-row + .zm-th-row { border-top: 1px solid rgba(110,118,129,.15); } .zm-th-row b { overflow-wrap: anywhere; } .zm-th-author { opacity: .72; overflow-wrap: anywhere; } .zm-th-row a { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .zm-th-row.zm-th-self { background: rgba(26,127,55,.06); } @media (max-width: 640px) { .zm-th-hero { flex-wrap: wrap; padding: 16px; } .zm-th-heart { width: 44px; height: 44px; font-size: 22px; } .zm-th-text { flex-basis: calc(100% - 60px); } .zm-th-btn { flex: 1 1 100%; } .zm-th-row { grid-template-columns: minmax(0, 1fr); gap: 1px; } .zm-th-author { font-size: 12px; } } .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-doh-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin: 16px 0 0; } .zm-doh-head h4 { margin: 0; } .zm-doh-head .cbi-button { margin: 0; } .zm-doh-group { margin-top: 14px; } .zm-doh-group-head { display: flex; align-items: baseline; gap: 2px 10px; flex-wrap: wrap; font-size: 13px; } .zm-doh-group-head .zm-hint { margin: 0; } .zm-doh-group .zm-node { min-height: 0; gap: 6px; } .zm-doh-group .zm-node.zm-sec-add { min-height: 58px; } .zm-doh-grid { grid-auto-flow: row dense; } .zm-doh-fold-name { display: flex; justify-content: space-between; align-items: center; gap: 8px; } .zm-doh-fold-name .zm-svc-chev { flex: none; } .zm-doh-fold-open .zm-svc-chev { transform: rotate(90deg); opacity: .8; } .zm-doh-fold-open:not(.zm-active) { border-color: rgba(26,127,55,.55); } .zm-doh-fold-cur { font-size: 12.5px; font-weight: 600; color: #1a7f37; margin-top: -2px; } html.zm-theme-dark .zm-doh-fold-cur { color: #3fb950; } .zm-doh-fold-panel { grid-column: 1 / -1; border: 1px solid rgba(26,127,55,.35); border-radius: 14px; padding: 12px; background: rgba(26,127,55,.04); } .zm-doh-fold-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; font-size: 13px; } .zm-doh-fold-x { flex: none; width: 28px; height: 28px; border-radius: 8px; border: 1px solid rgba(110,118,129,.3); background: transparent; color: inherit; cursor: pointer; font-size: 13px; line-height: 1; opacity: .7; } .zm-doh-fold-x:hover { opacity: 1; border-color: rgba(110,118,129,.6); } .zm-doh-var-desc { font-size: 12px; opacity: .75; line-height: 1.35; } .zm-doh-own .zm-node-name { padding-right: 52px; } .zm-doh-own-tools { position: absolute; top: 6px; right: 6px; display: flex; gap: 2px; } .zm-doh-own-btn { display: grid; place-items: center; width: 22px; height: 22px; padding: 0; margin: 0; border: 0; border-radius: 6px; background: transparent; color: inherit; font-size: 12px; line-height: 1; cursor: pointer; opacity: .45; transition: opacity .15s, background .15s, color .15s; } .zm-doh-own:hover .zm-doh-own-btn, .zm-doh-own-btn:focus-visible { opacity: .8; } .zm-doh-own-btn:hover { opacity: 1; background: rgba(127,127,127,.16); } .zm-doh-own-btn.zm-doh-own-del:hover { color: #cf222e; background: rgba(207,34,46,.1); } .zm-doh-own-btn.zm-doh-own-save:hover { color: #1a7f37; background: rgba(26,127,55,.12); } .zm-doh-own-btn[disabled] { opacity: .2 !important; cursor: default; background: transparent !important; } @media (hover: none) { .zm-doh-own-btn { opacity: .7; } } .zm-rm-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin: 4px 0 6px; } .zm-rm-tile { display: flex; flex-direction: column; gap: 6px; min-width: 0; padding: 14px 16px; border-radius: 14px; border: 1px solid rgba(127,127,127,.22); background: rgba(127,127,127,.05); } .zm-rm-tile.zm-rm-danger { border-color: rgba(207,34,46,.45); } .zm-rm-title { font-size: 14px; } .zm-rm-danger .zm-rm-title { color: #cf222e; } html.zm-theme-dark .zm-rm-danger .zm-rm-title { color: #ff7b72; } .zm-rm-text { margin: 0 !important; flex: 1 1 auto; } .zm-rm-btn { margin-top: 6px; } .zm-rm-btn .cbi-button { margin: 0 !important; } .zm-pg { margin-top: 16px; padding-top: 14px; border-top: 1px dashed rgba(127,127,127,.35); } .zm-pg-head { display: flex; align-items: center; justify-content: space-between; gap: 8px 12px; flex-wrap: wrap; } .zm-pg-head > div { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .zm-pg-group { margin-top: 14px; } .zm-pg-group-title { font-size: 12px; font-weight: 600; opacity: .7; margin-bottom: 4px; } .zm-pg-row { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 10px; border: 1px solid transparent; cursor: pointer; } .zm-pg-row + .zm-pg-row { border-top-color: rgba(127,127,127,.15); } .zm-pg-row:hover { background: rgba(127,127,127,.06); } .zm-pg-row.zm-pg-on { background: rgba(207,34,46,.06); } .zm-pg-check { width: 16px; height: 16px; margin: 0; cursor: pointer; accent-color: #cf222e; } .zm-pg-main { display: flex; flex-direction: column; min-width: 0; } .zm-pg-name { font-size: 13.5px; font-weight: 600; } .zm-pg-sub { font-size: 12px; opacity: .7; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .zm-pg-tools { margin-top: 12px; } .zm-pg-note { margin: 12px 0 0; flex-direction: column; align-items: flex-start; gap: 4px; } .zm-pg-go .cbi-input-text { flex: 1 1 180px; max-width: 260px; text-transform: uppercase; } @media (max-width: 480px) { .zm-pg-row { grid-template-columns: 22px minmax(0, 1fr); } .zm-pg-row .zm-badge { grid-column: 2; justify-self: start; } .zm-pg-sub { white-space: normal; } .zm-pg-go .cbi-input-text { max-width: none; } } @media (max-width: 480px) { .zm-seg.zm-seg-stack { flex-direction: column; width: 100%; } .zm-seg.zm-seg-stack .zm-seg-item { text-align: center; } } .zm-doh-svc { display: inline-flex; align-items: center; gap: 6px 10px; flex-wrap: wrap; min-width: 0; } .zm-fo-chain { position: relative; display: flex; flex-direction: column; gap: 10px; margin: 16px 0 4px; } .zm-fo-chain > * { position: relative; } .zm-fo-chain > *:not(:first-child)::before, .zm-fo-chain > *:not(:last-child)::after { content: ""; position: absolute; left: 15px; width: 2px; background: rgba(110,118,129,.25); } .zm-fo-chain > *:not(:first-child)::before { top: -10px; height: 34px; } .zm-fo-chain > *:not(:last-child)::after { top: 24px; bottom: 0; } .zm-fo-step { display: grid; grid-template-columns: 32px minmax(0, 1fr); align-items: start; gap: 12px; } .zm-fo-num { position: relative; z-index: 1; margin-top: 8px; display: grid; place-items: center; width: 32px; height: 32px; box-sizing: border-box; border-radius: 50%; border: 2px solid rgba(110,118,129,.35); background: #f6f8fa; color: #57606a; font-weight: 700; font-size: 14px; font-variant-numeric: tabular-nums; } .zm-fo-card { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 14px; min-width: 0; padding: 10px 14px; border-radius: 12px; border: 1px solid rgba(110,118,129,.22); background: rgba(110,118,129,.045); transition: border-color .15s, box-shadow .15s; } .zm-fo-body { flex: 1 1 260px; min-width: 0; } .zm-fo-side { display: flex; align-items: center; gap: 6px; margin-left: auto; } .zm-fo-side .zm-lat { margin-right: 6px; } .zm-fo-name { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; font-size: 14px; min-width: 0; } .zm-fo-sub { margin-top: 4px; font-size: 12.5px; line-height: 1.45; opacity: .72; } .zm-fo-sub.zm-fo-warn { opacity: 1; color: #9a6700; } .zm-fo-tag { display: inline-flex; align-items: center; padding: 2px 9px; border-radius: 999px; font-size: 11.5px; font-weight: 600; line-height: 1.5; white-space: nowrap; background: rgba(26,163,255,.12); color: #0969da; } .zm-fo-tag.zm-fo-tag-now { background: rgba(26,127,55,.13); color: #1a7f37; } .zm-fo-main .zm-fo-num { border-color: #1aa3ff; color: #0969da; } .zm-fo-last .zm-fo-num { border-style: dashed; } .zm-fo-last .zm-seg { display: inline-flex; flex-wrap: wrap; max-width: 100%; margin-top: 8px; } .zm-fo-step.zm-fo-now .zm-fo-num { border-color: #1a7f37; background: #1a7f37; color: #fff; } .zm-fo-step.zm-fo-now .zm-fo-card { border-color: rgba(26,127,55,.55); box-shadow: 0 0 0 3px rgba(26,127,55,.1); } .zm-fo-sel { min-width: 210px; max-width: 100%; margin: 0; } .zm-fo-ico { min-width: 34px; height: 32px; padding: 0 9px !important; margin: 0 !important; line-height: 1; font-size: 15px; } .zm-fo-ico[disabled] { opacity: .35; cursor: default; } .zm-fo-add { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-height: 34px; padding-left: 44px; } .zm-fo-add .cbi-button { margin: 0; } html.zm-theme-dark .zm-fo-num { background: #2d333b; color: #adbac7; } html.zm-theme-dark .zm-fo-main .zm-fo-num, html.zm-theme-dark .zm-fo-tag { color: #58a6ff; } html.zm-theme-dark .zm-fo-tag.zm-fo-tag-now { color: #3fb950; } html.zm-theme-dark .zm-fo-step.zm-fo-now .zm-fo-num { background: #238636; border-color: #238636; color: #fff; } html.zm-theme-dark .zm-fo-sub.zm-fo-warn { color: #d29922; } @media (max-width: 640px) { .zm-fo-chain > *:not(:first-child)::before, .zm-fo-chain > *:not(:last-child)::after { left: 12px; } .zm-fo-chain > *:not(:first-child)::before { height: 31px; } .zm-fo-chain > *:not(:last-child)::after { top: 21px; } .zm-fo-step { grid-template-columns: 26px minmax(0, 1fr); gap: 8px; } .zm-fo-num { width: 26px; height: 26px; margin-top: 8px; font-size: 12px; } .zm-fo-card { padding: 10px 12px; } .zm-fo-body { flex-basis: 100%; } .zm-fo-sel { min-width: 0; width: 100%; } .zm-fo-add { padding-left: 34px; } } .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-term { box-sizing: border-box; min-width: 0; overflow: hidden; background: #0d1117; color: #8b949e; border: 1px solid rgba(255,255,255,.10); border-radius: 10px; box-shadow: inset 0 0 0 1px rgba(0,0,0,.2); } .zm-term-bar { display: flex; align-items: center; gap: 12px; height: 38px; padding: 0 16px; box-sizing: border-box; border-bottom: 1px solid rgba(255,255,255,.07); font-family: ui-monospace, "SF Mono", "Cascadia Code", Consolas, "Liberation Mono", monospace; font-size: 12.5px; line-height: 1; color: #8b949e; user-select: none; } .zm-term-dots { display: inline-flex; gap: 8px; flex: 0 0 auto; } .zm-term-dots i { display: block; width: 10px; height: 10px; border-radius: 50%; background: #ff5f57; } .zm-term-dots i:nth-child(2) { background: #febc2e; } .zm-term-dots i:nth-child(3) { background: #28c840; } .zm-term-title { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .zm-term-body { padding: 8px 11px 10px; background: inherit; } .zm-term-frame { display: block; width: 100%; border: 0; background: #0d1117; height: 480px; height: clamp(320px, calc(100vh - 340px), 780px); } @media (max-width: 600px) { .zm-term-bar { padding: 0 12px; } .zm-term-body { padding: 6px 5px 8px; } .zm-term-frame { height: clamp(280px, calc(100vh - 300px), 620px); } } .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-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-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-refresh-banner:not(.zm-reboot-banner) { flex-wrap: wrap; } .zm-refresh-banner:not(.zm-reboot-banner) > span:first-child { flex: 1 1 0; min-width: min(260px, 100%); line-height: 1.45; } .zm-refresh-banner.zm-pg-note > span { flex: 0 0 auto; min-width: 0; width: 100%; text-align: left; } @media (max-width: 640px) { .zm-refresh-banner:not(.zm-reboot-banner) { padding: 12px 14px; font-size: 14px; gap: 10px 12px; } .zm-refresh-banner:not(.zm-reboot-banner) > span:first-child { flex-basis: 100%; } .zm-refresh-banner:not(.zm-reboot-banner) > .cbi-button { flex: 1 1 auto; margin: 0; min-width: 0; white-space: normal; } .zm-refresh-banner:not(.zm-reboot-banner) > .cbi-button.zm-cl-link { flex: 0 0 auto; } } .zm-ab-auto { margin-top: 16px; border: 1px solid rgba(127,127,127,.22); border-radius: 12px; padding: 12px 14px; background: rgba(127,127,127,.05); } .zm-ab-auto-title { font-weight: 600; font-size: 13.5px; } .zm-ab-auto-sub { font-size: 12.5px; opacity: .65; margin-top: 2px; line-height: 1.45; } .zm-ab-auto-head { display: flex; align-items: center; justify-content: space-between; gap: 10px 12px; flex-wrap: wrap; margin-top: 10px; } .zm-ab-auto-head .cbi-button { flex-shrink: 0; } .zm-ab-auto-chips { display: flex; flex-wrap: wrap; gap: 6px; } .zm-ab-chip { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 999px; background: rgba(26,127,55,.10); color: #1a7f37; } .zm-ab-chip b { font-weight: 700; opacity: .75; } html.zm-theme-dark .zm-ab-chip { background: rgba(63,185,80,.14); color: #3fb950; } .zm-ab-grp { margin-top: 14px; } .zm-ab-grp-h { font-size: 12.5px; font-weight: 700; } .zm-ab-grp-h span { font-weight: 400; opacity: .6; } .zm-ab-doms { margin-top: 6px; display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 2px 18px; font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; opacity: .85; word-break: break-all; } .zm-ab-doms .zm-ab-own { color: #1a7f37; font-weight: 600; } html.zm-theme-dark .zm-ab-doms .zm-ab-own { color: #3fb950; } .zm-home-banner { font-size: 13.5px; padding: 12px 16px; } .zm-banner-acts { display: inline-flex; gap: 8px; flex-wrap: wrap; flex-shrink: 0; } @media (max-width: 700px) { .zm-home-banner { flex-direction: column; align-items: stretch; } .zm-banner-acts .cbi-button { flex: 1 1 auto; } } #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; } .zm-toast-busy { border-left-color: #1aa3ff; cursor: default; } .zm-toast-spin { flex-shrink: 0; align-self: center; width: 18px; height: 18px; box-sizing: border-box; border-radius: 50%; border: 2px solid rgba(255,255,255,.25); border-top-color: #58b7ff; animation: zm-savebar-spin .8s linear infinite; } html.zm-theme-dark .zm-toast-spin { border-color: rgba(0,0,0,.14); border-top-color: #0b72d1; } .zm-toast-main, .zm-toast-sub { display: block; } .zm-toast-sub { margin-top: 2px; font-size: .82em; font-weight: 500; opacity: .72; } 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-key { font-family: ui-monospace, "SF Mono", "Cascadia Code", Consolas, monospace; font-size: 12.5px; overflow-wrap: anywhere; } .zm-tg-edit { margin-top: 14px; padding: 14px 16px; border-radius: 12px; border: 1px solid rgba(110,118,129,.25); background: rgba(110,118,129,.05); display: flex; flex-direction: column; gap: 12px; } .zm-tg-edit-row { display: grid; grid-template-columns: 64px minmax(0, 1fr); gap: 6px 12px; align-items: start; } .zm-tg-edit-label { font-size: 12.5px; font-weight: 600; opacity: .75; line-height: 34px; } .zm-tg-edit-plabel { line-height: 1.3; margin-bottom: -6px; } .zm-tg-edit-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; } .zm-tg-edit-in { box-sizing: border-box; font-family: ui-monospace, "SF Mono", "Cascadia Code", Consolas, monospace; letter-spacing: .02em; } .zm-tg-edit-port { width: 120px !important; max-width: 100%; } .zm-tg-edit-keyline { display: flex; gap: 8px; flex-wrap: wrap; min-width: 0; } .zm-tg-edit-key { flex: 1 1 260px; min-width: 0; width: auto !important; } .zm-tg-edit-msg { min-height: 20px; } .zm-tg-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 10px; border-radius: 999px; font-size: 12px; line-height: 1.5; } .zm-tg-chip-ok { background: rgba(26,127,55,.12); color: #1a7f37; } .zm-tg-chip-bad { background: rgba(207,34,46,.12); color: #cf222e; } .zm-tg-chip-wait { background: rgba(154,103,0,.12); color: #9a6700; } .zm-tg-edit-preview { margin: 0; font-size: 13px; } .zm-tg-edit-preview-off { opacity: .55; color: #8b949e !important; font-style: italic; } .zm-tg-edit .zm-actions { margin-top: 0; } html.zm-theme-dark .zm-tg-chip-ok { color: #3fb950; } html.zm-theme-dark .zm-tg-chip-bad { color: #ff7b72; } html.zm-theme-dark .zm-tg-chip-wait { color: #d29922; } @media (max-width: 600px) { .zm-tg-edit-row { grid-template-columns: 1fr; } .zm-tg-edit-label { line-height: 1.3; } } .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-head { display: flex; align-items: flex-start; gap: 14px; flex-wrap: wrap; } .zm-ab-hero .zm-grid { margin-top: 16px; } @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-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-gen-label { margin: 18px 0 0; font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; opacity: .6; } .zm-gen-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 10px; } .zm-gen-grid > .zm-awg-if.zm-gen-tile { margin: 0; display: flex; flex-direction: column; gap: 6px; min-width: 0; } .zm-gen-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } .zm-gen-title { font-weight: 600; } .zm-gen-time { font-size: 12px; opacity: .6; white-space: nowrap; } .zm-gen-tile .zm-gen-text { margin: 0; flex: 1 1 auto; } .zm-gen-foot { display: flex; align-items: center; gap: 8px; margin-top: 6px; min-width: 0; } .zm-gen-foot .cbi-input-text { flex: 1 1 auto; min-width: 0; width: auto; max-width: none; } .zm-gen-foot .cbi-button { flex: 0 0 auto; margin: 0 !important; } .zm-gen-tile .zm-gen-need { margin: 0; } @media (max-width: 720px) { .zm-gen-grid { grid-template-columns: minmax(0, 1fr); } .zm-gen-foot > .cbi-button:only-child { flex: 1 1 auto; } } .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-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; } @media (max-width: 600px) { .zm-st-stats { grid-template-columns: 1fr; } .zm-svc-grid { grid-template-columns: 1fr; } } @media (max-width: 600px) { .zm-ab-head > .zm-badge { margin-left: 58px; } } .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-tabsplit.zm-tabs-one > .zm-tabgrp { flex: 1 1 100%; width: 100%; max-width: 100%; display: flex; } .zm-tabsplit.zm-tabs-one > .zm-tabgrp > .cbi-button { flex: 1 1 0; justify-content: center; text-align: center; } #zmw-view .zm-tabsplit.zm-tabs-one > .zm-actions.zmw-tabs { width: 100%; } #zmw-view .zm-tabsplit.zm-tabs-one .zmw-tabs .cbi-button { flex: 1 0 auto; } .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-lat-blk { color: #b45309; font-weight: 700; } .zm-node-foot > span.zm-lat-blk { white-space: normal; text-align: right; line-height: 1.25; flex-shrink: 1; min-width: 0; } html.zm-theme-dark .zm-lat-blk { color: #f0b54a; } .zm-node.zm-node-blk { opacity: .78; border-style: dashed; } .zm-sub-meta { display: flex; flex-direction: column; align-items: stretch; gap: 10px; padding: 12px 16px; margin: 4px 0 12px; border-radius: 10px; background: rgba(26,127,55,.06); border: 1px solid rgba(26,127,55,.18); } .zm-sub-meta.zm-sub-expired { background: rgba(207,34,46,.06); border-color: rgba(207,34,46,.35); } .zm-sub-title { font-weight: 700; font-size: 15px; overflow-wrap: anywhere; } .zm-sub-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); gap: 10px 18px; } .zm-sub-fact { display: flex; flex-direction: column; gap: 3px; font-size: 12px; min-width: 0; } .zm-sub-fact > span { opacity: .6; } .zm-sub-fact > b { font-size: 13px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } .zm-ab-facts { display: flex; flex-wrap: wrap; gap: 12px 24px; } .zm-quota { width: 100%; max-width: 160px; 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-svc-dom { flex-shrink: 0; box-sizing: border-box; width: 28px; height: 28px; min-width: 0; min-height: 0; margin: 0; padding: 0; line-height: 1; box-shadow: none; -webkit-appearance: none; appearance: none; display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; border-radius: 9px; border: 1px solid rgba(110,118,129,.32); background: transparent; color: inherit; cursor: pointer; transition: background .15s, border-color .15s, color .15s; } .zm-svc-dom > i { display: block; width: 11px; height: 2px; border-radius: 2px; background: currentColor; opacity: .8; } .zm-svc-dom:hover { border-color: #1aa3ff; } .zm-svc-dom:focus-visible { outline: 2px solid #1aa3ff; outline-offset: 2px; } .zm-svc-dom.zm-active { background: #1aa3ff; border-color: #1aa3ff; color: #fff; } .zm-dom-panel { grid-column: 1 / -1; min-width: 0; padding: 14px 16px; border-radius: 12px; border: 1px solid rgba(26,163,255,.45); background: rgba(26,163,255,.05); animation: zmDomIn .16s ease-out; } .zm-dom-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } .zm-dom-title { display: flex; flex-direction: column; gap: 2px; flex: 1 1 220px; min-width: 0; } .zm-dom-title b { font-size: 14px; } .zm-dom-sub { font-size: 12px; opacity: .65; } .zm-dom-btns { display: flex; gap: 8px; flex-wrap: wrap; } .zm-dom-btns .cbi-button { margin: 0; } .zm-dom-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); column-gap: 24px; margin-top: 12px; padding: 6px 14px; max-height: 360px; overflow: auto; border-radius: 10px; border: 1px solid rgba(110,118,129,.18); background: var(--background-color-medium, #fff); } html.zm-theme-dark .zm-dom-list { background: #1b2027; } .zm-dom-item { min-width: 0; padding: 5px 0; border-bottom: 1px solid rgba(110,118,129,.12); font-family: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace; font-size: 12.5px; overflow-wrap: anywhere; } .zm-dom-more { margin: 10px 0 0; font-size: 12px; } @keyframes zmDomIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } } @media (prefers-reduced-motion: reduce) { .zm-dom-panel { animation: none; } } .zm-find-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } .zm-find-head > h4 { flex: 1 1 auto; } .zm-find-open { display: inline-flex; align-items: center; gap: 8px; box-sizing: border-box; height: 38px; margin: 0; padding: 0 14px 0 12px; border-radius: 11px; border: 1px solid rgba(110,118,129,.3); background: transparent; color: inherit; font: inherit; font-weight: 700; font-size: 13.5px; line-height: 1; white-space: nowrap; flex-shrink: 0; cursor: pointer; box-shadow: none; -webkit-appearance: none; appearance: none; transition: border-color .15s, background .15s; } .zm-find-open:hover { border-color: #1aa3ff; } .zm-find-open:focus-visible { outline: 2px solid #1aa3ff; outline-offset: 2px; } .zm-find-ico, .zm-find-x-ico { display: inline-flex; flex-shrink: 0; width: 17px; height: 17px; } .zm-find-ico > svg, .zm-find-x-ico > svg { display: block; width: 100%; height: 100%; } .zm-find-open .zm-find-ico { color: #6d4cff; } .zm-find-x { flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 36px; height: 36px; margin: 0; padding: 0; border: 0; border-radius: 10px; background: rgba(110,118,129,.12); color: inherit; cursor: pointer; box-shadow: none; -webkit-appearance: none; appearance: none; transition: background .15s, transform .15s; } .zm-find-x:hover { background: rgba(110,118,129,.22); } .zm-find-x:focus-visible { outline: 2px solid #6d4cff; outline-offset: 2px; } .zm-find-x .zm-find-x-ico { width: 16px; height: 16px; opacity: .8; } .zm-find-wrap { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: flex-start; justify-content: center; padding: 9vh 16px 16px; box-sizing: border-box; background: rgba(15,23,42,.45); -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px); animation: zmFindFade .15s ease-out; } .zm-find { width: 100%; max-width: 640px; max-height: 82vh; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box; border-radius: 22px; border: 1px solid rgba(124,92,255,.38); background: var(--background-color-high, #fff); color: var(--text-color-highest, #0f172a); box-shadow: 0 30px 80px -20px rgba(15,23,42,.55); animation: zmFindIn .18s ease-out; } html.zm-theme-dark .zm-find { background: #1c2128; color: #e6edf3; } .zm-find-field { flex-shrink: 0; display: flex; align-items: center; gap: 14px; padding: 0 18px 0 22px; height: 66px; border-bottom: 1px solid rgba(110,118,129,.18); } .zm-find-field > .zm-find-ico { width: 21px; height: 21px; color: #6d4cff; } .zm-find-input, .zm-find-input:focus { flex: 1; min-width: 0; height: 44px; margin: 0; padding: 0; border: 0 !important; outline: none; box-shadow: none !important; background: transparent !important; color: inherit; font-family: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace; font-size: 19px; } .zm-find-body { overflow: auto; padding: 14px 14px 16px; } .zm-find-title { font-size: 11.5px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; opacity: .6; padding: 2px 10px 8px; } .zm-find-list { display: flex; flex-direction: column; gap: 4px; } .zm-find-row { display: flex; align-items: center; gap: 13px; padding: 11px 12px; border-radius: 14px; cursor: pointer; border: 1px solid transparent; transition: background .12s, border-color .12s; } .zm-find-row:hover, .zm-find-row:focus-visible { background: rgba(124,92,255,.07); border-color: rgba(124,92,255,.25); outline: none; } .zm-find-row.zm-on { background: rgba(26,163,255,.08); border-color: rgba(26,163,255,.35); } .zm-find-row.zm-find-taken { opacity: .6; } .zm-find-row .zm-svc-ico { width: 36px; height: 36px; } .zm-find-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } .zm-find-name { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-weight: 700; font-size: 14.5px; } .zm-find-why { font-size: 12.5px; opacity: .7; overflow-wrap: anywhere; } .zm-find-best { font-size: 11px; font-weight: 800; padding: 2px 8px; border-radius: 999px; color: #fff; background: linear-gradient(135deg, #7c5cff 0%, #3b82f6 100%); } .zm-find-tag { flex-shrink: 0; font-size: 11.5px; font-weight: 700; padding: 4px 9px; border-radius: 999px; } .zm-find-tag-dom { background: rgba(124,92,255,.13); color: #5b21b6; } .zm-find-tag-ip { background: rgba(14,165,233,.14); color: #075985; } html.zm-theme-dark .zm-find-tag-dom { color: #c4b5fd; } html.zm-theme-dark .zm-find-tag-ip { color: #7dd3fc; } .zm-find-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; padding: 26px 16px; text-align: center; font-size: 13.5px; } .zm-find-empty > span { opacity: .7; } .zm-find-empty > b { font-size: 15px; } .zm-find-err > span { color: #cf222e; opacity: 1; } .zm-find-note { display: flex; align-items: center; gap: 9px; margin: 10px 10px 0; font-size: 12.5px; opacity: .75; } .zm-find-foot { display: flex; margin-top: 14px; padding: 14px 10px 0; border-top: 1px solid rgba(110,118,129,.16); } .zm-find-foot .cbi-button { flex: 1; margin: 0; min-height: 44px; font-weight: 700; } .zm-find-spin { display: inline-block; flex-shrink: 0; width: 14px; height: 14px; border-radius: 50%; border: 2px solid rgba(124,92,255,.25); border-top-color: #7c5cff; animation: zmFindSpin .8s linear infinite; } .zm-find-empty > .zm-find-spin { width: 20px; height: 20px; opacity: 1; } @keyframes zmFindFade { from { opacity: 0; } to { opacity: 1; } } @keyframes zmFindIn { from { opacity: 0; transform: translateY(-8px) scale(.985); } to { opacity: 1; transform: none; } } @keyframes zmFindSpin { to { transform: rotate(360deg); } } @media (max-width: 600px) { .zm-find-wrap { padding: 12px 10px; } .zm-find { max-height: calc(100vh - 24px); border-radius: 18px; } .zm-find-field { height: 58px; padding: 0 12px 0 16px; } .zm-find-input { font-size: 16px; } .zm-find-tag { display: none; } } @media (prefers-reduced-motion: reduce) { .zm-find-wrap, .zm-find { animation: none; } .zm-find-spin { animation: zmFindSpin .9s linear infinite !important; } } .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-st-stat.zm-fk-top { margin-top: 16px; padding: 16px 18px; gap: 6px; border-width: 1.5px; border-color: rgba(26,163,255,.45); } .zm-fk-top > .zm-fk-sec-head { flex-wrap: wrap; } .zm-fk-top > .zm-fk-sec-head > .zm-st-stat-label { white-space: normal; } .zm-fk-top-badges { display: inline-flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; flex-shrink: 0; } .zm-fk-top-badges > .zm-badge { padding: 2px 9px; font-size: 11px; } @media (max-width: 600px) { .zm-fk-top-badges { flex-shrink: 1; min-width: 0; max-width: 100%; justify-content: flex-start; } .zm-fk-top-badges > .zm-badge { white-space: normal; max-width: 100%; } } .zm-fk-top + .zm-st-stats.zm-fk-secs { margin-top: 10px; } .zm-st-stats.zm-fk-secs.zm-fk-cols-1 { grid-template-columns: minmax(0, 1fr); } .zm-st-stats.zm-fk-secs.zm-fk-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } .zm-st-stats.zm-fk-secs.zm-fk-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } @media (max-width: 1000px) { .zm-st-stats.zm-fk-secs.zm-fk-cols-3 { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 700px) { .zm-st-stats.zm-fk-secs.zm-fk-cols-2, .zm-st-stats.zm-fk-secs.zm-fk-cols-3 { grid-template-columns: minmax(0, 1fr); } } .zm-fk-traffic { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-top: 6px; } .zm-fk-tile { display: flex; flex-direction: column; gap: 2px; min-width: 0; padding: 10px 12px; border-radius: 10px; background: rgba(0,0,0,.04); } html.zm-theme-dark .zm-fk-tile { background: rgba(255,255,255,.06); } .zm-fk-tile > b { font-size: 17px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; } .zm-fk-tile > span { font-size: 11.5px; opacity: .6; } .zm-fk-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(125px, 100%), 1fr)); gap: 8px 20px; margin-top: 8px; padding-top: 10px; border-top: 1px solid rgba(0,0,0,.08); } html.zm-theme-dark .zm-fk-info { border-top-color: rgba(255,255,255,.1); } .zm-fk-info > div { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .zm-fk-info > div > span { font-size: 11.5px; opacity: .6; } .zm-fk-info > div > b { font-size: 13px; font-weight: 600; overflow-wrap: anywhere; } .zm-fk-sec.zm-fk-sec-main { border-width: 1.5px; border-color: rgba(26,163,255,.45); } .zm-fk-sec.zm-fk-sec-cur { border-color: #1aa3ff; box-shadow: inset 0 0 0 1px #1aa3ff; } .zm-fk-sec.zm-fk-sec-off > *:not(.zm-fk-sec-head), .zm-node.zm-sec.zm-sec-off .zm-node-name { opacity: .55; } .zm-node.zm-sec.zm-sec-off { border-style: dashed; } .zm-fk-chips { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; } .zm-fk-chip { font-size: 11.5px; line-height: 1.5; padding: 2px 9px; border-radius: 999px; background: rgba(0,0,0,.05); border: 1px solid transparent; white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; } html.zm-theme-dark .zm-fk-chip { background: rgba(255,255,255,.08); } .zm-fk-chip.zm-fk-chip-x { background: transparent; border: 1px dashed rgba(0,0,0,.22); opacity: .8; } html.zm-theme-dark .zm-fk-chip.zm-fk-chip-x { border-color: rgba(255,255,255,.25); } .zm-fk-chip.zm-fk-chip-more { background: rgba(26,163,255,.12); color: #1a7fd0; font-weight: 600; cursor: pointer; } .zm-fk-chip.zm-fk-chip-more:hover { background: rgba(26,163,255,.2); } @media (max-width: 600px) { .zm-fk-traffic, .zm-fk-info { grid-template-columns: repeat(2, minmax(0, 1fr)); } .zm-fk-info { gap: 8px 12px; } .zm-fk-top-badges { justify-content: flex-start; } } .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-group { margin: 22px 0 10px; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; opacity: .6; } .zm-si-mas { display: flex; align-items: flex-start; gap: 14px; } .zm-si-mas.zm-si-even { align-items: stretch; } .zm-si-col { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 14px; } .zm-si-col > .zm-si-box { margin: 0 !important; } .zm-si-even .zm-si-col > .zm-si-box { flex: 1 0 auto; } .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; } 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; display: grid; grid-template-columns: minmax(96px, 42%) minmax(0, 1fr); align-items: center; } .zm-si-box .zm-row > .zm-label { min-width: 0 !important; width: auto !important; overflow-wrap: anywhere; } .zm-si-box .zm-row > .zm-badge { justify-self: start; max-width: 100%; white-space: normal; border-radius: 10px; } .zm-si-val { overflow-wrap: anywhere; } .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-grid .zm-tt-tile.zm-tt-wide { grid-column: 1 / -1; } .zm-ab-row .zm-label { min-width: 120px; } .zm-ab-ctl { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } .zm-ab-time { width: auto !important; min-width: 110px; } .zm-auto-num { width: 84px !important; min-width: 0 !important; } .zm-unit-row { align-items: center; } .zm-unit-seg .zm-seg-item { min-width: 64px; text-align: center; user-select: none; } .zm-unit-hint { margin: 6px 0 0; } .zm-ab-last { margin: 12px 0 4px; padding: 10px 12px; border-radius: 10px; background: rgba(127,127,127,.08); font-size: 13px; line-height: 1.5; } .zm-ab-last .zm-label { opacity: .7; } .zm-ab-ok { color: #1a7f37; } .zm-ab-bad { color: #cf222e; } .zm-ab-actions { align-items: center; } .zm-ab-clock { margin: 0 0 0 auto !important; } @media (max-width: 600px) { .zm-ab-row .zm-label { min-width: 0; width: 100%; } .zm-ab-clock { margin: 0 !important; width: 100%; } } .zm-tz-clock { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; padding: 14px 16px; border-radius: 14px; background: linear-gradient(135deg, rgba(124,92,255,.10), rgba(34,211,238,.08)); border: 1px solid rgba(124,92,255,.18); } .zm-tz-now { min-width: 0; } .zm-tz-time { font-size: 34px; font-weight: 700; line-height: 1.1; letter-spacing: .02em; font-variant-numeric: tabular-nums; } .zm-tz-date { font-size: 13px; opacity: .65; margin-top: 2px; } .zm-tz-zone { font-size: 14px; font-weight: 600; margin-top: 2px; } .zm-tz-btns { margin-left: auto; display: flex; flex-direction: column; gap: 8px; min-width: 210px; } .zm-tz-btns .cbi-button { width: 100%; margin: 0 !important; } .zm-tz-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); margin-top: 12px; } .zm-tz-grid .zm-tz-tile { min-width: 0; text-align: left; } .zm-tz-tile b { display: block; font-weight: 600; } .zm-tz-tile span { font-size: 12px; opacity: .7; } @media (max-width: 600px) { .zm-tz-btns { margin-left: 0; width: 100%; } .zm-tz-time { font-size: 30px; } } .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-btns { display: flex; gap: 8px; flex-wrap: wrap; } .zm-tt-meta { margin: 10px 0 8px; } .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: var(--background-color-high, #fff); color: var(--text-color-highest, #1f2328); 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; color: #e6edf3; 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; } } @keyframes zm-savebar-spin { to { transform: rotate(360deg); } } .zm-savebar.zm-savebar-run { border-color: rgba(26,163,255,.65); } .zm-savebar.zm-savebar-done { border-color: rgba(22,163,74,.55); } .zm-savebar.zm-savebar-done .zm-savebar-dot { background: #16a34a; box-shadow: 0 0 0 4px rgba(22,163,74,.18); } @media (prefers-reduced-motion: reduce) { .zm-toast-spin { animation-duration: 1.4s !important; animation-iteration-count: infinite !important; } } .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-dock .zm-refresh-banner { margin: 0; background: #fff6dd; box-shadow: 0 18px 44px -14px rgba(0,0,0,.45); animation: zm-savebar-in .18s ease-out; } html.zm-theme-dark .zm-dock .zm-refresh-banner { background: #2f2a1c; color: #e3b341; } .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 { box-sizing: border-box; width: 100%; max-width: 380px; display: flex; flex-direction: column; align-items: center; padding: 28px 26px 22px; border-radius: 20px; text-align: center; background: var(--surface-solid, #ffffff); color: var(--text, #1f2328); box-shadow: 0 24px 70px -20px rgba(0,0,0,.55); } html.zm-theme-dark .zm-reboot-box { background: var(--surface-solid, #1c2128); color: var(--text, #e6edf3); } .zm-reboot-spin { width: 44px; height: 44px; box-sizing: border-box; border-radius: 50%; border: 3px solid rgba(26,140,255,.18); border-top-color: #1a8cff; animation: zm-savebar-spin .9s linear infinite; margin-bottom: 16px; } .zm-reboot-title { margin: 0 0 8px !important; padding: 0 !important; border: 0 !important; font-size: 19px !important; font-weight: 750; line-height: 1.25; letter-spacing: -.01em; } .zm-reboot-title::before { display: none !important; } .zm-reboot-text { margin: 0; max-width: 300px; font-size: 14px; line-height: 1.5; opacity: .72; text-wrap: balance; } .zm-reboot-meta { display: inline-flex; align-items: center; gap: 6px; margin-top: 16px; padding: 5px 12px; border-radius: 999px; background: rgba(127,140,160,.14); font-size: 12.5px; } .zm-reboot-meta > span:first-child { opacity: .7; } .zm-reboot-count { font-weight: 700; font-variant-numeric: tabular-nums; min-width: 2.6em; text-align: left; } @media (prefers-reduced-motion: reduce) { .zm-reboot-spin { animation-duration: 1.6s !important; animation-iteration-count: infinite !important; } } .zm-refresh-banner.zm-reboot-banner { flex-direction: row !important; align-items: center !important; flex-wrap: nowrap; gap: 12px; padding: 10px 12px 10px 16px; font-size: 13.5px; } .zm-reboot-banner > span { flex: 1 1 auto; min-width: 0; line-height: 1.4; } .zm-reboot-banner > .cbi-button { margin: 0; white-space: nowrap; } .zm-reboot-banner > .zm-banner-x { flex: 0 0 auto; width: 28px; height: 28px; min-width: 0 !important; margin: 0 !important; padding: 0 !important; border: 0 !important; border-radius: 8px; background: transparent !important; box-shadow: none !important; color: inherit !important; font-size: 20px; line-height: 1; opacity: .55; cursor: pointer; } .zm-reboot-banner > .zm-banner-x:hover { opacity: 1; background: rgba(127,127,127,.18) !important; } .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-chip.zm-route-chip-on { border-color: var(--zm-accent, #2563eb); background: rgba(37,99,235,.12); font-weight: 600; } .zm-route-devs { margin: -4px 0 12px; } .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-where { line-height: 1.5; overflow-wrap: anywhere; } .zm-route-where + .zm-route-where { margin-top: 2px; } .zm-route-where-off { opacity: .6; } .zm-route-entry { font-size: 12px; padding: 0 4px; border-radius: 4px; background: rgba(127,127,127,.14); } .zm-route-tag { display: inline-block; margin-left: 6px; padding: 0 6px; border-radius: 999px; font-size: 11px; line-height: 1.6; border: 1px solid rgba(127,127,127,.35); opacity: .85; } .zm-route-tag-win { border-color: #2ea043; color: #2ea043; opacity: 1; } .zm-fold { margin-top: 14px; border-top: 1px solid rgba(0,0,0,.08); padding-top: 10px; } html.zm-theme-dark .zm-fold { border-top-color: rgba(255,255,255,.1); } .zm-fold-head { display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600; font-size: 13px; user-select: none; flex-wrap: wrap; } .zm-fold-head:hover { opacity: .85; } .zm-fold-open > .zm-fold-head .zm-svc-chev { transform: rotate(90deg); } .zm-fold-sum { font-weight: 400; font-size: 12px; opacity: .6; overflow-wrap: anywhere; } .zm-fold-body { padding: 4px 0 0 22px; } .zm-fold-body h4 { margin: 16px 0 4px; } .zm-fold-body h4:first-child { margin-top: 8px; } @media (max-width: 600px) { .zm-fold-body { padding-left: 0; } } .zm-fk-traffic { font-variant-numeric: tabular-nums; } @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-wn-wrap { background: rgba(10,12,18,.45); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); animation: zmFindFade .15s ease-out; } html.zm-wn-lock, html.zm-wn-lock body { overflow: hidden; } .zm-wn-head { display: flex; align-items: center; gap: 12px; margin: 0 0 10px; } .zm-wn-head h3 { margin: 0 !important; line-height: 1.3; } .zm-wn { max-width: 560px; max-height: calc(100vh - 32px); max-height: calc(100dvh - 32px); display: flex; flex-direction: column; overflow: hidden; padding: 22px 24px 18px; border-radius: 18px; } .zm-wn-body { flex: 1 1 auto; min-height: 0; overflow: auto; margin: 0 -6px; padding: 0 6px; overscroll-behavior: contain; } .zm-wn-foot { flex: 0 0 auto; margin: 0 !important; padding-top: 14px; } .zm-wn-ico { flex: 0 0 auto; width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; margin: 0; background: rgba(217,119,6,.14); color: #d97706; font-size: 21px; font-weight: 800; } .zm-wn h3 { margin: 0 0 8px !important; font-size: 20px !important; } .zm-wn-lead { margin: 0 0 10px; font-size: 14px; line-height: 1.5; opacity: .8; } .zm-wn-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } .zm-wn-list li { display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 6px; padding: 8px 12px; border-radius: 12px; background: rgba(110,118,129,.09); font-size: 13.5px; line-height: 1.45; } .zm-wn-list li i { font-style: normal; font-weight: 800; color: #d97706; text-align: center; } .zm-wn-list li b { font-size: 13.5px; } .zm-wn-list li span { opacity: .72; } .zm-wn-check { display: flex; align-items: flex-start; gap: 10px; margin: 12px 0 2px; padding: 10px 14px; border-radius: 12px; border: 1px dashed rgba(217,119,6,.6); font-size: 13.5px; font-weight: 600; line-height: 1.45; cursor: pointer; } .zm-wn-check input { width: 18px; height: 18px; margin: 1px 0 0; flex-shrink: 0; cursor: pointer; } /* Темы, которые рисуют чекбокс переключателем (proton2025 и др.): возвращаем обычную галочку */ html .zm-wn-check input[type="checkbox"], html input[type="checkbox"].zm-pg-check { -webkit-appearance: checkbox; appearance: auto; position: static; display: inline-block; width: 18px; height: 18px; min-width: 0; min-height: 0; padding: 0; background: none; border: 0; border-radius: 0; box-shadow: none; transform: none; transition: none; flex: 0 0 auto; } html input[type="checkbox"].zm-pg-check { width: 16px; height: 16px; margin: 0; } html:root input[type="checkbox"].zm-pg-check { accent-color: #cf222e; } html .zm-wn-check input[type="checkbox"]::before, html .zm-wn-check input[type="checkbox"]::after, html input[type="checkbox"].zm-pg-check::before, html input[type="checkbox"].zm-pg-check::after { content: none; display: none; } .zm-wn-gt { margin: 14px 0 6px; font-size: 12px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; opacity: .6; } .zm-wn-group:first-child .zm-wn-gt { margin-top: 4px; } .zm-wn-list li.zm-wn-del i { color: #cf222e; } .zm-wn-list li.zm-wn-add i { color: #1a7f37; } .zm-wn-list li.zm-wn-ret i { color: #2563eb; } .zm-wn-list li.zm-wn-same i { color: #6e7681; } .zm-wn-list li.zm-wn-same { opacity: .85; } html.zm-theme-dark .zm-wn-list li.zm-wn-add i { color: #3fb950; } html.zm-theme-dark .zm-wn-list li.zm-wn-ret i { color: #60a5fa; } html.zm-theme-dark .zm-wn-list li.zm-wn-del i { color: #f85149; } .zm-bk-item { margin-top: 10px; } .zm-bk-head { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; } .zm-bk-head > b { font-size: 14.5px; overflow-wrap: anywhere; } .zm-bk-sub { margin: 0 0 0 auto !important; } .zm-bk-h4 { margin: 18px 0 0; } .zm-bk-h4 .zm-hint { font-weight: 400; margin: 0; } .zm-bk-empty { margin: 10px 0 0; } .zm-bk-note { margin: 12px 0 0; } .zm-bk-note + .zm-bk-note { margin-top: 6px; } @media (max-width: 600px) { .zm-bk-sub { margin-left: 0 !important; flex-basis: 100%; } } .zm-wn-tip { margin: 10px 0 0; padding: 9px 12px; border-radius: 12px; background: rgba(37,99,235,.08); font-size: 13px; line-height: 1.5; } html.zm-theme-dark .zm-wn-tip { background: rgba(96,165,250,.1); } .zm-wn .zm-dlg-note { margin-top: 12px; } .zm-wn-btn-off, .zm-wn-foot .cbi-button[disabled] { opacity: .45; cursor: not-allowed; } .zm-wn-info .zm-wn-ico { background: rgba(59,130,246,.14); color: #3b82f6; font-family: Georgia, "Times New Roman", serif; font-style: italic; } .zm-wn-info .zm-wn-list li { grid-template-columns: 20px minmax(0, 1fr); } .zm-wn-info .zm-wn-list li i { color: #3b82f6; font-weight: 700; } html.zm-theme-dark .zm-wn-list li { background: rgba(255,255,255,.05); } @media (max-width: 600px) { .zm-wn { padding: 20px 16px 14px; } .zm-wn h3 { font-size: 18px !important; } .zm-wn-foot .cbi-button { flex: 1 1 auto; } } @media (max-width: 600px) { .zm-wn.zm-wn { padding: 16px 14px 12px; } .zm-wn-head { gap: 10px; margin-bottom: 8px; } .zm-wn-ico { width: 32px; height: 32px; font-size: 17px; } .zm-wn .zm-wn-head h3 { font-size: 16.5px !important; } .zm-wn-lead { font-size: 13px; line-height: 1.45; margin-bottom: 8px; } .zm-wn-list { gap: 5px; } .zm-wn-list li { padding: 6px 10px; font-size: 12.5px; line-height: 1.4; } .zm-wn-list li b { font-size: 12.5px; } .zm-wn-tip { margin-top: 8px; padding: 7px 10px; font-size: 12.5px; line-height: 1.4; } .zm-wn .zm-dlg-note { margin-top: 8px; font-size: 12.5px; } .zm-wn-check { margin-top: 8px; padding: 8px 10px; font-size: 12.5px; line-height: 1.4; } .zm-wn-foot { padding-top: 10px; } .zm-ts-steps { gap: 6px; } .zm-ts-steps li { font-size: 12.5px; line-height: 1.45; grid-template-columns: 24px minmax(0, 1fr); gap: 8px; } .zm-ts-steps li::before { width: 22px; height: 22px; font-size: 12px; } .zm-ts-cmd { margin-top: 6px; padding: 7px 7px 7px 10px; font-size: 12.5px; } } .zm-wn-wrap { z-index: 9990; } .zm-wn:focus { outline: none; } .zm-ts { max-width: 580px; } .zm-ts-where { display: inline; } .zm-ts-where span + span { margin-left: 4px; } .zm-ts-where span { padding: 1px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; background: rgba(110,118,129,.14); font-family: ui-monospace, "SF Mono", Consolas, monospace; } .zm-ts-steps { counter-reset: zmts; list-style: none; margin: 4px 0 0; padding: 0; display: flex; flex-direction: column; gap: 9px; } .zm-ts-steps li { counter-increment: zmts; display: grid; grid-template-columns: 28px minmax(0, 1fr); gap: 10px; align-items: start; font-size: 13.5px; line-height: 1.55; } .zm-ts-steps li::before { content: counter(zmts); width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; font-weight: 700; font-size: 13px; background: rgba(26,163,255,.14); color: #0969da; } .zm-ts-cmd { display: flex; align-items: center; gap: 10px; margin: 8px 0 2px; padding: 9px 9px 9px 14px; border-radius: 12px; background: #0d1117; color: #e6edf3; font-family: ui-monospace, "SF Mono", "Cascadia Code", Consolas, monospace; font-size: 13.5px; } .zm-ts-cmd code { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; background: none; color: inherit; padding: 0; user-select: all; -webkit-user-select: all; } .zm-ts-cmd .cbi-button { flex-shrink: 0; margin: 0 !important; } .zm-afix-list { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; } .zm-swrow.zm-afix-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; margin: 0; padding: 10px 12px; border-radius: 12px; border: 1px solid rgba(127,127,127,.22); background: rgba(127,127,127,.05); cursor: pointer; } .zm-afix-row .zm-swrow-text { min-width: 0; } .zm-afix-row .zm-hint { margin: 2px 0 0; overflow-wrap: anywhere; } .zm-afix-st { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; text-align: right; } .zm-afix-note { font-size: 12px; opacity: .7; } .zm-afix-mode { margin-top: 14px; } .zm-afix-mode > .zm-label { min-width: 0 !important; } .zm-seg.zm-afix-seg { display: inline-grid; grid-template-columns: repeat(4, auto); } .zm-afix-seg .zm-seg-item { text-align: center; } .zm-afix-log { margin-top: 12px; } @media (max-width: 420px) { .zm-swrow.zm-afix-row { grid-template-columns: auto minmax(0, 1fr); row-gap: 6px; } .zm-afix-row > .zm-afix-st { grid-column: 2; align-items: flex-start; text-align: left; } .zm-afix-row .zm-afix-st .zm-badge { white-space: normal; } } .zm-seg.zm-seg-3 { display: inline-grid; grid-template-columns: repeat(3, auto); } .zm-seg.zm-seg-wrap { flex-wrap: wrap; max-width: 100%; } .zm-seg-3 .zm-seg-item { text-align: center; } .zm-st-ext-list { margin-top: 12px; } .zm-swrow.zm-st-ext-row { outline: none; transition: border-color .15s, box-shadow .15s, background .15s; } .zm-swrow.zm-st-ext-row:focus-visible { box-shadow: 0 0 0 3px rgba(26,163,255,.35); } .zm-swrow.zm-st-ext-row.zm-st-ext-on { border-color: #1aa3ff; box-shadow: inset 0 0 0 1px #1aa3ff; background: rgba(26,163,255,.07); } .zm-st-radio { width: 18px; height: 18px; border-radius: 50%; border: 2px solid rgba(127,127,127,.55); box-sizing: border-box; display: grid; place-items: center; flex: 0 0 auto; transition: border-color .15s; } .zm-st-ext-on .zm-st-radio { border-color: #1aa3ff; } .zm-st-ext-on .zm-st-radio::after { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #1aa3ff; } .zm-st-ext-empty { margin-top: 12px; padding: 14px 16px; border-radius: 12px; border: 1px dashed rgba(127,127,127,.4); } .zm-st-ext-empty-t { font-weight: 600; margin-bottom: 4px; } .zm-st-ext-empty .zm-hint { margin: 0; } .zm-st-ext-line { margin: -4px 0 6px; overflow-wrap: anywhere; } .zm-st-name { display: flex; flex: 1 1 auto; gap: 8px; align-items: center; flex-wrap: wrap; min-width: 0; } .zm-st-line { display: inline-flex; align-items: center; gap: 6px 10px; flex-wrap: wrap; min-width: 0; overflow-wrap: anywhere; } .zm-st-secdel { margin-top: 16px; } .zm-st-ext-link { font-size: 13px; color: inherit; opacity: .75; text-decoration: underline; text-underline-offset: 2px; } .zm-st-ext-link:hover { opacity: 1; } #zmw-view .zm-swrow.zm-st-ext-row.zm-st-ext-on { border-color: var(--a1); box-shadow: inset 0 0 0 1px var(--a1); background: var(--grad-soft); } #zmw-view .zm-st-ext-on .zm-st-radio { border-color: var(--a1); } #zmw-view .zm-st-ext-on .zm-st-radio::after { background: var(--a1); } #zmw-view .zm-swrow.zm-st-ext-row:focus-visible { box-shadow: 0 0 0 3px var(--ring); } @media (max-width: 600px) { .zm-seg.zm-seg-3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); width: 100%; } .zm-seg-3 .zm-seg-item { white-space: normal; line-height: 1.25; padding: 7px 6px; display: flex; align-items: center; justify-content: center; overflow: visible; } .zm-st-mode { flex-wrap: wrap; } } @media (max-width: 600px) { .zm-swrow.zm-afix-row { grid-template-columns: auto minmax(0, 1fr); } .zm-afix-st { grid-column: 2; align-items: flex-start; text-align: left; } .zm-seg.zm-afix-seg { grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; } } .zm-kbd { display: inline-block; padding: 0 6px; border-radius: 6px; border: 1px solid rgba(110,118,129,.45); border-bottom-width: 2px; font: 600 12px/1.6 inherit; font-family: inherit; } .zm-ts .zm-dlg-note { margin-top: 12px; } html.zm-theme-dark .zm-ts-steps li::before { color: #58a6ff; } @media (max-width: 600px) { .zm-ts-cmd { flex-wrap: wrap; } .zm-ts-cmd .cbi-button { flex: 1 1 100%; } } .zm-dlg-wrap { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; padding: 16px; background: rgba(0,0,0,.5); } .zm-dlg { width: 100%; max-width: 540px; max-height: 88vh; overflow: auto; box-shadow: 0 12px 40px rgba(0,0,0,.3); } .zm-dlg h3 { font-size: 17px; margin-bottom: 8px; } .zm-dlg-intro { margin: 0 0 6px; font-size: 13px; line-height: 1.5; opacity: .8; white-space: pre-line; overflow-wrap: anywhere; } .zm-dlg-input { width: 100% !important; box-sizing: border-box; margin: 4px 0 0; } .zm-dlg-title { margin: 16px 0 6px; font-size: 13px; font-weight: 600; } .zm-dlg-list { margin: 0; padding-left: 20px; font-size: 13px; line-height: 1.6; } .zm-dlg-note { margin: 12px 0 0; padding: 9px 12px; border-radius: 10px; font-size: 12.5px; line-height: 1.5; background: rgba(110,118,129,.12); } .zm-dlg-note-warn { background: rgba(191,135,0,.14); color: #9a6700; } .zm-dlg-note-bad { background: rgba(207,34,46,.10); color: #cf222e; } .zm-dlg-opt { display: flex; align-items: flex-start; gap: 11px; margin: 7px 0; padding: 10px 12px; border: 1px solid rgba(110,118,129,.3); border-radius: 10px; cursor: pointer; transition: border-color .15s, background .15s; } .zm-dlg-opt:hover { border-color: rgba(26,163,255,.6); } .zm-dlg-opt.zm-active { border-color: #1aa3ff; background: rgba(26,163,255,.08); cursor: default; } .zm-dlg-opt.zm-dlg-opt-off { opacity: .5; cursor: not-allowed; } .zm-dlg-opt .zm-hint { margin: 2px 0 0; } .zm-dlg-radio { width: 16px; height: 16px; margin-top: 2px; border-radius: 50%; border: 2px solid rgba(110,118,129,.6); flex-shrink: 0; display: flex; align-items: center; justify-content: center; box-sizing: border-box; } .zm-dlg-opt.zm-active .zm-dlg-radio { border-color: #1aa3ff; } .zm-dlg-opt.zm-active .zm-dlg-radio > span { width: 8px; height: 8px; border-radius: 50%; background: #1aa3ff; } .zm-hint.zm-dlg-warn { opacity: 1; color: #9a6700; } .zm-dlg-actions { justify-content: flex-end; margin: 18px 0 0; } .zm-dlg-actions.zm-dlg-actions-3 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .zm-dlg-actions.zm-dlg-actions-3 > .cbi-button { width: 100%; margin: 0; } .zm-dlg-actions.zm-dlg-actions-3 > .cbi-button:first-child { grid-column: 1 / -1; } @media (max-width: 480px) { .zm-dlg-actions.zm-dlg-actions-3 { grid-template-columns: 1fr; } } html.zm-theme-dark .zm-dlg-note-warn, html.zm-theme-dark .zm-hint.zm-dlg-warn { color: #e3b341; } html.zm-theme-dark .zm-dlg-note-bad { color: #ff7b72; } @media (max-width: 600px) { .zm-dlg-actions .cbi-button { flex: 1 1 auto; } } .zm-ab-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 0 0 10px; } .zm-ab-head h3 { margin: 0 !important; } .zm-ab-head > .zm-badge { margin-left: auto; } .zm-ab-lead { margin: 0; max-width: 760px; } .zm-ab-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 14px 0 2px; } .zm-ab-hero .zm-refresh-banner { margin: 12px 0 0; } .zm-ab-facts { margin: 16px 0 0; gap: 14px 36px; } .zm-ab-stats { margin: 18px 0 4px; } .zm-ab-stats-off { display: flex; align-items: center; gap: 12px 18px; flex-wrap: wrap; padding: 14px 16px; border-radius: 14px; border: 1px dashed rgba(127,127,127,.35); } .zm-ab-stats-off > div { flex: 1 1 320px; } .zm-ab-stats-head { display: flex; align-items: center; gap: 10px 14px; flex-wrap: wrap; margin-bottom: 12px; } .zm-ab-stats-head > b { font-size: 15px; } .zm-ab-tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; } .zm-ab-tile { display: flex; align-items: flex-start; gap: 12px; padding: 14px 16px; border-radius: 14px; border: 1px solid rgba(127,127,127,.22); background: rgba(127,127,127,.05); min-width: 0; } .zm-ab-tile-body { display: flex; flex-direction: column; min-width: 0; } .zm-ab-tile-label { font-size: 13px; font-weight: 600; opacity: .85; } .zm-ab-tile-body { flex: 1 1 auto; } .zm-ab-tile-num { font-size: 26px; line-height: 1.15; font-weight: 700; font-variant-numeric: tabular-nums; margin: 2px 0; white-space: nowrap; } .zm-ab-tile-sub { font-size: 12px; opacity: .65; } .zm-ab-ranks { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 24px; margin-top: 16px; } .zm-ab-rank h4 { margin: 0 0 8px; font-size: 13.5px; } .zm-ab-rank-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-top: 1px solid rgba(127,127,127,.15); } .zm-ab-rank-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 4px; } .zm-ab-rank-line { display: flex; gap: 10px; align-items: baseline; } .zm-ab-rank-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; } .zm-ab-rank-n { font-variant-numeric: tabular-nums; font-weight: 600; font-size: 13px; } .zm-ab-bar { display: block; height: 4px; border-radius: 2px; background: rgba(127,127,127,.15); overflow: hidden; } .zm-ab-bar > i { display: block; height: 100%; border-radius: 2px; background: #16a34a; } .zm-ab-rank-btn { margin: 0 !important; padding: 2px 10px !important; font-size: 12px !important; } .zm-ab-stats-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-top: 14px; } @media (max-width: 900px) { .zm-ab-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } .zm-ab-ranks { grid-template-columns: minmax(0, 1fr); } } @media (max-width: 420px) { .zm-ab-tile { padding: 12px; gap: 10px; } .zm-ab-tile-num { font-size: 22px; } } .zm-ab-facts .zm-sub-fact > b { font-size: 15px; } .zm-ab-actions { margin: 16px 0 0; } .zm-ab-seg { display: flex; flex-wrap: wrap; width: max-content; max-width: 100%; box-sizing: border-box; } .zm-ab-seg .zm-seg-item { user-select: none; } .zm-ab-seg .zm-seg-item:focus-visible { outline: 2px solid #1aa3ff; outline-offset: 1px; } .zm-ab-desc { margin: 12px 0 6px; padding: 12px 14px; border-radius: 12px; border: 1px solid rgba(127,127,127,.22); background: rgba(127,127,127,.05); max-width: 760px; box-sizing: border-box; } .zm-ab-desc-head { display: flex; align-items: center; gap: 8px 10px; flex-wrap: wrap; font-size: 14px; } .zm-ab-desc-n { margin-left: auto; font-size: 12px; opacity: .65; font-variant-numeric: tabular-nums; } .zm-ab-desc .zm-hint { margin-top: 6px; } .zm-ab-pips { display: inline-flex; gap: 3px; } .zm-ab-pips > i { display: block; width: 14px; height: 5px; border-radius: 3px; background: rgba(127,127,127,.28); } .zm-ab-pips > i.zm-on { background: #1a7f37; } .zm-ab-warn { color: #9a6700; } .zm-hint.zm-ab-warn { opacity: 1; } .zm-hint .zm-ab-warn { color: #6b4700; font-weight: 600; } html.zm-theme-dark .zm-hint .zm-ab-warn { color: #f0c05a; } html.zm-theme-dark .zm-ab-warn { color: #e3b341; } .zm-config-editor.zm-ab-editor { min-height: 190px; height: 190px; resize: vertical; } @media (max-width: 600px) { .zm-ab-seg { width: 100%; } .zm-ab-seg .zm-seg-item { flex: 1 1 auto; text-align: center; padding: 6px 10px; } .zm-ab-desc-n { margin-left: 0; width: 100%; } } .zm-mon { max-width: 1280px; --zm-acc: var(--a1, #7c5cff); --zm-acc2: var(--a2, #3b82f6); --zm-acc3: var(--a3, #22d3ee); --zm-c-cpu: #7c5cff; --zm-c-mem: #10b981; --zm-c-temp: #f59e0b; --zm-c-flash: #0ea5e9; } .zm-mon [hidden] { display: none !important; } .zm-mon-over { display: flex; flex-direction: column; gap: inherit; } .zm-mon .zm-card h3 { margin: 0; } .zm-mon-head { display: flex; align-items: center; justify-content: space-between; gap: 12px 16px; flex-wrap: wrap; } .zm-mon-head-l { display: flex; flex-direction: column; gap: 4px; min-width: 0; } .zm-mon-head-r { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .zm-mon-head-r .cbi-button { margin: 0; } .zm-mon-sub { font-size: 12.5px; opacity: .65; } .zm-mon-dev { font-size: 15px; font-weight: 600; line-height: 1.35; overflow-wrap: anywhere; } .zm-mon-cap { font-size: 11.5px; letter-spacing: .06em; text-transform: uppercase; opacity: .6; } .zm-mon-big { font-size: 15px; font-weight: 700; line-height: 1.3; } .zm-mon-gauge-t .zm-mon-sub { line-height: 1.35; } .zmw-body .zm-mon-head h3 { display: none; } .zm-mon-gauges { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 14px; } .zm-mon-gauge { display: flex; align-items: center; gap: 16px; } .zm-mon-gauge svg { flex: none; } .zm-mon-gauge-t { display: flex; flex-direction: column; gap: 3px; min-width: 0; } .zm-mon-track { stroke: rgba(127,127,127,.16); } .zm-mon-arc { transition: stroke-dasharray .6s ease, stroke .3s; } .zm-mon-arc.zm-mon-acc, .zm-mon-arc.zm-mon-cpu { stroke: var(--zm-c-cpu); } .zm-mon-arc.zm-mon-mem { stroke: var(--zm-c-mem); } .zm-mon-arc.zm-mon-temp { stroke: var(--zm-c-temp); } .zm-mon-arc.zm-mon-flash { stroke: var(--zm-c-flash); } .zm-mon-arc.zm-mon-ok { stroke: #22c55e; } .zm-mon-arc.zm-mon-warn { stroke: #f59e0b; } .zm-mon-arc.zm-mon-bad { stroke: #ef4444; } .zm-mon-arc.zm-mon-off { stroke: transparent; } .zm-mon-ringtxt { font-size: 17px; font-weight: 700; fill: currentColor; font-family: inherit; } .zm-mon-row { display: grid; gap: 14px; align-items: stretch; } .zm-mon-row-21 { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); } @media (max-width: 900px) { .zm-mon-row-21 { grid-template-columns: minmax(0, 1fr); } } .zm-mon-col { display: flex; flex-direction: column; gap: 14px; min-width: 0; } .zm-mon-col > .zm-card:last-child { flex: 1 1 auto; } .zm-mon .zm-card { box-sizing: border-box; } .zm-mon-gauges > .zm-card, .zm-mon-row > .zm-card { height: 100%; } .zm-mon-flex { display: flex; flex-direction: column; } .zm-mon-flex > .zm-mon-cardfoot { margin-top: auto; padding-top: 10px; } #zmw-view .zm-mon .zm-card { margin: 0 !important; } .zm-mon-cardhead { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; } .zm-mon-cardfoot { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-top: 10px; } .zm-mon-cardfoot .cbi-button { margin: 0; } .zm-mon-chartbox { flex: 1 1 auto; min-height: 190px; display: grid; grid-template-columns: 38px minmax(0, 1fr) 30px; grid-template-rows: minmax(170px, 1fr) 22px; } .zm-mon-plot { position: relative; min-height: 170px; touch-action: pan-y; cursor: crosshair; } .zm-mon-ax { position: relative; font-size: 11.5px; color: var(--muted, #6e7781); font-variant-numeric: tabular-nums; } .zm-mon-ax span { position: absolute; transform: translateY(-50%); white-space: nowrap; line-height: 1; } .zm-mon-ax-l span { right: 8px; } .zm-mon-ax-r span { left: 6px; } .zm-mon-ax-x { position: relative; font-size: 11.5px; color: var(--muted, #6e7781); } .zm-mon-ax-x span { position: absolute; top: 6px; transform: translateX(-50%); white-space: nowrap; line-height: 1; } .zm-mon-ax-x span:first-child { transform: none; } .zm-mon-ax-x span:last-child { transform: translateX(-100%); } html.zm-theme-dark .zm-mon-ax, html.zm-theme-dark .zm-mon-ax-x { color: #8b949e; } .zm-mon-xline, .zm-mon-xdot, .zm-mon-xtip { display: none; position: absolute; pointer-events: none; } .zm-mon-hover .zm-mon-xline, .zm-mon-hover .zm-mon-xdot, .zm-mon-hover .zm-mon-xtip { display: block; } .zm-mon-xline { top: 0; bottom: 0; width: 0; border-left: 1px dashed rgba(127,127,127,.7); } .zm-mon-xdot { width: 10px; height: 10px; margin: -5px 0 0 -5px; border-radius: 50%; box-shadow: 0 0 0 2px var(--surface-solid, #fff); } html.zm-theme-dark .zm-mon-xdot { box-shadow: 0 0 0 2px #1c2128; } .zm-mon-xdot.zm-mon-l-cpu { background: var(--zm-c-cpu); } .zm-mon-xdot.zm-mon-l-mem { background: var(--zm-c-mem); } .zm-mon-xdot.zm-mon-l-temp { background: var(--zm-c-temp); } .zm-mon-xtip { top: 6px; margin-left: 12px; z-index: 2; display: none; min-width: 150px; padding: 8px 10px; border-radius: 10px; font-size: 12.5px; line-height: 1.55; white-space: nowrap; background: var(--surface-solid, #fff); color: inherit; border: 1px solid rgba(127,127,127,.25); box-shadow: 0 8px 24px -10px rgba(0,0,0,.35); } html.zm-theme-dark .zm-mon-xtip { background: #1c2128; } .zm-mon-hover .zm-mon-xtip { display: flex; flex-direction: column; } .zm-mon-xtip.zm-mon-xtip-l { margin-left: 0; transform: translateX(calc(-100% - 12px)); } .zm-mon-xtip > b { font-weight: 600; margin-bottom: 2px; } .zm-mon-xtip span { display: flex; align-items: center; gap: 7px; } .zm-mon-xtip span b { margin-left: auto; padding-left: 12px; font-weight: 600; font-variant-numeric: tabular-nums; } .zm-mon-xtip span i { width: 8px; height: 8px; border-radius: 50%; flex: none; } .zm-mon-xtip i.zm-mon-l-cpu { background: var(--zm-c-cpu); } .zm-mon-xtip i.zm-mon-l-mem { background: var(--zm-c-mem); } .zm-mon-xtip i.zm-mon-l-temp { background: var(--zm-c-temp); } @media (max-width: 600px) { .zm-mon-ax-x span:nth-child(2), .zm-mon-ax-x span:nth-child(4), .zm-mon-ax-x span:nth-child(5) { display: none; } .zm-mon-chartbox { grid-template-columns: 32px minmax(0, 1fr) 26px; } .zm-mon-ax-l span { right: 5px; } .zm-mon-ax-r span { left: 4px; } } .zm-mon-chart { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: block; overflow: visible; } .zm-mon-grid { stroke: rgba(127,127,127,.28); stroke-width: 1; vector-effect: non-scaling-stroke; } .zm-mon-grid.zm-mon-grid-d, .zm-mon-grid.zm-mon-grid-v { stroke: rgba(127,127,127,.18); stroke-dasharray: 3 4; } .zm-mon-area { fill: var(--zm-c-cpu); opacity: .14; } .zm-mon-nchart { display: block; width: 100%; height: 84px; margin: 0 0 8px; } .zm-mon-nrx, .zm-mon-ntx { stroke-width: 1.8; stroke-linejoin: round; vector-effect: non-scaling-stroke; fill-opacity: .2; } .zm-mon-nrx { stroke: var(--zm-acc2); fill: var(--zm-acc2); } .zm-mon-ntx { stroke: var(--zm-c-mem); fill: var(--zm-c-mem); } .zm-mon-nleg { display: flex; justify-content: space-between; gap: 6px 14px; flex-wrap: wrap; font-size: 13px; margin: 0 0 14px; } .zm-mon-nleg i { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: 1px; } .zm-mon-nleg i.zm-mon-nrx { background: var(--zm-acc2); } .zm-mon-nleg i.zm-mon-ntx { background: var(--zm-c-mem); } .zm-mon-nleg small { opacity: .6; margin-left: 6px; } .zm-mon-netsel { font-size: 12.5px; padding: 3px 8px; height: auto; max-width: 60%; } .zm-mon-fan { gap: 6px; } .zm-mon-fan svg { width: 14px; height: 14px; flex: none; } .zm-mon-fan.zm-ok svg { animation: zm-mon-spin 1.4s linear infinite; } @keyframes zm-mon-spin { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { .zm-mon-fan.zm-ok svg { animation: none; } } .zm-mon-line { fill: none; stroke-width: 2.4; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; } .zm-mon-line.zm-mon-l-cpu { stroke: var(--zm-c-cpu); } .zm-mon-line.zm-mon-l-mem { stroke: var(--zm-c-mem); } .zm-mon-line.zm-mon-l-temp { stroke: #f59e0b; stroke-dasharray: 5 5; stroke-width: 2; } .zm-mon-legs, .zm-mon-stackleg { display: flex; gap: 8px 16px; flex-wrap: wrap; font-size: 12.5px; } .zm-mon-leg { display: inline-flex; align-items: center; gap: 7px; opacity: .85; } .zm-mon-leg i { width: 12px; height: 12px; border-radius: 4px; display: inline-block; flex: none; } .zm-mon-leg i.zm-mon-l-cpu { background: var(--zm-c-cpu); height: 4px; } .zm-mon-leg i.zm-mon-l-mem { background: var(--zm-c-mem); height: 4px; } .zm-mon-leg i.zm-mon-l-temp { background: #f59e0b; height: 4px; } .zm-mon-cores { display: flex; flex-direction: column; gap: 12px; } .zm-mon-meter { display: flex; flex-direction: column; gap: 6px; margin: 0 0 12px; } .zm-mon-meter:last-child { margin-bottom: 0; } .zm-mon-meter-h { display: flex; justify-content: space-between; gap: 10px; font-size: 13px; } .zm-mon-bar { height: 9px; border-radius: 6px; background: rgba(127,127,127,.16); overflow: hidden; } .zm-mon-fill { height: 100%; border-radius: 6px; transition: width .6s ease; } .zm-mon-fill.zm-mon-acc { background: linear-gradient(90deg, var(--zm-c-cpu), #a78bfa); } .zm-mon-fill.zm-mon-acc2 { background: linear-gradient(90deg, var(--zm-acc2), var(--zm-acc3)); } .zm-mon-fill.zm-mon-warn { background: #f59e0b; } .zm-mon-fill.zm-mon-bad { background: #ef4444; } .zm-mon-fill.zm-mon-heat { background: linear-gradient(90deg, #22c55e 0, #eab308 60%, #f97316 75%, #ef4444 90%); background-size: 100vw 100%; } .zm-mon-split { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 14px; } .zm-mon-tile { display: flex; flex-direction: column; gap: 3px; padding: 10px 12px; border-radius: 10px; background: rgba(127,127,127,.08); min-width: 0; } .zm-mon-tile > span { font-size: 12px; opacity: .65; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .zm-mon-rate { font-size: 20px; font-weight: 700; } .zm-mon-rate small { font-size: 12px; font-weight: 500; opacity: .65; } .zm-mon-kv { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; padding: 5px 0; } .zm-mon-kv > span { opacity: .65; } .zm-mon-kv > b { font-weight: 600; text-align: right; overflow-wrap: anywhere; } .zm-mon-t-ok { color: #15803d; } .zm-mon-t-warn { color: #b45309; } .zm-mon-t-bad { color: #dc2626; } html.zm-theme-dark .zm-mon-t-ok { color: #4ade80; } html.zm-theme-dark .zm-mon-t-warn { color: #fbbf24; } html.zm-theme-dark .zm-mon-t-bad { color: #f87171; } .zm-mon-stack { display: flex; height: 24px; border-radius: 8px; overflow: hidden; gap: 2px; margin-bottom: 12px; background: rgba(127,127,127,.08); } .zm-mon-stackseg { height: 100%; transition: width .6s ease; min-width: 2px; } .zm-mon-stackseg.zm-mon-seg-sys, .zm-mon-leg i.zm-mon-seg-sys { background: repeating-linear-gradient(45deg, rgba(127,127,127,.35) 0 4px, rgba(127,127,127,.2) 4px 8px); } .zm-mon-stackseg.zm-mon-seg-free { background: transparent; } .zm-mon-leg i.zm-mon-seg-free { background: rgba(127,127,127,.16); border: 1px solid rgba(127,127,127,.3); box-sizing: border-box; } .zm-mon-seg { margin: 0; } .zm-mon-tablebox { overflow-x: auto; -webkit-overflow-scrolling: touch; } .zm-mon-table { width: 100%; border-collapse: collapse; font-size: 13px; } .zm-mon-table th { text-align: left; font-weight: 500; color: var(--muted, #6e7781); padding: 6px 8px; white-space: nowrap; } html.zm-theme-dark .zm-mon-table th { color: #8b949e; } .zm-mon-table td { padding: 8px; border-top: 1px solid rgba(127,127,127,.14); vertical-align: middle; white-space: nowrap; } .zm-mon-table .zm-r { text-align: right; } .zm-mon-mono { font-family: ui-monospace, "JetBrains Mono", "SF Mono", Consolas, monospace; font-size: 12.5px; } .zm-mon-tag { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; color: var(--zm-tag); background: rgba(127,127,127,.1); box-shadow: inset 0 0 0 1px var(--zm-tag); } .zm-mon-table td.zm-mon-cmd { width: 100%; max-width: 0; min-width: 160px; overflow: hidden; text-overflow: ellipsis; opacity: .8; } .zm-mon-table td:last-child, .zm-mon-table th:last-child { width: 1%; } @media (max-width: 1000px) { .zm-mon-procs .zm-mon-hide-s { display: none; } } @media (max-width: 600px) { .zm-mon-procs .zm-mon-hide-xs { display: none; } .zm-mon-procs .zm-mon-table th, .zm-mon-procs .zm-mon-table td { padding: 7px 4px; } .zm-mon-procs .zm-mon-table td.zm-mon-pname { width: 100%; max-width: 0; overflow: hidden; text-overflow: ellipsis; } .zm-mon-procs .zm-mon-table { font-size: 12.5px; } .zm-mon-procs .zm-mon-mono { font-size: 12px; } .zm-mon-procs .zm-mon-kill { padding: 3px 8px !important; font-size: 11.5px !important; } } .zm-mon-kill { margin: 0 !important; padding: 3px 10px !important; font-size: 12px !important; } .zm-mon-pwrap { align-items: center; } .zm-mon-procs { width: min(1200px, calc(100vw - 32px)) !important; max-width: none !important; max-height: calc(100vh - 48px); display: flex; flex-direction: column; box-sizing: border-box; } .zm-mon-ptools { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; } .zm-mon-search { flex: 1 1 240px; min-width: 0; height: 38px; box-sizing: border-box; } .zm-mon-ptable { flex: 1 1 auto; min-height: 0; overflow: auto; } .zm-mon-ptable thead th { position: sticky; top: 0; background: var(--background-color-medium, #fff); z-index: 1; } html.zm-theme-dark .zm-mon-ptable thead th { background: #1c2128; } .zmw-body .zm-mon-ptable thead th { background: var(--surface-solid, #121722); } .zm-mon-sortable button { all: unset; cursor: pointer; } .zm-mon-sortable button:focus-visible { outline: 2px solid var(--zm-acc); outline-offset: 2px; border-radius: 4px; } .zm-mon-sortable.zm-active { color: var(--zm-acc); } .zm-mon-info { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; } @media (max-width: 1100px) { .zm-mon-info { grid-template-columns: repeat(2, minmax(0, 1fr)); } } .zm-mon-info .zm-mon-tile > b { font-weight: 600; overflow-wrap: anywhere; } .zm-mon-empty { text-align: center; opacity: .65; padding: 18px 8px !important; white-space: normal !important; } .zm-mon-tabs { margin: 0; } .zm-mon-help { margin: 0 !important; width: 38px; height: 38px; padding: 0 !important; border-radius: 50% !important; font-weight: 700; font-size: 16px; flex: none !important; } .zm-mon-tabs .zm-seg-item { min-width: 72px; text-align: center; } .zm-mon-hcard { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 10px; padding: 36px 20px !important; } .zm-mon-hcard .zm-hint { max-width: 560px; margin: 0; white-space: pre-line; line-height: 1.6; } .zm-mon-hcard .zm-log { width: 100%; max-width: 760px; text-align: left; margin: 6px 0 0; } .zm-mon-hbtns { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; } .zm-mon-hbtns .cbi-button { margin: 0; } .zm-mon-hicon { display: flex; gap: 4px; align-items: flex-end; height: 34px; } .zm-mon-hicon i { width: 9px; border-radius: 3px; background: var(--zm-c-mem); } .zm-mon-hicon i:nth-child(1) { height: 40%; background: var(--zm-c-cpu); } .zm-mon-hicon i:nth-child(2) { height: 100%; } .zm-mon-hicon i:nth-child(3) { height: 65%; background: var(--zm-c-temp); } .zm-mon-hterm { padding: 0 !important; overflow: hidden; } .zm-mon-hterm .zm-term { margin: 0; border-radius: 0; } .zm-mon-hterm .zm-term-bar { display: flex; align-items: center; gap: 10px; } .zm-mon-hterm .zm-term-bar .cbi-button { margin: 0 0 0 4px; padding: 3px 12px; } .zm-mon-hterm .zm-mon-cardfoot { margin: 0; padding: 10px 14px 12px; } .zm-mon-hfoot { margin: 0 !important; flex: 1 1 320px; } .zm-mon-hacts { flex: none; flex-wrap: nowrap; } .zm-mon-htopframe { height: max(380px, calc(100vh - 360px)) !important; } @media (max-width: 600px) { .zm-mon-gauge { gap: 12px; } .zm-mon-gauge svg { width: 72px; height: 72px; } .zm-mon-head-r { width: 100%; } .zm-mon-head-r .cbi-button { flex: 1 1 auto; } .zm-mon-procs { width: calc(100vw - 16px) !important; max-height: calc(100vh - 16px); } .zm-mon-head-r > .zm-mon-head-r { width: 100%; } .zm-mon-tabs { flex: 1 1 auto; } .zm-mon-tabs .zm-seg-item { flex: 1 1 0; } .zm-mon-hacts { width: 100%; } .zm-mon-hacts .cbi-button { flex: 1 1 0; } } .zm-watch-list { display: flex; flex-direction: column; } .zm-watch-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 0; border-top: 1px solid rgba(127,127,127,.14); } .zm-watch-item:first-child { border-top: 0; } .zm-watch-t { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .zm-watch-t .zm-hint { margin: 0; } .zm-watch-idle .zm-watch-t { opacity: .6; } @media (max-width: 600px) { .zm-watch-item { flex-direction: column; align-items: flex-start; } } .zm-cl-ver { display: flex; align-items: baseline; gap: 8px; margin: 4px 2px 8px; font-size: 14px; font-weight: 700; } .zm-cl-ver small { font-size: 12.5px; font-weight: 500; opacity: .6; } .zm-cl-sec { margin: 12px 2px 6px; font-size: 13px; font-weight: 700; opacity: .85; } .zm-wn-list + .zm-cl-ver { margin-top: 20px; padding-top: 14px; border-top: 1px solid rgba(127,127,127,.2); } .zm-cl-cur { align-self: center; font-size: 11px; font-weight: 700; line-height: 1; padding: 4px 8px; border-radius: 999px; background: var(--grad-soft, rgba(37,99,235,.12)); color: var(--a1, #2563eb); } .zm-wn.zm-cl .zm-wn-list li { grid-template-columns: 86px minmax(0, 1fr); align-items: baseline; gap: 10px; } .zm-wn.zm-cl .zm-wn-list li.zm-cl-plain { grid-template-columns: minmax(0, 1fr); } .zm-wn.zm-cl .zm-wn-list li .zm-cl-tag { justify-self: start; opacity: 1; font-size: 11px; font-weight: 700; line-height: 1; padding: 4px 8px; border-radius: 999px; white-space: nowrap; } .zm-wn.zm-cl .zm-wn-list li .zm-cl-new { background: rgba(22,163,74,.16); color: #15803d; } .zm-wn.zm-cl .zm-wn-list li .zm-cl-imp { background: rgba(124,58,237,.13); color: #6d28d9; } .zm-wn.zm-cl .zm-wn-list li .zm-cl-fix { background: rgba(37,99,235,.13); color: #1d4ed8; } .zm-wn.zm-cl .zm-wn-list li .zm-cl-del { background: rgba(100,116,139,.16); color: #475569; } html.zm-theme-dark .zm-wn.zm-cl .zm-wn-list li .zm-cl-new { background: rgba(74,222,128,.14); color: #4ade80; } html.zm-theme-dark .zm-wn.zm-cl .zm-wn-list li .zm-cl-imp { background: rgba(167,139,250,.16); color: #c4b5fd; } html.zm-theme-dark .zm-wn.zm-cl .zm-wn-list li .zm-cl-fix { background: rgba(96,165,250,.15); color: #93c5fd; } html.zm-theme-dark .zm-wn.zm-cl .zm-wn-list li .zm-cl-del { background: rgba(148,163,184,.16); color: #cbd5e1; } .zm-cl-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .zm-cl-all { font-size: 13px; font-weight: 600; color: var(--a1, #2563eb); text-decoration: none; } .zm-cl-all:hover { text-decoration: underline; } .zm-cl-btns { display: flex; gap: 8px; } .zm-cl-foot.zm-cl-foot-solo { justify-content: flex-end; } .zm-refresh-banner .zm-cl-link, .zm-refresh-banner .zm-cl-link:hover { background: transparent !important; border: 0 !important; box-shadow: none !important; color: var(--a1, #2563eb) !important; font-weight: 600; text-decoration: underline; text-underline-offset: 3px; padding: 0 6px !important; transform: none !important; } @media (max-width: 600px) { .zm-wn.zm-cl .zm-wn-list li { grid-template-columns: minmax(0, 1fr); gap: 4px; } .zm-cl-foot { flex-direction: column-reverse; align-items: stretch; } .zm-cl-all { text-align: center; padding: 4px 0; } .zm-cl-btns .cbi-button { flex: 1 1 0; } } .zm-tsc-login, .zm-tsc-box { display: flex; flex-direction: column; gap: 8px; margin-top: 14px; padding: 12px 14px; border-radius: 12px; border: 1px solid rgba(110,118,129,.25); background: rgba(110,118,129,.06); } .zm-tsc-login { border-color: rgba(46,160,67,.4); background: rgba(46,160,67,.07); } .zm-tsc-box { border-color: rgba(191,135,0,.4); background: rgba(191,135,0,.07); } .zm-tsc-login .cbi-button { align-self: flex-start; margin: 2px 0 0; } .zm-tsc-svc { padding: 10px 0; border-top: 1px solid rgba(110,118,129,.18); } .zm-tsc-svc:first-of-type { border-top: 0; padding-top: 2px; } .zm-tsc-svc-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; } .zm-tsc .zm-ts-steps { margin: 6px 0 4px; } .zm-tsc .zm-ts-cmd { max-width: 600px; } .zm-tsc .zm-ts-cmd code { overflow-wrap: anywhere; word-break: break-all; } .zm-tsc-wait { display: flex; align-items: center; gap: 10px; padding: 6px 0 2px; font-size: 14px; opacity: .85; } @media (prefers-reduced-motion: reduce) { html .zm-tsc-spin { animation-duration: 1.2s !important; animation-iteration-count: infinite !important; } } .zm-tsc-spin { width: 18px; height: 18px; flex: none; border-radius: 50%; border: 2.5px solid rgba(127,127,127,.25); border-top-color: var(--zm-acc2, #3b82f6); animation: zm-mon-spin .8s linear infinite; } .zm-tsc-fhead { display: flex; align-items: center; justify-content: space-between; gap: 10px; cursor: pointer; user-select: none; outline: none; } .zm-tsc-fhead:hover .zm-svc-chev { opacity: .9; } .zm-tsc-fhead:focus-visible { box-shadow: 0 0 0 2px rgba(59,130,246,.5); border-radius: 6px; } .zm-tsc-fhead .zm-svc-chev { flex: none; width: 22px; text-align: center; } .zm-tsc-luci { margin-top: 4px; padding-top: 10px; border-top: 1px dashed rgba(110,118,129,.3); } .zm-tsc-luci code { font-size: 12px; padding: 1px 6px; border-radius: 6px; background: rgba(110,118,129,.14); } html.zm-theme-dark .zm-ts-steps li::before { background: rgba(88,166,255,.16); color: #79c0ff; } @media (max-width: 600px) { .zm-tsc .zm-ts-cmd { max-width: none; } } ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/style.css' cat > '/www/luci-static/resources/view/zapret-manager/system.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require view'; 'require zapret-manager.common as zm'; var E = (function(raw) { return function() { var a = Array.prototype.slice.call(arguments), i = a.length - 1; if (i >= 1 && (typeof a[i] === 'string' || typeof a[i] === 'number')) a[i] = [ String(a[i]) ]; return raw.apply(null, a); }; })(window.E); 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() { var part = this.zmPart || '', si = function() { return zm.sysinfoGet().catch(function() { return {}; }); }; zm.injectCss(); if (part && part !== 'main') return Promise.all([ {}, {}, part === 'sysinfo' ? si() : {} ]); return Promise.all([ zm.systemStatus(), zm.mirrorStatus(), part ? {} : si() ]); }, render: function(data) { var sysData = data[0], mirrorData = data[1]; var wrap = E('div', { 'class': 'zm-wrap' }); var part = this.zmPart || '', SYS_MAIN = { status: 1, time: 1, net: 1, ver: 1, mirror: 1, changelog: 1, thanks: 1 }; function on(k) { return !part || (part === 'main' ? !!SYS_MAIN[k] : part === k); } function put(k, el) { if (on(k)) wrap.appendChild(el); } 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; } var stCard = renderStatusCard(sysData); function refresh(d) { var newCard = renderStatusCard(d); if (stCard.parentNode) stCard.parentNode.replaceChild(newCard, stCard); stCard = newCard; } put('status', stCard); 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 Manager одним отчётом: память, система, сеть, каждый установленный компонент с настройками, стратегия, пакеты и доступность сайтов. Адреса, ключи, пароли и ссылки подписок в отчёт не попадают — его можно смело отправлять в чат поддержки. Займёт до минуты.'), 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 siSitesSorted() { return (si.sites || []).map(function(x, i) { return { x: x, i: i }; }).sort(function(a, b) { return (a.x.ok ? 1 : 0) - (b.x.ok ? 1 : 0) || a.i - b.i; }).map(function(o) { return o.x; }); } var SI_COL_MIN = 330, SI_GAP = 14; function siMasonry(boxes) { var host = E('div', { 'class': 'zm-si-mas' }), col = E('div', { 'class': 'zm-si-col' }); boxes.forEach(function(b) { col.appendChild(b); }); host.appendChild(col); host._boxes = boxes; host._w = 0; var relayout = function() { siMasLayout(host); }; if (window.ResizeObserver) new ResizeObserver(relayout).observe(host); else { window.addEventListener('resize', relayout); setTimeout(relayout, 0); setTimeout(relayout, 400); } return host; } function siSpread(cols) { var hi = 0, lo = Infinity; cols.forEach(function(c) { hi = Math.max(hi, c.h); lo = Math.min(lo, c.h); }); return hi - lo; } function siAssign(hs, n, order) { var cols = []; for (var i = 0; i < n; i++) cols.push({ h: 0, items: [] }); order.forEach(function(k) { var best = cols[0]; cols.forEach(function(c) { if (c.h < best.h - 0.5) best = c; }); best.items.push(k); best.h += hs[k] + SI_GAP; }); cols.forEach(function(c) { c.items.sort(function(a, b) { return a - b; }); }); return cols; } function siMasLayout(host) { var boxes = host._boxes, w = host.clientWidth; if (!w || !boxes || !host.isConnected) return; var n = Math.max(1, Math.min(boxes.length, Math.floor((w + SI_GAP) / (SI_COL_MIN + SI_GAP)))); if (w === host._w && n === host._n) return; host._w = w; host._n = n; host.classList.remove('zm-si-even'); host.innerHTML = ''; var colW = (w - SI_GAP * (n - 1)) / n; var meter = E('div', { 'class': 'zm-si-col', 'style': 'flex:0 0 ' + colW + 'px; width:' + colW + 'px' }); boxes.forEach(function(b) { meter.appendChild(b); }); host.appendChild(meter); var hs = boxes.map(function(b) { return b.offsetHeight; }); host.innerHTML = ''; var idx = boxes.map(function(b, i) { return i; }); var plans = [ siAssign(hs, n, idx), siAssign(hs, n, idx.slice().sort(function(a, b) { return hs[b] - hs[a] || a - b; })) ]; var plan = siSpread(plans[1]) + 1 < siSpread(plans[0]) ? plans[1] : plans[0]; plan.sort(function(a, b) { return (a.items.length ? a.items[0] : 1e9) - (b.items.length ? b.items[0] : 1e9); }); plan.forEach(function(c) { if (!c.items.length) return; var col = E('div', { 'class': 'zm-si-col' }); c.items.forEach(function(k) { col.appendChild(boxes[k]); }); host.appendChild(col); }); if (n > 1) host.classList.add('zm-si-even'); } function siSections() { var l = (si.sections || []).slice(), i = -1; l.forEach(function(s, k) { if (s.title === 'Компоненты ZM') i = k; }); if (i < 0) return l; var c = l.splice(i, 1)[0], at = 0; l.forEach(function(s, k) { if ({ 'Роутер': 1, 'Память': 1, 'Система': 1, 'Сеть': 1 }[s.title]) at = k + 1; }); l.splice(at, 0, c); return l; } function siText() { var out = [ 'Zapret Manager — системная информация' + (si.ts ? ' (' + si.ts + ')' : '') ]; siSections().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 + ' =='); siSitesSorted().forEach(function(x) { out.push((x.ok ? '[ OK ] ' : '[FAIL] ') + x.name); }); } out.push('', '(адреса, ключи, пароли и ссылки подписок в отчёт не включены)'); 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 siSave() { var txt = siText(); zm.systemInfo().catch(function() { return {}; }).then(function(r) { r = r || {}; var p2 = function(n) { return ('0' + n).slice(-2); }, now = new Date(); var m = /(\d{1,2})\.(\d{1,2})\.(\d{4})\D+(\d{1,2}):(\d{2})/.exec(String(si.ts || '')); var stamp = m ? m[3] + '-' + p2(m[2]) + '-' + p2(m[1]) + '_' + p2(m[4]) + '-' + m[5] : now.getFullYear() + '-' + p2(now.getMonth() + 1) + '-' + p2(now.getDate()) + '_' + p2(now.getHours()) + '-' + p2(now.getMinutes()); var who = String(r.model || r.hostname || 'router').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'router'; var a = E('a', { 'href': URL.createObjectURL(new Blob([ '\ufeff' + txt.replace(/\n/g, '\r\n') + '\r\n' ], { type: 'text/plain;charset=utf-8' })), 'download': who + '_' + stamp + '_zapret-manager-info.txt', 'style': 'display:none' }); document.body.appendChild(a); a.click(); setTimeout(function() { URL.revokeObjectURL(a.href); if (a.parentNode) a.parentNode.removeChild(a); }, 2000); zm.toast('Отчёт сохранён в файл', 'info'); }); } 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 && !siBusy) siActs.appendChild(E('button', { 'class': 'cbi-button', 'title': 'Скачать отчёт текстовым файлом — удобно отправить в чат поддержки', 'click': siSave }, 'Сохранить в файл')); if (si && si.ts && !siBusy) siActs.appendChild(E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Собрано ' + si.ts)); siBody.innerHTML = ''; if (!si || siBusy) return; var SYS = { 'Роутер': 1, 'Память': 1, 'Система': 1, 'Сеть': 1 }; var bSys = [], bZm = []; siSections().forEach(function(s) { if (!s.rows || !s.rows.length) return; (SYS[s.title] ? bSys : bZm).push(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) bZm.push(siBox('Домены в hosts', [ E('div', { 'class': 'zm-chips' }, si.hosts.map(function(h) { return E('span', { 'class': 'zm-chip' }, [ h ]); })) ])); if (bSys.length) { siBody.appendChild(E('div', { 'class': 'zm-si-group' }, 'Роутер и сеть')); siBody.appendChild(siMasonry(bSys)); } if (bZm.length) { siBody.appendChild(E('div', { 'class': 'zm-si-group' }, 'Zapret Manager и компоненты')); siBody.appendChild(siMasonry(bZm)); } 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' }, siSitesSorted().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(); put('sysinfo', siCard); var ZONES = [ { id: 'Europe/Kaliningrad', city: 'Калининград', off: 'UTC+2' }, { id: 'Europe/Moscow', city: 'Москва', off: 'UTC+3' }, { id: 'Europe/Samara', city: 'Самара', off: 'UTC+4' }, { id: 'Asia/Yekaterinburg', city: 'Екатеринбург', off: 'UTC+5' }, { id: 'Asia/Omsk', city: 'Омск', off: 'UTC+6' }, { id: 'Asia/Krasnoyarsk', city: 'Красноярск', off: 'UTC+7' }, { id: 'Asia/Irkutsk', city: 'Иркутск', off: 'UTC+8' }, { id: 'Asia/Yakutsk', city: 'Якутск', off: 'UTC+9' }, { id: 'Asia/Vladivostok', city: 'Владивосток', off: 'UTC+10' }, { id: 'Asia/Magadan', city: 'Магадан', off: 'UTC+11' }, { id: 'Asia/Kamchatka', city: 'Камчатка', off: 'UTC+12' }, { id: 'UTC', city: 'UTC', off: '±0' } ]; var WDAYS = [ 'воскресенье', 'понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота' ]; var MONTHS = [ 'января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря' ]; var timeCard = E('div', { 'class': 'zm-card' }), timeBusy = '', timeData = sysData, tzOpen = false, autoNote = ''; var clockT = E('div', { 'class': 'zm-tz-time' }), clockD = E('div', { 'class': 'zm-tz-date' }), clockBase = 0, clockAt = 0; function offText(o) { o = String(o || '+0000'); var h = +o.slice(1, 3), m = +o.slice(3, 5); return 'UTC' + (h || m ? o.charAt(0) + h + (m ? ':' + o.slice(3, 5) : '') : '±0'); } function zoneText() { var z = ZONES.filter(function(x) { return x.id === timeData.zone; })[0]; if (z) return z.city + ' · ' + offText(timeData.utc_offset); return (timeData.zone ? timeData.zone.replace(/^.*\//, '').replace(/_/g, ' ') + ' · ' : 'Пояс не выбран · ') + offText(timeData.utc_offset); } function nowLocal() { if (!clockBase) return null; var s = clockBase + Math.round((Date.now() - clockAt) / 1000); var off = String(timeData.utc_offset || '+0000'), sg = off.charAt(0) === '-' ? -1 : 1, mins = sg * ((+off.slice(1, 3)) * 60 + (+off.slice(3, 5))); return new Date((s + mins * 60) * 1000); } function paintClock() { var d = nowLocal(), p = function(n) { return (n < 10 ? '0' : '') + n; }; if (!d) { clockT.textContent = '—'; clockD.textContent = timeData.time_text || ''; return; } clockT.textContent = p(d.getUTCHours()) + ':' + p(d.getUTCMinutes()) + ':' + p(d.getUTCSeconds()); clockD.textContent = WDAYS[d.getUTCDay()] + ', ' + d.getUTCDate() + ' ' + MONTHS[d.getUTCMonth()] + ' ' + d.getUTCFullYear(); } function tick() { if (!document.body.contains(clockT)) return; paintClock(); setTimeout(tick, 1000); } function timeDo(value, what, text) { if (timeBusy) return; timeBusy = what; renderTime(); zm.toast(text, 'warning'); zm.systemTimeSet(value).then(function(r) { timeBusy = ''; if (r && r.time) { timeData = r; clockBase = r.time; clockAt = Date.now(); } if (!r || r.error) { zm.toast((r && r.error) || 'Роутер не ответил', 'error'); renderTime(); return; } if (value === 'auto' && r.auto) { var a = r.auto, how = a.sync === 'ntp' ? 'время синхронизировано по NTP' : a.sync === 'http' ? 'время взято с сайтов (NTP у провайдера закрыт)' : 'время синхронизировать не удалось'; autoNote = 'Определено: ' + a.zone + (a.city ? ' (' + a.city + ')' : '') + ' — так ответили ' + a.votes + ' из ' + a.got + ' сервисов; ' + how + '.'; tzOpen = false; zm.toast(a.sync ? 'Часовой пояс определён, время синхронизировано' : 'Часовой пояс определён, но время синхронизировать не удалось', a.sync ? 'info' : 'warning'); } else { autoNote = ''; if (/^tz:/.test(value)) tzOpen = false; if (/^ntpsrv:/.test(value)) zm.toast((r.ntpsrv === 'ip' ? 'Серверы времени — по IP-адресам' : 'Серверы времени — стандартные') + (r.sync ? ', время синхронизировано' : ', но время пока не синхронизировалось'), r.sync ? 'info' : 'warning'); else zm.toast(value === 'ntp' ? (r.sync === 'http' ? 'Время взято с сайтов — NTP у провайдера закрыт' : 'Время синхронизировано') : 'Часовой пояс установлен', 'info'); } renderTime(); }).catch(function() { timeBusy = ''; zm.toast('Роутер не ответил', 'error'); renderTime(); }); } function renderTime() { timeCard.innerHTML = ''; timeCard.appendChild(E('h3', {}, 'Время на роутере')); timeCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Нужно для всех расписаний панели: автоподбора стратегии, перезапусков и обновления списков.')); paintClock(); timeCard.appendChild(E('div', { 'class': 'zm-tz-clock' }, [ E('div', { 'class': 'zm-tz-now' }, [ clockT, clockD, E('div', { 'class': 'zm-tz-zone' }, zoneText()) ]), E('div', { 'class': 'zm-tz-btns' }, [ E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': timeBusy ? '' : null, 'title': 'Роутер сам определит часовой пояс по своему местоположению (сверяя несколько сервисов) и выставит точное время', 'click': function() { timeDo('auto', 'auto', 'Определяем часовой пояс и время'); } }, timeBusy === 'auto' ? 'Определяем…' : 'Определить автоматически'), E('button', { 'class': 'cbi-button', 'disabled': timeBusy ? '' : null, 'click': function() { tzOpen = !tzOpen; renderTime(); } }, tzOpen ? 'Скрыть часовые пояса' : 'Сменить часовой пояс'), E('button', { 'class': 'cbi-button', 'disabled': timeBusy ? '' : null, 'click': function() { timeDo('ntp', 'ntp', 'Синхронизируем время'); } }, timeBusy === 'ntp' ? 'Синхронизируем…' : 'Синхронизировать время') ]) ])); if (autoNote) timeCard.appendChild(E('p', { 'class': 'zm-hint' }, autoNote)); renderNtpSrv(); if (timeData.time && new Date(timeData.time * 1000).getFullYear() < 2024) timeCard.appendChild(E('p', { 'class': 'zm-hint' }, '⚠ Время на роутере неверное — нажмите «Определить автоматически», иначе расписания не сработают.')); if (tzOpen) { var list = ZONES.slice(); if (timeData.zone && !list.some(function(z) { return z.id === timeData.zone; })) list.unshift({ id: timeData.zone, city: timeData.zone.replace(/^.*\//, '').replace(/_/g, ' '), off: offText(timeData.utc_offset), own: true }); timeCard.appendChild(E('div', { 'class': 'zm-grid zm-tz-grid' }, list.map(function(z) { var on = z.id === timeData.zone; return E('div', { 'class': 'zm-tile zm-tz-tile' + (on ? ' zm-active' : ''), 'click': function() { if (on || z.own || timeBusy) return; timeDo('tz:' + z.id, 'tz', 'Ставим часовой пояс: ' + z.city); } }, [ E('b', {}, z.city), E('span', {}, z.off + (on ? ' · выбран' : '')) ]); }))); } } var NTP_SRV = [ { id: 'std', label: 'Стандартные' }, { id: 'ip', label: 'По IP-адресам' } ]; function renderNtpSrv() { var cur = timeData.ntp_preset || 'none', lock = !!timeData.dnsm || !!timeBusy; timeCard.appendChild(E('div', { 'class': 'zm-row', 'style': 'margin-top:12px; flex-wrap:wrap; gap:8px 12px' }, [ E('span', { 'class': 'zm-label' }, 'Серверы времени'), E('div', { 'class': 'zm-seg' + (lock ? ' zm-seg-busy' : '') }, NTP_SRV.map(function(it) { return E('div', { 'class': 'zm-seg-item' + (it.id === cur ? ' zm-active' : ''), 'click': function() { if (it.id === cur || lock) return; timeDo('ntpsrv:' + it.id, 'ntpsrv', it.id === 'ip' ? 'Ставим серверы времени по IP-адресам' : 'Ставим стандартные серверы времени'); } }, it.label); })) ])); timeCard.appendChild(E('p', { 'class': 'zm-hint' }, timeData.dnsm ? 'Серверы времени настраивает DNS Manager — меняйте их в нём.' : cur === 'ip' ? 'ВНИИФТРИ, Cloudflare и Google по IP-адресам — время синхронизируется, даже если DNS на роутере не отвечает.' : cur === 'std' ? 'pool.ntp.org — роутер находит их через DNS. Не отвечает DNS (например, сервер DoH) — время не синхронизируется, а без точного времени не работают DoH и HTTPS. С DNS over HTTPS лучше «По IP-адресам».' : cur === 'own' ? 'Сейчас стоят свои серверы: ' + String(timeData.ntp_servers || '').split(' ').join(', ') + '. Выбор заменит их.' : 'Серверы времени не заданы — выберите, какие использовать.')); } if (timeData.time) { clockBase = timeData.time; clockAt = Date.now(); } renderTime(); put('time', timeCard); setTimeout(tick, 1000); var netBusy = false; var msFmt = function(v) { var n = parseFloat(v); return isNaN(n) ? String(v || '') : n < 10 ? String(Math.round(n * 10) / 10) : String(Math.round(n)); }; 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, 'доступен, ' + msFmt(res.ipv4_ms) + ' мс', 'недоступен') ])); var noDns = res.ipv6_ok === true && res.ipv6_dns === false; netEl.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, noDns ? 'IPv6 (по адресу)' : 'IPv6 (google.com)'), zm.badge(res.ipv6_ok === true, 'доступен, ' + msFmt(res.ipv6_ms) + ' мс', 'недоступен') ])); if (noDns) netEl.appendChild(E('p', { 'class': 'zm-hint' }, 'IPv6 на роутере работает, но DNS роутера не отдаёт IPv6-адреса сайтов — устройства ходят на сайты по IPv4. Включать IPv6 в Zapret не нужно.')); }).catch(function() { netBusy = false; }); } }, 'Проверить IPv4 / IPv6') ]) ]); put('net', 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 verCard = E('div', { 'class': 'zm-card' }), verD = null, verBusy = false, verTimer = null; var VER_MODES = [ { id: 'day', label: 'Раз в день' }, { id: 'open', label: 'При открытии Дашборда' }, { id: 'button', label: 'Только по кнопке' } ]; var VER_VIA = { day: 'ночная проверка', open: 'при открытии Дашборда', button: 'кнопкой' }; function verPoll() { if (verTimer) { clearTimeout(verTimer); verTimer = null; } if (!verD || !verD.pending || !document.body.contains(verCard)) return; verTimer = setTimeout(function() { zm.versions('').then(function(r) { var was = verD && verD.pending; verD = r || verD; renderVer(); if (was && verD && !verD.pending) { zm.toast('Версии проверены — ' + verFound(verD), 'info'); zm.zmUpdateNotify(zm.zmUpdateFromVersions(verD)); } verPoll(); }).catch(function() { verPoll(); }); }, 3000); } function verFound(d) { var n = ((d && d.items) || []).filter(function(it) { return it.latest && it.latest !== it.installed; }).length; return n ? 'найдено обновлений: ' + n : 'всё актуально'; } function renderVer() { verCard.innerHTML = ''; verCard.appendChild(E('h3', {}, 'Поиск новых версий')); verCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:0' }, 'Как панель узнаёт о новых версиях: сама панель Zapret Manager, Forkozz, Steer, TG WS Proxy и другие установленные пакеты.')); if (!verD) { verCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Загружаем…')); return; } var mode = verD.mode === 'open' || verD.mode === 'button' ? verD.mode : 'day'; var mm = ('0' + (parseInt(verD.minute, 10) || 0)).slice(-2), night = verD.bg !== false; verCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Проверять'), E('div', { 'class': 'zm-seg' }, VER_MODES.map(function(o) { return E('div', { 'class': 'zm-seg-item' + (mode === o.id ? ' zm-active' : ''), 'click': function() { if (mode === o.id || verBusy) return; verBusy = true; zm.versionsMode(o.id).then(function(r) { verBusy = false; if (!r || r.error) { zm.toast((r && r.error) || 'Роутер не ответил', 'error'); return; } verD = r; renderVer(); zm.toast(o.id === 'day' ? (r.bg === false ? 'Фоновые проверки выключены — новые версии проверятся при открытии Дашборда раз в сутки' : 'Новые версии роутер будет искать сам раз в день, около 04:' + ('0' + (parseInt(r.minute, 10) || 0)).slice(-2)) : o.id === 'open' ? 'Новые версии ищутся при каждом открытии Дашборда' : 'Новые версии ищутся только кнопкой', 'info'); }).catch(function() { verBusy = false; zm.toast('Роутер не ответил', 'error'); }); } }, o.label); })) ])); verCard.appendChild(E('p', { 'class': 'zm-hint' }, mode === 'open' ? 'Каждый раз, когда вы открываете Дашборд, панель спрашивает GitHub о новых версиях (не чаще раза в 10 минут). Сведения всегда свежие, но роутер ходит в интернет при каждом заходе.' : mode === 'button' ? 'Панель сама в интернет не ходит: новые версии проверяются только кнопкой «Проверить» на Дашборде или здесь.' : !night ? 'Фоновые проверки выключены (' + (part ? 'раздел «Фоновые проверки» в меню' : 'ниже, «Фоновые проверки»') + '), поэтому ночью роутер ничего не проверяет. Раз в сутки версии проверятся при открытии Дашборда; в остальное время Дашборд показывает сохранённое.' : 'Роутер сам проверяет новые версии раз в сутки, ночью около 04:' + mm + ', и запоминает результат. Дашборд показывает сохранённое и в интернет не ходит. Если роутер ночью был выключен, проверка пройдёт при следующем открытии Дашборда.')); var last = verD.pending ? 'проверяем…' : +verD.at > 0 && verD.ts ? verD.ts + (VER_VIA[verD.via] ? ', ' + VER_VIA[verD.via] : '') + ' · ' + verFound(verD) + (mode === 'day' && night ? ' · следующая — около 04:' + mm : '') : 'ещё не было' + (mode === 'day' && night ? ' · первая — около 04:' + mm : ''); verCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Последняя проверка'), E('span', {}, last) ])); verCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-action', 'disabled': verD.pending ? '' : null, 'click': function() { zm.versions('refresh').then(function(r) { verD = r || verD; renderVer(); verPoll(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } }, verD.pending ? 'Проверяем…' : 'Проверить сейчас') ])); } renderVer(); if (on('ver')) zm.versions('').then(function(r) { verD = r || {}; renderVer(); verPoll(); }).catch(function() { verD = { mode: 'day' }; renderVer(); }); put('ver', verCard); var clBusy = false; var clCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Список изменений'), E('p', { 'class': 'zm-hint', 'style': 'margin-top:0' }, 'Что нового в каждой версии Zapret Manager. Список берётся с GitHub, а если он недоступен — показывается копия, сохранённая на роутере при установке.'), E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-action', 'click': function(ev) { if (clBusy) return; var btn = ev.currentTarget; clBusy = true; btn.disabled = true; zm.changelogShow('all').then(function() { clBusy = false; btn.disabled = false; }, function() { clBusy = false; btn.disabled = false; }); } }, 'Открыть список изменений') ]) ]); var watchCard = E('div', { 'class': 'zm-card zm-watch' }), watchD = null, watchBusy = false; function watchBadge(cls, text) { return E('span', { 'class': 'zm-badge ' + cls }, [ E('span', { 'class': 'zm-dot' }), text ]); } function watchAgo(d) { if (!d || !(+d.last > 0)) return 'ещё не было'; var s = Math.max(0, (+d.now || Math.floor(Date.now() / 1000)) - +d.last); return s < 60 ? 'только что' : s < 3600 ? Math.floor(s / 60) + ' мин назад' : s < 86400 ? Math.floor(s / 3600) + ' ч назад' : Math.floor(s / 86400) + ' д назад'; } function watchSet(v, okText) { if (watchBusy) return; watchBusy = true; renderWatch(); zm.watchSet(v).then(function(r) { watchBusy = false; if (!r || r.error) { zm.toast((r && r.error) || 'Роутер не ответил', 'error'); renderWatch(); return; } watchD = r; renderWatch(); if (okText) zm.toast(okText, 'info'); }).catch(function() { watchBusy = false; renderWatch(); zm.toast('Роутер не ответил', 'error'); }); } function watchToggle() { if (watchBusy || !watchD) return; if (watchD.on === false) { watchSet('on', 'Фоновые проверки включены — роутер снова следит за работой панели'); return; } var act = (watchD.items || []).filter(function(x) { return x.active; }); zm.warnDialog({ title: 'Выключить фоновые проверки?', lead: 'Роутер перестанет сам проверять и чинить работу панели. Без присмотра останется:', items: act.map(function(x) { return [ x.title, x.what ]; }), tip: { title: 'Вернуть можно в любой момент', text: 'тем же переключателем; кнопка «Проверить сейчас» сделает одну проверку вручную.' }, check: 'Понимаю: после сбоя или перезагрузки это придётся чинить вручную', okText: 'Выключить' }).then(function(ok) { if (ok) watchSet('off', 'Фоновые проверки выключены — в фоне роутер ничего не проверяет'); }); } function renderWatch() { watchCard.innerHTML = ''; var d = watchD, on = !d || d.on !== false; watchCard.appendChild(E('h3', {}, [ 'Фоновые проверки ', !d ? E([]) : on ? watchBadge('zm-ok', 'включены') : watchBadge('zm-off', 'выключены') ])); watchCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Сторож панели раз в 2 минуты проверяет, что всё работает, и сам чинит сбои. Если делать нечего — выходит сразу, почти не нагружая роутер. Выключите, если не хотите, чтобы роутер что-то делал в фоне.')); if (!d) { watchCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Загружаем…')); return; } watchCard.appendChild(zm.swRow(on, 'Проверять в фоне — раз в 2 минуты', on ? 'Последняя проверка: ' + watchAgo(d) + (d.cron ? '' : ' · задания в расписании нет — включите заново') : 'Роутер ничего не проверяет сам. Проверка — только кнопкой ниже.', watchToggle, watchBusy)); var list = E('div', { 'class': 'zm-watch-list' }); (d.items || []).forEach(function(x) { var st = x.id === 'ver' ? (!x.active ? watchBadge('zm-off', 'другой режим') : on ? watchBadge('zm-ok', 'по расписанию') : watchBadge('zm-warn', 'при открытии Дашборда')) : !x.active ? watchBadge('zm-off', 'не нужно') : on ? watchBadge('zm-ok', 'под присмотром') : watchBadge('zm-warn', 'без присмотра'); list.appendChild(E('div', { 'class': 'zm-watch-item' + (x.active ? '' : ' zm-watch-idle') }, [ E('div', { 'class': 'zm-watch-t' }, [ E('b', {}, x.title), E('span', { 'class': 'zm-hint' }, x.what) ]), st ])); }); watchCard.appendChild(E('h4', { 'style': 'margin:16px 0 4px' }, 'За чем следит')); watchCard.appendChild(list); if (+d.others > 0) watchCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Ещё ' + d.others + ' ' + (function(n) { var a = n % 100, b = n % 10; return a > 10 && a < 20 ? 'заданий' : b === 1 ? 'задание' : b >= 2 && b <= 4 ? 'задания' : 'заданий'; })(+d.others) + ' в расписании вы включили сами на вкладках (автотест, автообновление списков и другие). Этот переключатель их не трогает — они работают так, как настроены там.')); watchCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'disabled': watchBusy ? '' : null, 'click': function() { watchSet('run', 'Проверка запущена — займёт несколько секунд'); } }, 'Проверить сейчас') ])); } renderWatch(); if (on('watch')) zm.watchStatus().then(function(r) { watchD = r && !r.error ? r : { on: true, items: [] }; renderWatch(); }).catch(function() { watchD = { on: true, items: [] }; renderWatch(); }); put('watch', watchCard); 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 ]), mirrorData.mirror_how === 'auto' ? E('p', { 'class': 'zm-hint' }, 'Зеркало выбрано автоматически: основной репозиторий OpenWrt отвечал с ошибками. Вернуть его — «default / OpenWrt».') : E([]), mirrorGrid, E('p', { 'class': 'zm-hint' }, 'Перед переключением панель проверяет, что зеркало отдаёт список пакетов этого роутера, и что список обновляется без ошибок; иначе остаётся прежнее. Если основной репозиторий при обновлении пакетов отвечает с ошибками, панель сама подбирает самое быстрое рабочее зеркало.'), mirrorLog ]); put('mirror', mirrorCard); var bkCard = E('div', { 'class': 'zm-card zm-bk' }), bkD = null, bkBusy = '', bkErr = ''; var bkLog = E('pre', { 'class': 'zm-log' }); var bkFile = E('input', { 'type': 'file', 'accept': '.zmb', 'style': 'display:none', 'aria-hidden': 'true', 'tabindex': '-1' }); bkFile.addEventListener('change', function() { var f = bkFile.files && bkFile.files[0]; bkFile.value = ''; if (f) bkUpload(f); }); function bkPad(n) { return (n < 10 ? '0' : '') + n; } function bkWhen(at, low) { var d = new Date((+at || 0) * 1000), n = new Date(), t = bkPad(d.getHours()) + ':' + bkPad(d.getMinutes()); var day = new Date(n.getFullYear(), n.getMonth(), n.getDate()).getTime(), dd = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); if (dd === day) return (low ? 'сегодня, ' : 'Сегодня, ') + t; if (day - dd === 864e5) return (low ? 'вчера, ' : 'Вчера, ') + t; return bkPad(d.getDate()) + '.' + bkPad(d.getMonth() + 1) + '.' + d.getFullYear() + ', ' + t; } function bkKb(k) { k = Math.max(0, Math.round(+k || 0)); return k < 1024 ? k + ' КБ' : (k / 1024).toFixed(1).replace('.', ',') + ' МБ'; } function bkSize(b) { return bkKb(Math.max(1, Math.round((+b || 0) / 1024))); } function bkKind(it) { if (it.kind === 'manual') return 'вручную'; if (it.kind === 'upload') return 'загружена с компьютера'; return 'авто' + (it.reason ? ' · ' + it.reason : ''); } function bkTitle(it) { return it.name ? '«' + it.name + '»' : 'от ' + bkWhen(it.at, true); } function bkBadge(cls, text) { return E('span', { 'class': 'zm-badge ' + cls }, [ E('span', { 'class': 'zm-dot' }), text ]); } function bkLoad() { return zm.backupList().then(function(r) { bkErr = !r || r.error || !r.items ? ((r && r.error) || 'роутер не ответил') : ''; if (!bkErr) bkD = r; if (!bkErr && r.busy === 'резервная копия' && !bkBusy) bkFollow(); renderBk(); return bkD; }, function() { bkErr = 'роутер не ответил'; renderBk(); return bkD; }); } function bkFollow(after) { bkBusy = 'job'; zm.pollJob('backup', bkLog, function(ok) { bkBusy = ''; if (after) after(ok); bkLoad(); }); } function bkRow(it, i) { var sub = [ it.name ? bkWhen(it.at) : '', bkKind(it), bkSize(it.size), it.panel ? 'панель ' + it.panel : '' ].filter(Boolean).join(' · '); var chips = (it.chips || []).length ? it.chips : [ 'Только настройки панели и сети' ]; var off = bkBusy ? '' : null; return E('div', { 'class': 'zm-awg-if zm-bk-item' }, [ E('div', { 'class': 'zm-bk-head' }, [ E('b', {}, it.name || bkWhen(it.at)), i === 0 ? bkBadge('zm-ok', 'последняя') : '', E('span', { 'class': 'zm-hint zm-bk-sub' }, sub) ]), E('div', { 'class': 'zm-fk-chips' }, chips.map(function(c) { return E('span', { 'class': 'zm-fk-chip', 'title': c }, c); })), E('div', { 'class': 'zm-actions', 'style': 'margin:10px 0 0' }, [ E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': off, 'click': function() { bkRestore(it); } }, 'Восстановить'), E('button', { 'class': 'cbi-button', 'disabled': bkBusy || !bkD.b64 ? '' : null, 'title': bkD.b64 ? '' : 'На роутере нет ucode и base64', 'click': function() { bkDown(it); } }, 'Скачать'), E('button', { 'class': 'cbi-button cbi-button-remove', 'disabled': off, 'click': function() { bkDel(it); } }, 'Удалить') ]) ]); } function renderBk() { bkCard.innerHTML = ''; var d = bkD, items = (d && d.items) || [], on = !d || d.auto !== false, off = bkBusy || !d ? '' : null; bkCard.appendChild(E('h3', {}, 'Резервная копия')); bkCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Сохраняет всё, что настроено через Zapret Manager: компоненты и их пакеты, стратегии, Steer, Forkozz, AmneziaWG, DNS, блокировку рекламы, расписания и настройки самой панели. Натворили дел — нажмите «Восстановить», и всё вернётся как было в момент копии.')); bkCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': off, 'click': bkCreate }, bkBusy === 'create' ? 'Сохраняем…' : 'Создать копию'), E('button', { 'class': 'cbi-button', 'disabled': off || (d && !d.b64) ? '' : null, 'click': function() { bkFile.click(); } }, bkBusy === 'upload' ? 'Загружаем…' : 'Загрузить файл копии'), bkFile ])); if (d && !d.b64) bkCard.appendChild(E('p', { 'class': 'zm-hint zm-ab-warn' }, 'Скачать копию на компьютер и загрузить её обратно не получится: на роутере нет ни ucode, ни base64. Копии на самом роутере работают.')); bkCard.appendChild(zm.swRow(on, 'Копия перед рискованными действиями', on ? 'Панель сама сохранит копию перед установкой и удалением компонентов, полным удалением и обновлением панели. Хранятся ' + ((d && d.auto_n) || 5) + ' последних, старые удаляются сами.' : 'Выключено: перед установкой, удалением и обновлением копия сама не сохраняется — только кнопкой «Создать копию».', bkAuto, !d || bkBusy === 'auto')); if (bkErr && !d) { bkCard.appendChild(E('p', { 'class': 'zm-hint zm-ab-warn' }, 'Список копий не загрузился: ' + bkErr)); bkCard.appendChild(bkLog); return; } if (!d) { bkCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Загружаем…')); return; } bkCard.appendChild(E('h4', { 'class': 'zm-bk-h4' }, [ 'Копии на роутере ', E('span', { 'class': 'zm-hint' }, items.length ? '· ' + items.length + ' · занимают ' + bkKb(d.used_kb) + (+d.free_kb >= 0 ? ' · свободно ' + bkKb(d.free_kb) : '') : '') ])); if (!items.length) bkCard.appendChild(E('p', { 'class': 'zm-hint zm-bk-empty' }, 'Копий пока нет. Создайте первую — это займёт несколько секунд и ничего не перезапустит.')); items.forEach(function(it, i) { bkCard.appendChild(bkRow(it, i)); }); bkCard.appendChild(bkLog); bkCard.appendChild(E('p', { 'class': 'zm-hint zm-bk-note' }, '⚠ В копии есть ключи WARP и AmneziaWG, ссылки подписок и вход Tailscale. Скачанный файл храните у себя и никому не передавайте.')); bkCard.appendChild(E('p', { 'class': 'zm-hint zm-bk-note' }, 'Копии лежат на флеше роутера в ' + d.dir + ' и переживают обновление прошивки с сохранением настроек (последние, до ' + bkKb(d.keep_kb) + '). После прошивки начисто поставьте панель и загрузите скачанный файл копии.')); } function bkAuto() { if (bkBusy || !bkD) return; var want = bkD.auto === false; bkBusy = 'auto'; renderBk(); zm.backupAction('auto', want ? 'on' : 'off').then(function(r) { bkBusy = ''; if (!r || r.error) { zm.toast((r && r.error) || 'Роутер не ответил', 'error'); renderBk(); return; } bkD.auto = want; renderBk(); zm.toast(want ? 'Копия перед рискованными действиями включена' : 'Копия перед рискованными действиями выключена', 'info'); }).catch(function() { bkBusy = ''; renderBk(); zm.toast('Роутер не ответил', 'error'); }); } function bkCreate() { if (bkBusy) return; var bt = zm.busyToast('Смотрим, что попадёт в копию…'); zm.backupAction('info', '').then(function(inf) { bt.close(); if (!inf || inf.error) { zm.toast((inf && inf.error) || 'Роутер не ответил', 'error'); return; } if (inf.busy) { zm.toast('Сейчас идёт операция «' + inf.busy + '» — дождитесь её окончания', 'warning'); return; } var what = (inf.chips || []).slice(); if (!what.length) what.push('Компонентов не установлено — сохранятся настройки панели и сети'); what.push('Настройки самой панели: расписания, автоподбор, предупреждения, списки'); what.push('Сеть, firewall, DHCP и hosts — целиком, как они есть сейчас'); if (+inf.pkgs > 0) what.push('Список пакетов компонентов с версиями (' + inf.pkgs + ') — сами программы при восстановлении скачаются заново'); var sec = []; if (inf.secrets) { if (inf.secrets.warp) sec.push('ключи WARP'); if (inf.secrets.awg) sec.push('ключи AmneziaWG'); if (inf.secrets.subs) sec.push('ссылки подписок'); if (inf.secrets.tsc) sec.push('вход Tailscale'); } var low = +inf.free_kb >= 0 && +inf.free_kb < +inf.est_kb + 512, free = +inf.free_kb >= 0 ? ', свободно ' + bkKb(inf.free_kb) : ''; return zm.dialog({ title: 'Создать копию', intro: 'Сохранит всё, что настроено через панель. Займёт несколько секунд, ничего не перезапускается.', okText: 'Создать копию', canOk: function() { return !low; }, blocks: [ { type: 'input', id: 'name', value: '', title: 'Название (необязательно)', placeholder: 'Например, «до экспериментов со Steer»' }, { type: 'list', title: 'Что попадёт в копию', items: what }, sec.length ? { type: 'note', text: 'В копии будут ' + sec.join(', ') + ' — скачанный файл никому не передавайте.' } : null, { type: 'note', kind: low ? 'warn' : '', text: low ? 'Мало места на флеше: копия займёт около ' + bkKb(inf.est_kb) + free + '. Удалите старые копии или очистите мусор ниже.' : 'Займёт около ' + bkKb(inf.est_kb) + ' на флеше' + free + '.' } ] }).then(function(v) { if (!v) return; bkBusy = 'create'; renderBk(); bkLog.classList.add('zm-show'); zm.renderLog(bkLog, '==> Сохраняем копию'); return zm.backupAction('create', String(v.name || '').trim()).then(function(r) { if (!r || r.error || !r.started) throw new Error((r && r.error) || 'роутер не ответил'); bkFollow(function(ok) { zm.toast(ok ? 'Копия сохранена' : 'Копию сохранить не удалось — причина в журнале', ok ? 'info' : 'error'); }); }); }); }).catch(function(e) { bt.close(); bkBusy = ''; renderBk(); zm.toast((e && e.message) || 'Роутер не ответил', 'error'); }); } function bkSecWord(t) { if (/^amneziawg_/.test(t)) return 'пир'; return { 'interface': 'интерфейс', zone: 'зона', rule: 'правило', forwarding: 'пересылка', redirect: 'проброс порта', dnsmasq: 'настройки DNS', host: 'адрес', domain: 'запись DNS', device: 'устройство', route: 'маршрут', defaults: 'общие правила', include: 'подключаемый файл', ipset: 'набор адресов', odhcpd: 'настройки DHCPv6', globals: 'общие настройки', 'switch': 'коммутатор', switch_vlan: 'VLAN', dhcp: 'раздача адресов', 'nat': 'NAT' }[t] || t; } function bkSecText(s) { var w = bkSecWord(s.t), l = String(s.l || ''); return l.indexOf(s.t + ' #') === 0 ? w : w + ' ' + l; } function bkNetItem(n) { var ttl = { network: 'Сеть', firewall: 'Firewall', dhcp: 'DHCP и DNS' }[n.f] || n.f, parts = []; [ [ 'add', 'вернётся' ], [ 'del', 'пропадёт' ], [ 'mod', 'изменится' ] ].forEach(function(op) { var a = (n.s || []).filter(function(s) { return s.op === op[0]; }).map(bkSecText); if (a.length) parts.push(op[1] + ': ' + (a.length > 4 ? a.slice(0, 4).join(', ') + ' и ещё ' + (a.length - 4) : a.join(', '))); }); return [ ttl + ' вернётся к копии', parts.join('; '), '↺', 'ret' ]; } function bkCnt(x) { var a = []; if (+x.mod) a.push('изменится файлов: ' + x.mod); if (+x.ret) a.push('вернётся: ' + x.ret); if (+x.del) a.push('удалится лишних: ' + x.del); return a.join(', '); } function bkVerCmp(a, b) { a = String(a || '').split('.'); b = String(b || '').split('.'); for (var i = 0; i < Math.max(a.length, b.length); i++) { var x = parseInt(a[i], 10) || 0, y = parseInt(b[i], 10) || 0; if (x !== y) return x < y ? -1 : 1; } return 0; } function bkPlan(it, d, pre) { var ch = [], risk = [], pf = d.panelf || {}, cur = d.cur || {}; (d.remove || []).forEach(function(x) { ch.push([ 'Удалится ' + x.name, 'его поставили после этой копии', '−', 'del' ]); }); (d.install || []).forEach(function(x) { ch.push([ 'Установится ' + x.name, 'он был в копии — программа скачается из интернета' + (x.pkgs ? ' (' + x.pkgs + ')' : ''), '+', 'add' ]); }); (d.change || []).forEach(function(x) { var t = [], none = { awg: 'без интерфейсов', steer: 'без туннеля', zapret: 'без стратегии', doh: 'без сервера' }[x.id] || 'по умолчанию'; var cut = function(c) { c = String(c || ''); var i = c.indexOf(' · '); return i > 0 ? c.slice(i + 3) : none; }; if (x.was && x.now && x.was !== x.now) t.push(cut(x.was) + ' вместо ' + cut(x.now)); var c = bkCnt(x); if (c) t.push(c); ch.push([ 'Вернутся настройки ' + x.name, t.join('; ') || 'как в копии', '↺', 'ret' ]); }); if (bkCnt(pf)) ch.push([ 'Вернутся настройки панели', bkCnt(pf), '↺', 'ret' ]); if (+pf.cron > 0) ch.push([ 'Вернётся расписание панели', 'отличается заданий: ' + pf.cron, '↺', 'ret' ]); if (pf.sys) ch.push([ 'Вернутся часовой пояс и серверы времени', '', '↺', 'ret' ]); (d.net || []).forEach(function(n) { ch.push(bkNetItem(n)); }); if (pf.lan) ch.push([ 'Адрес роутера станет ' + pf.lan.was, 'сейчас ' + pf.lan.now + ' — после восстановления откройте панель по адресу ' + pf.lan.was, '!', 'warn' ]); if (!ch.length) ch.push([ 'Отличий нет', 'на роутере уже всё как в копии — восстановление просто перезапишет настройки теми же', '=', 'same' ]); if ((d.same || []).length) ch.push([ 'Без изменений', d.same.join(', '), '=', 'same' ]); var vers = (d.vers || []).map(function(v) { return v.p + ' ' + v.was + ' → ' + v.now; }); risk.push([ 'Версии программ', vers.length ? 'уже отличаются от копии и останутся новыми: ' + vers.slice(0, 4).join(', ') + (vers.length > 4 ? ' и ещё ' + (vers.length - 4) : '') : (d.install || []).length ? 'программы скачаются свежих версий, а не тех, что были в копии; настройки при этом будут как в копии' : 'не меняются: восстанавливаются только настройки' ]); if ((d.install || []).length) risk.push([ 'Нужен интернет', 'без него ' + d.install.map(function(x) { return x.name; }).join(', ') + ' не установится — настройки всё равно вернутся, поставите позже' ]); if (d.secrets && d.secrets.tsc) risk.push([ 'Tailscale', 'если вход истёк или роутер удалили из аккаунта — войдите заново на вкладке Tailscale' ]); if (d.secrets && d.secrets.warp) risk.push([ 'WARP', 'Cloudflare мог отозвать старые ключи — тогда нажмите «Пересоздать» на вкладке Steer' ]); if (d.secrets && d.secrets.subs) risk.push([ 'Подписки VPN', 'ссылки из копии могли устареть или закончиться у провайдера' ]); if (pf.otherhw) risk.push([ 'Копия с другого роутера', (pf.otherhw.was || 'другая модель') + ', а здесь ' + (pf.otherhw.now || 'другая') + '. Сеть, firewall и DHCP останутся как сейчас — туннели AmneziaWG и WARP пересоздайте на их вкладках' ]); else risk.push([ 'Правки не через панель', 'изменения в сети, firewall, DHCP и hosts после копии откатятся вместе с ними. Wi‑Fi, пароль роутера и остальные настройки OpenWrt не трогаются' ]); if ((d.owrt && cur.owrt && d.owrt !== cur.owrt) || (d.arch && cur.arch && d.arch !== cur.arch)) risk.push([ 'Другая прошивка', 'копия сделана на OpenWrt ' + (d.owrt || '?') + (d.arch ? ' (' + d.arch + ')' : '') + ', здесь ' + (cur.owrt || '?') + (cur.arch ? ' (' + cur.arch + ')' : '') + ' — часть программ может не встать или работать иначе' ]); if (d.panel && cur.panel && d.panel !== cur.panel) risk.push([ 'Другая версия панели', 'копия из панели ' + d.panel + ', сейчас ' + cur.panel + (bkVerCmp(d.panel, cur.panel) > 0 ? ' — настройки из более новой версии могут подойти не все' : ' — то, что появилось в панели позже, останется по умолчанию') ]); var slow = (d.install || []).length || (d.remove || []).length; return { title: 'Восстановить копию ' + bkTitle(it) + '?', lead: (pre || '') + 'Роутер вернёт всё, что настроено через панель, к состоянию на ' + bkWhen(d.at || it.at, true) + '. Займёт ' + (slow ? '3–10 минут' : '1–3 минуты') + ', интернет на это время может пропадать.', groups: [ { title: 'Что изменится', items: ch }, { title: 'Что может отличаться от копии', items: risk } ], tip: { title: 'Подстраховка:', text: 'перед восстановлением панель сама сохранит текущее состояние — передумаете, вернёте его одной кнопкой. Если после смены сети роутер потеряет интернет, прежняя сеть вернётся сама через 90 секунд.' }, check: 'Понимаю: текущие настройки заменятся копией, а результат может отличаться в том, что перечислено выше', okText: 'Восстановить' }; } function bkRestore(it, pre) { if (bkBusy) return; var bt = zm.busyToast('Сравниваем копию с тем, что сейчас на роутере…'); zm.backupAction('diff', it.id).then(function(d) { bt.close(); if (!d || d.error) { zm.toast((d && d.error) || 'Роутер не ответил', 'error'); return; } if (d.busy) { zm.toast('Сейчас идёт операция «' + d.busy + '» — дождитесь её окончания', 'warning'); return; } return zm.warnDialog(bkPlan(it, d, pre)).then(function(ok) { if (!ok) return; bkBusy = 'restore'; renderBk(); bkLog.classList.add('zm-show'); zm.renderLog(bkLog, '==> Запускаем восстановление'); return zm.backupAction('restore', it.id).then(function(r) { if (!r || r.error || !r.started) throw new Error((r && r.error) || 'роутер не ответил'); zm.toast('Восстановление запущено — страницу можно закрыть, роутер доделает сам', 'warning'); bkFollow(function(ok2) { zm.toast(ok2 ? 'Восстановлено — всё как в копии' : 'Восстановлено с отличиями — они перечислены в журнале', ok2 ? 'info' : 'warning'); }); }); }); }).catch(function(e) { bt.close(); bkBusy = ''; renderBk(); zm.toast((e && e.message) || 'Роутер не ответил', 'error'); }); } function bkDel(it) { if (bkBusy) return; zm.ask('Удалить копию ' + bkTitle(it) + '?\n\nОна удалится с роутера. Если вы её скачивали, файл на компьютере останется.', { okText: 'Удалить', danger: true }).then(function(ok) { if (!ok) return; return zm.backupAction('delete', it.id).then(function(r) { if (!r || r.error) { zm.toast((r && r.error) || 'Роутер не ответил', 'error'); return; } zm.toast('Копия удалена', 'info'); bkLoad(); }); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } function bkName(it) { var d = new Date((+it.at || 0) * 1000), h = String(it.host || 'router').replace(/[^A-Za-z0-9_-]+/g, '') || 'router'; return 'zapret-manager-' + h + '-' + d.getFullYear() + '-' + bkPad(d.getMonth() + 1) + '-' + bkPad(d.getDate()) + '_' + bkPad(d.getHours()) + '-' + bkPad(d.getMinutes()) + '.zmb'; } function bkDown(it) { if (bkBusy) return; var bt = zm.busyToast('Скачиваем копию…', '0%'), parts = [], off = 0, total = 0; bkBusy = 'down'; renderBk(); function step() { return zm.backupDown(it.id, String(off)).then(function(r) { if (!r || r.error || typeof r.data !== 'string') throw new Error((r && r.error) || 'роутер не ответил'); var bin = atob(r.data), u = new Uint8Array(bin.length); for (var i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i); parts.push(u); off += u.length; total = +r.size || 0; bt.set('Скачиваем копию…', (total ? Math.min(100, Math.round(off * 100 / total)) : 100) + '%'); if (u.length && off < total) return step(); if (off !== total) throw new Error('файл скачался не полностью'); }); } step().then(function() { bt.close(); bkBusy = ''; renderBk(); var a = E('a', { 'href': URL.createObjectURL(new Blob(parts, { type: 'application/octet-stream' })), 'download': bkName(it), 'style': 'display:none' }); document.body.appendChild(a); a.click(); setTimeout(function() { URL.revokeObjectURL(a.href); if (a.parentNode) a.parentNode.removeChild(a); }, 2000); zm.toast('Файл копии скачан. В нём есть ключи и ссылки — храните его у себя', 'info'); }).catch(function(e) { bt.close(); bkBusy = ''; renderBk(); zm.toast('Скачать не удалось: ' + ((e && e.message) || 'роутер не ответил'), 'error'); }); } function bkB64(u8) { var s = '', CH = 0x8000; for (var i = 0; i < u8.length; i += CH) s += String.fromCharCode.apply(null, u8.subarray(i, i + CH)); return btoa(s); } function bkUpload(f) { if (bkBusy) return; if (!f.size) { zm.toast('Файл пустой', 'error'); return; } if (f.size > 24 * 1048576) { zm.toast('Файл больше 24 МБ — это не похоже на копию панели', 'error'); return; } if (!/\.zmb$/i.test(f.name || '')) { zm.toast('Нужен файл копии Zapret Manager (.zmb)', 'error'); return; } var bt = zm.busyToast('Загружаем копию на роутер…', '0%'), rd = new FileReader(); bkBusy = 'upload'; renderBk(); function fail(e) { bt.close(); bkBusy = ''; renderBk(); zm.toast('Загрузить не удалось: ' + ((e && e.message) || 'роутер не ответил'), 'error'); } rd.onerror = function() { fail(new Error('файл не прочитался')); }; rd.onload = function() { var b64 = bkB64(new Uint8Array(rd.result)), CH = 48000, n = Math.ceil(b64.length / CH), k = 0; function next() { if (k >= n) { bt.set('Проверяем копию на роутере…', ''); return zm.backupAction('put', ''); } return zm.backupUp(k ? 'next' : 'first', b64.slice(k * CH, (k + 1) * CH)).then(function(r) { if (!r || r.error) throw new Error((r && r.error) || 'роутер не ответил'); k++; bt.set('Загружаем копию на роутер…', Math.round(k * 100 / n) + '%'); return next(); }); } next().then(function(r) { if (!r || r.error || !r.id) throw new Error((r && r.error) || 'роутер не ответил'); bt.close(); bkBusy = ''; return bkLoad().then(function(dd) { var it = ((dd && dd.items) || []).filter(function(x) { return x.id === r.id; })[0]; if (it) bkRestore(it, r.same ? 'Эта копия уже была на роутере. ' : 'Копия загружена и проверена — файл целый. '); else zm.toast('Копия загружена', 'info'); }); }).catch(fail); }; rd.readAsArrayBuffer(f); } renderBk(); if (on('backup')) bkLoad(); put('backup', bkCard); var cleanLog = E('pre', { 'class': 'zm-log' }); var cleanBusy = false; function fmtKb(k) { k = parseInt(k, 10) || 0; if (k < 1024) return k + ' КБ'; return (k / 1024).toFixed(k < 10240 ? 1 : 0) + ' МБ'; } var uninstallLog = E('pre', { 'class': 'zm-log' }); var doClean = function() { if (cleanBusy) { zm.toast('Очистка уже идёт', 'warning'); return; } cleanBusy = true; cleanLog.classList.add('zm-show'); zm.renderLog(cleanLog, '==> Удаляем временные файлы и мусор панели'); zm.zmCleanup().then(function(res) { cleanBusy = false; if (!res || res.error) { var why = (res && res.error) || 'роутер не ответил'; zm.renderLog(cleanLog, '==> Очистка не выполнена: ' + why); zm.toast(why, 'error'); return; } var lines = [ '==> Удаляем временные файлы и мусор панели', ' ✓ Удалено файлов и папок: ' + (parseInt(res.removed, 10) || 0), ' ✓ Освобождено на флеше: ' + fmtKb(res.freed_flash_kb), ' ✓ Освобождено в ОЗУ: ' + fmtKb(Math.max(parseInt(res.freed_tmp_kb, 10) || 0, parseInt(res.freed_mem_kb, 10) || 0)) ]; if ((parseInt(res.refs_missing, 10) || 0) > 0) lines.push('!! Правилам ядра steer не хватает файлов списков: ' + res.refs_missing + '. Очистка их не трогала — запустите обновление панели (команда zmw) при работающем интернете, списки скачаются заново'); lines.push('==> Готово'); zm.renderLog(cleanLog, lines.join('\n')); zm.toast('Очистка выполнена', 'info'); }).catch(function() { cleanBusy = false; zm.renderLog(cleanLog, '==> Очистка не выполнена: роутер не ответил'); zm.toast('Роутер не ответил', 'error'); }); }; var doUninstall = function() { zm.dialog({ title: 'Удалить Zapret Manager?', danger: true, okText: 'Удалить панель', blocks: [ { type: 'list', title: 'Что произойдёт', items: function(v) { return [ 'Удалится панель — приложение в LuCI и Web UI', 'Zapret, Steer, Forkozz, Mixomo и остальное останутся и продолжат работать', 'Выключится то, что делает сама панель: блокировка рекламы, автопереключение DoH, ночной автоподбор стратегии, автообновления списков и прокси, автоподбор WARP и подписки Steer', v.keep ? 'Настройки панели останутся на роутере: поставите панель снова — блокировка рекламы и расписания включатся сами' : 'Настройки панели удалятся вместе с ней: блокировку рекламы и расписания придётся настраивать заново' ]; } }, { type: 'switch', id: 'keep', value: true, label: 'Сохранить настройки панели', hint: 'Блокировка рекламы (списки, белый список, статистика), предупреждения и расписания. Займут немного места на флеше — в основном списки рекламы. Выключите, если удаляете панель насовсем.' }, { type: 'note', kind: 'warn', text: 'Панель придётся ставить заново. Эта страница сразу станет недоступна.' } ] }).then(function(v) { if (!v) return; uninstallLog.classList.add('zm-show'); zm.renderLog(uninstallLog, '==> Удаляем панель Zapret Manager'); zm.toast('Удаляем панель Zapret Manager', 'warning'); zm.systemUninstallPanel(v.keep ? 'keep' : 'wipe').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); }); }); }; function rmTile(cls, title, text, btn) { return E('div', { 'class': 'zm-rm-tile' + (cls ? ' ' + cls : '') }, [ E('b', { 'class': 'zm-rm-title' }, title), E('p', { 'class': 'zm-hint zm-rm-text' }, text), E('div', { 'class': 'zm-rm-btn' }, [ btn ]) ]); } var pgBox = E('div', { 'class': 'zm-pg', 'style': 'display:none' }), pgLog = E('pre', { 'class': 'zm-log' }); var pgData = null, pgSel = {}, pgBusy = false, pgScanning = false, pgOpen = false, pgErr = ''; var pgBtn = E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { pgOpen = !pgOpen; pgBtn.textContent = pgOpen ? 'Скрыть' : 'Выбрать, что удалить'; pgBox.style.display = pgOpen ? '' : 'none'; if (pgOpen && !pgData && !pgScanning) pgScan(true); else pgRender(); } }, 'Выбрать, что удалить'); var rmCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Очистка и удаление'), E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Выберите, насколько глубоко чистить.'), E('div', { 'class': 'zm-rm-tiles' }, [ rmTile('', 'Очистить мусор', 'Журналы операций, результаты тестов, недокачанные архивы, кэш списков и остатки прошлых версий. Настройки, программы и нужные списки не трогает.', E('button', { 'class': 'cbi-button cbi-button-action', 'click': doClean }, 'Очистить')), rmTile('', 'Удалить панель', 'Только приложение в LuCI и Web UI. Zapret, Steer, Forkozz и остальное останутся и продолжат работать. Настройки панели можно сохранить.', E('button', { 'class': 'cbi-button cbi-button-remove', 'click': doUninstall }, 'Удалить панель')), rmTile('zm-rm-danger', 'Полное удаление', 'Всё, что ставила панель: программы, пакеты, файлы, настройки, правила и задания — вместе с хвостами, даже если программа уже удалена.', pgBtn) ]), cleanLog, uninstallLog, pgBox ]); put('cleanup', rmCard); var PG_GROUPS = [ [ 'block', 'Обход блокировок' ], [ 'vpn', 'VPN и прокси' ], [ 'dns', 'DNS и сеть' ], [ 'tools', 'Инструменты' ], [ 'panel', 'Сама панель' ] ]; var PG_STATE = { installed: [ 'zm-ok', 'установлен' ], tails: [ 'zm-warn', 'хвосты' ], clean: [ 'zm-off', 'чисто' ] }; var pgWord = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'spellcheck': 'false', 'autocomplete': 'off', 'placeholder': 'УДАЛИТЬ', 'aria-label': 'Подтверждение: введите слово УДАЛИТЬ' }); var pgGo = E('button', { 'class': 'cbi-button cbi-button-remove', 'click': pgRun }, 'Удалить выбранное'); pgWord.addEventListener('input', pgPaintGo); function pgItems() { return (pgData && pgData.items) || []; } function pgIds() { return pgItems().filter(function(it) { return pgSel[it.id]; }).map(function(it) { return it.id; }); } function pgWordOk() { return pgWord.value.trim().toUpperCase() === 'УДАЛИТЬ'; } function pgPaintGo() { var n = pgIds().length; pgGo.textContent = pgBusy ? 'Удаляем…' : 'Удалить выбранное' + (n ? ' (' + n + ')' : ''); pgGo.disabled = pgBusy || !n || !pgWordOk() || !!(pgData && pgData.busy); } function pgScan(first) { pgScanning = true; pgErr = ''; pgRender(); zm.purgeScan().then(function(r) { pgScanning = false; if (!r || r.error || !r.items) { pgErr = (r && r.error) || 'роутер не ответил'; pgRender(); return; } pgData = r; if (first) { pgSel = {}; r.items.forEach(function(it) { if (it.state !== 'clean' && it.id !== 'panel') pgSel[it.id] = true; }); } pgRender(); }, function() { pgScanning = false; pgErr = 'роутер не ответил'; pgRender(); }); } function pgMark(how) { if (pgBusy) return; pgSel = {}; pgItems().forEach(function(it) { if (how === 'all' || (how === 'found' && it.state !== 'clean' && it.id !== 'panel')) pgSel[it.id] = true; }); pgRender(); } function pgRow(it) { var on = !!pgSel[it.id], st = PG_STATE[it.state] || PG_STATE.clean; var sub = it.items.length ? it.items.slice(0, 3).join(' · ') + (it.n > 3 ? ' · ещё ' + (it.n - 3) : '') : 'на роутере ничего не найдено — удаление всё равно пройдёт по списку'; var box = E('input', { 'type': 'checkbox', 'class': 'zm-pg-check', 'aria-label': it.title }); box.checked = on; box.disabled = pgBusy; var row = E('label', { 'class': 'zm-pg-row' + (on ? ' zm-pg-on' : '') + (it.id === 'panel' ? ' zm-pg-self' : '') }, [ box, E('span', { 'class': 'zm-pg-main' }, [ E('span', { 'class': 'zm-pg-name' }, it.title), E('span', { 'class': 'zm-pg-sub', 'title': it.items.join('\n') }, sub) ]), E('span', { 'class': 'zm-badge ' + st[0] }, [ E('span', { 'class': 'zm-dot' }), it.id === 'panel' ? 'эта панель' : st[1] ]) ]); box.addEventListener('change', function() { pgSel[it.id] = box.checked; pgRender(); }); return row; } function pgRender() { if (!pgOpen) return; pgBox.innerHTML = ''; var items = pgItems(), cnt = { installed: 0, tails: 0, clean: 0 }; items.forEach(function(it) { if (it.id !== 'panel') cnt[it.state] = (cnt[it.state] || 0) + 1; }); pgBox.appendChild(E('div', { 'class': 'zm-pg-head' }, [ E('div', {}, [ E('h4', { 'style': 'margin:0' }, 'Полное удаление'), E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, pgScanning ? 'Проверяем роутер…' : pgErr ? 'Проверка не удалась: ' + pgErr : pgData ? 'Стоит: ' + cnt.installed + ' · с хвостами: ' + cnt.tails + ' · чисто: ' + cnt.clean : '') ]), E('button', { 'class': 'cbi-button', 'disabled': pgScanning || pgBusy ? '' : null, 'click': function() { pgScan(false); } }, pgScanning ? 'Проверяем…' : 'Проверить снова') ])); if (!pgData) { if (pgLog.classList.contains('zm-show')) pgBox.appendChild(pgLog); return; } if (pgData.busy && !pgBusy) pgBox.appendChild(E('p', { 'class': 'zm-hint zm-ab-warn' }, 'Сейчас идёт операция «' + pgData.busy + '» — дождитесь её окончания и нажмите «Проверить снова».')); PG_GROUPS.forEach(function(g) { var list = items.filter(function(it) { return it.group === g[0]; }); if (!list.length) return; pgBox.appendChild(E('div', { 'class': 'zm-pg-group' }, [ E('div', { 'class': 'zm-pg-group-title' }, g[1]) ].concat(list.map(pgRow)))); }); pgBox.appendChild(E('div', { 'class': 'zm-actions zm-pg-tools' }, [ E('button', { 'class': 'cbi-button', 'disabled': pgBusy ? '' : null, 'click': function() { pgMark('found'); } }, 'Отметить найденное'), E('button', { 'class': 'cbi-button', 'disabled': pgBusy ? '' : null, 'click': function() { pgMark('all'); } }, 'Отметить всё'), E('button', { 'class': 'cbi-button', 'disabled': pgBusy ? '' : null, 'click': function() { pgMark('none'); } }, 'Снять все') ])); var notes = [ 'Сеть, Wi‑Fi и пакеты OpenWrt не трогаются — удаляется только то, что ставила панель. Свои записи в настройках DNS и hosts остаются.' ]; if (pgSel.panel) notes.push('Панель удалится последней — после этого страница перестанет открываться. Вернуть её — команда установки или zmw.'); pgBox.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show zm-pg-note', 'role': 'note' }, notes.map(function(t) { return E('span', {}, t); }))); pgBox.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin:12px 0 6px' }, 'Для подтверждения введите слово УДАЛИТЬ')); pgWord.disabled = pgBusy; pgBox.appendChild(E('div', { 'class': 'zm-actions zm-pg-go' }, [ pgWord, pgGo ])); pgPaintGo(); pgBox.appendChild(pgLog); } function pgRun() { var ids = pgIds(); if (pgBusy || !ids.length || !pgWordOk()) return; pgBusy = true; pgRender(); pgLog.classList.add('zm-show'); var lines = [ '==> Полное удаление' ]; zm.renderLog(pgLog, lines.join('\n')); zm.purgeRun(ids.join(',')).then(function(res) { if (!res || res.error || !res.started) throw new Error((res && res.error) || 'роутер не ответил'); zm.toast('Полное удаление запущено', 'warning'); zm.pollJob('purge', pgLog, function(ok) { pgBusy = false; if (ids.indexOf('panel') >= 0) { zm.toast('Всё выбранное удалено, панель удаляется', 'info'); setTimeout(function() { location.href = L.url('admin/logout'); }, 4000); return; } zm.toast(ok ? 'Всё выбранное удалено' : 'Удаление закончилось, но кое-что осталось — смотрите журнал', ok ? 'info' : 'warning'); pgWord.value = ''; pgScan(false); }); }).catch(function(e) { pgBusy = false; if (ids.indexOf('panel') >= 0 && pgLog.textContent.indexOf('панель удаляется') >= 0) { setTimeout(function() { location.href = L.url('admin/logout'); }, 3000); return; } lines.push('==> ОШИБКА: ' + (e && e.message || 'роутер не ответил')); zm.renderLog(pgLog, lines.join('\n')); zm.toast(e && e.message || 'Роутер не ответил', 'error'); pgRender(); }); } var CREDITS = [ { g: 'zm', product: 'Zapret Manager и ByeTube', author: 'StressOzz', url: 'https://github.com/StressOzz' }, { g: 'block', product: 'zapret, zapret2', author: 'bol-van', url: 'https://github.com/bol-van' }, { g: 'block', product: 'zapret-openwrt', author: 'remittor', url: 'https://github.com/remittor/zapret-openwrt' }, { g: 'block', product: 'Zapret2, сервер удалённого доступа', author: 'routerich', url: 'https://github.com/routerich' }, { g: 'block', product: 'стратегии Flowseal', author: 'Flowseal', url: 'https://github.com/Flowseal/zapret-discord-youtube' }, { g: 'block', product: 'ByeDPI', author: 'hufrea', url: 'https://github.com/hufrea/byedpi' }, { g: 'block', product: 'ByeDPI-OpenWrt', author: 'DPITrickster', url: 'https://github.com/DPITrickster/ByeDPI-OpenWrt' }, { g: 'block', product: 'zapret4rocket (список РКН)', author: 'IndeecFOX', url: 'https://github.com/IndeecFOX/zapret4rocket' }, { g: 'block', product: 'dpi-checkers', author: 'hyperion-cs', url: 'https://github.com/hyperion-cs/dpi-checkers' }, { g: 'vpn', product: 'sTGWS, Steer, каталог списков', author: 'xyzmean', url: 'https://github.com/xyzmean' }, { g: 'vpn', product: 'NetShift (основа Forkozz)', author: 'yandexru45', url: 'https://github.com/yandexru45/netshift' }, { g: 'vpn', product: 'sing-box', author: 'SagerNet', url: 'https://github.com/SagerNet/sing-box' }, { g: 'vpn', product: 'sing-box-extended', author: 'shtorm-7', url: 'https://github.com/shtorm-7/sing-box-extended' }, { g: 'vpn', product: 'sing-box-extended-lite', author: 'yandexru45', url: 'https://github.com/yandexru45/sing-box-extended-lite' }, { g: 'vpn', product: 'Mixomo, GeoHideDNS', author: 'Internet-Helper', url: 'https://github.com/Internet-Helper' }, { g: 'vpn', product: 'mihomo, metacubexd, meta-rules-dat', author: 'MetaCubeX', url: 'https://github.com/MetaCubeX' }, { g: 'vpn', product: 'zashboard', author: 'Zephyruso', url: 'https://github.com/Zephyruso/zashboard' }, { g: 'vpn', product: 'hev-socks5-tunnel', author: 'heiher', url: 'https://github.com/heiher/hev-socks5-tunnel' }, { g: 'vpn', product: 'MagiTrickle', author: 'MagiTrickle', url: 'https://github.com/MagiTrickle/MagiTrickle' }, { g: 'vpn', product: 'AmneziaWG', author: 'amnezia-vpn', url: 'https://github.com/amnezia-vpn' }, { g: 'vpn', product: 'Tailscale', author: 'Tailscale', url: 'https://github.com/tailscale/tailscale' }, { g: 'vpn', product: 'openwrt-tailscale (сборка для OpenWrt)', author: 'GuNanOvO', url: 'https://github.com/GuNanOvO/openwrt-tailscale' }, { g: 'vpn', product: 'luci-app-tailscale-community', author: 'Tokisaki-Galaxy', url: 'https://github.com/Tokisaki-Galaxy/luci-app-tailscale-community' }, { g: 'vpn', product: 'NaïveProxy (для Forkozz)', author: 'klzgrad', url: 'https://github.com/klzgrad/naiveproxy' }, { g: 'vpn', product: 'TailSocks (клиент для Android)', author: 'bropines', url: 'https://github.com/bropines/tailsocks' }, { g: 'vpn', product: 'awg-openwrt (пакеты AmneziaWG)', author: 'Slava-Shchipunov', url: 'https://github.com/Slava-Shchipunov/awg-openwrt' }, { g: 'vpn', product: 'awg-openwrt (сборки)', author: '2Grey', url: 'https://github.com/2Grey/awg-openwrt' }, { g: 'vpn', product: 'warpscout (разведка WARP)', author: 'vernette', url: 'https://github.com/vernette/warpscout' }, { g: 'vpn', product: 'base-relay (реле регистрации WARP)', author: 'nellimonix', url: 'https://github.com/nellimonix/base-relay' }, { g: 'vpn', product: 'генератор WARP для Mixomo', author: 'santa-atmo.ru', url: 'https://santa-atmo.ru' }, { g: 'tg', product: 'tg-ws-proxy (список доменов Cloudflare)', author: 'Flowseal', url: 'https://github.com/Flowseal/tg-ws-proxy' }, { g: 'tg', product: 'tg-ws-proxy-go (MTProto)', author: 'spatiumstas', url: 'https://github.com/spatiumstas/tg-ws-proxy-go' }, { g: 'tg', product: 'tg-ws-proxy-go (SOCKS5)', author: 'd0mhate', url: 'https://github.com/d0mhate/-tg-ws-proxy-Manager-go' }, { g: 'tg', product: 'tg-ws-proxy-rs (Rust)', author: 'valnesfjord', url: 'https://github.com/valnesfjord/tg-ws-proxy-rs' }, { g: 'tg', product: 'QR-коды для прокси Telegram', author: 'goQR.me', url: 'https://goqr.me' }, { g: 'dns', product: 'allow-domains, Podkop', author: 'itdoginfo', url: 'https://github.com/itdoginfo' }, { g: 'dns', product: 'https-dns-proxy', author: 'stangri', url: 'https://github.com/stangri/https-dns-proxy' }, { g: 'dns', product: 'dns.malw.link (DoH и hosts)', author: 'ImMALWARE', url: 'https://github.com/ImMALWARE/dns.malw.link' }, { g: 'dns', product: 'Comss.one DNS (DoH)', author: 'Comss.ru', url: 'https://www.comss.ru/page.php?id=7315' }, { g: 'dns', product: 'XBOX DNS (DoH)', author: 'команда XBOX DNS', url: 'https://xbox-dns.ru' }, { g: 'dns', product: 'DNS-AI (DoH)', author: 'команда DNS-AI', url: 'https://dns-ai.ru' }, { g: 'dns', product: 'Bezmezhau DNS (DoH)', author: 'Bezmezhau Team', url: 'https://bezmezhau.com' }, { g: 'dns', product: 'zapret.moe (DoH)', author: 'zapret.moe', url: 'https://zapret.moe' }, { g: 'dns', product: 'WebShift DNS (DoH)', author: 'WebShift', url: 'https://dns.webshiftvpn.ru' }, { g: 'dns', product: 'Dagami DNS (DoH)', author: 'Dagami', url: 'https://dns.dagami.ru' }, { g: 'dns', product: 'dns-blocklists (блокировка рекламы, TIF)', author: 'hagezi', url: 'https://github.com/hagezi/dns-blocklists' }, { g: 'dns', product: 'ad-filter (реклама для sing-box)', author: 'zxc-rv', url: 'https://github.com/zxc-rv/ad-filter' }, { g: 'dns', product: 'domain-list-community', author: 'v2fly', url: 'https://github.com/v2fly/domain-list-community' }, { g: 'dns', product: 'ios_rule_script (списки)', author: 'blackmatrix7', url: 'https://github.com/blackmatrix7/ios_rule_script' }, { g: 'dns', product: 'b4geoip (списки игр и сервисов)', author: 'DanielLavrushin', url: 'https://github.com/DanielLavrushin/b4geoip' }, { g: 'dns', product: 'b4geoip-forkop (сборка для sing-box)', author: 'Greeg0ry', url: 'https://github.com/Greeg0ry/b4geoip-forkop' }, { g: 'dns', product: 'sing-box-supercell-ruleset', author: 'ushan0v', url: 'https://github.com/ushan0v/sing-box-supercell-ruleset' }, { g: 'other', product: 'ttyd (терминал)', author: 'tsl0922', url: 'https://github.com/tsl0922/ttyd' }, { g: 'other', product: 'флаги стран (country-flag-emoji-polyfill)', author: 'TalkJS', url: 'https://github.com/talkjs/country-flag-emoji-polyfill' } ]; var CREDIT_GROUPS = [ [ 'zm', 'Zapret Manager' ], [ 'block', 'Обход блокировок' ], [ 'vpn', 'VPN, прокси и туннели' ], [ 'tg', 'Telegram' ], [ 'dns', 'DNS, списки и блокировка рекламы' ], [ 'other', 'Прочее' ] ]; var thanksOpen = false; try { thanksOpen = localStorage.getItem('zm.thanks.open') === '1'; } catch (e) {} var thanksBtn = E('button', { 'class': 'cbi-button zm-th-btn', 'type': 'button', 'aria-expanded': 'false', 'click': function() { setThanks(!thanksOpen); } }); var thanksList = E('div', { 'class': 'zm-th-list' }, CREDIT_GROUPS.map(function(gr) { var items = CREDITS.filter(function(c) { return c.g === gr[0]; }); if (!items.length) return ''; return E('div', { 'class': 'zm-th-group' }, [ E('div', { 'class': 'zm-th-ghead' }, gr[1]), E('div', { 'class': 'zm-th-rows' }, items.map(function(c) { return E('div', { 'class': 'zm-th-row' + (c.g === 'zm' ? ' zm-th-self' : '') }, [ E('b', {}, c.product), E('span', { 'class': 'zm-th-author' }, c.author), c.url ? E('a', { 'href': c.url, 'target': '_blank', 'rel': 'noreferrer' }, c.url.replace(/^https?:\/\/(github\.com\/)?/, '')) : E('i') ]); })) ]); })); function setThanks(open) { thanksOpen = open; try { localStorage.setItem('zm.thanks.open', open ? '1' : '0'); } catch (e) {} thanksList.style.display = open ? '' : 'none'; thanksBtn.setAttribute('aria-expanded', open ? 'true' : 'false'); thanksBtn.innerHTML = ''; thanksBtn.appendChild(document.createTextNode(open ? 'Свернуть ' : 'Все авторы ')); thanksBtn.appendChild(E('span', { 'class': 'zm-th-caret' + (open ? ' zm-th-up' : '') }, '▾')); } var creditsCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Спасибо'), E('div', { 'class': 'zm-th-hero' }, [ E('div', { 'class': 'zm-th-heart', 'aria-hidden': 'true' }, '♥'), E('div', { 'class': 'zm-th-text' }, [ E('h4', {}, 'Спасибо авторам и всем пользователям'), E('p', {}, 'Работа открытых проектов и помощь людей, которые тестируют и поддерживают Zapret Manager. Без вас его бы не было.') ]), thanksBtn ]), thanksList ]); setThanks(thanksOpen); put('changelog', clCard); put('thanks', creditsCard); return wrap; } }); ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/system.js' cat > '/www/luci-static/resources/view/zapret-manager/monitor.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require view'; 'require zapret-manager.common as zm'; var E = (function(raw) { return function() { var a = Array.prototype.slice.call(arguments), i = a.length - 1; if (i >= 1 && (typeof a[i] === 'string' || typeof a[i] === 'number')) a[i] = [ String(a[i]) ]; return raw.apply(null, a); }; })(window.E); var SVGNS = 'http://www.w3.org/2000/svg'; var WIN = 300, HIST_MAX = 400; var COMPS = [ { id: 'zapret2', name: 'Zapret2', color: '#a855f7', rx: /\/opt\/zapret2\/|nfqws2/ }, { id: 'zapret', name: 'Zapret', color: '#7c5cff', rx: /\/opt\/zapret\/|(^|\/)nfqws( |$)|tpws/ }, { id: 'forkozz', name: 'Forkozz', color: '#3b82f6', rx: /sing-box|netshift/ }, { id: 'steer', name: 'Steer', color: '#0ea5e9', rx: /(^|\/)steer/ }, { id: 'mixomo', name: 'Mixomo', color: '#14b8a6', rx: /mihomo|magitrickle|hev-socks5-tunnel/ }, { id: 'bytetube', name: 'ByeTube', color: '#ef4444', rx: /ciadpi|byedpi|bytetube/ }, { id: 'tg', name: 'TG WS Proxy', color: '#38bdf8', rx: /tg-ws-proxy|(^|\/)s?tgws/ }, { id: 'doh', name: 'DNS over HTTPS', color: '#22d3ee', rx: /https-dns-proxy/ }, { id: 'dns', name: 'DNS (dnsmasq)', color: '#06b6d4', rx: /(^|\/)dnsmasq/ }, { id: 'term', name: 'Терминал', color: '#f59e0b', rx: /(^|\/)ttyd|(^|\/)htop/ }, { id: 'panel', name: 'Панель и LuCI', color: '#8b5cf6', rx: /uhttpd|(^|\/)rpcd|zapret-manager|backend\.sh/ }, { id: 'wifi', name: 'Wi-Fi', color: '#f472b6', rx: /hostapd|wpa_supplicant/ }, { id: 'kernel', name: 'Ядро Linux', color: '#64748b', rx: null }, { id: 'system', name: 'OpenWrt', color: '#94a3b8', rx: null } ]; var COMP_BY_ID = {}; COMPS.forEach(function(c) { COMP_BY_ID[c.id] = c; }); var PROTECTED = { procd: 1, ubusd: 1, rpcd: 1, uhttpd: 1, netifd: 1, logd: 1, ubox: 1, init: 1 }; function compOf(p) { if (p.ppid === 2 || p.pid === 2 || (!p.cmd && /^(kworker|ksoftirqd|kthreadd|migration|rcu_|irq\/|watchdog|cpuhp)/.test(p.name))) return COMP_BY_ID.kernel; var s = (p.cmd || '') + ' ' + p.name; for (var i = 0; i < COMPS.length; i++) if (COMPS[i].rx && COMPS[i].rx.test(s)) return COMPS[i]; return COMP_BY_ID.system; } function svg(tag, attrs, kids) { var el = document.createElementNS(SVGNS, tag); for (var k in attrs) if (attrs[k] != null) el.setAttribute(k, attrs[k]); (kids || []).forEach(function(c) { el.appendChild(c); }); return el; } function plural(n, one, few, many) { var a = Math.abs(n) % 100, b = a % 10; return a > 10 && a < 20 ? many : b === 1 ? one : b >= 2 && b <= 4 ? few : many; } function num(v, d) { return (Math.round(v * Math.pow(10, d || 0)) / Math.pow(10, d || 0)).toLocaleString('ru-RU', { minimumFractionDigits: d || 0, maximumFractionDigits: d || 0 }); } function mb(kb) { return kb >= 1048576 ? num(kb / 1048576, 1) + ' ГБ' : kb >= 1024 ? num(kb / 1024, kb >= 102400 ? 0 : 1) + ' МБ' : num(kb, 0) + ' КБ'; } function pct(v) { return num(v, v < 10 ? 1 : 0) + '%'; } function rate(bps) { var bit = bps * 8; if (bit >= 1e9) return [ num(bit / 1e9, 2), 'Гбит/с' ]; if (bit >= 1e6) return [ num(bit / 1e6, bit >= 1e8 ? 0 : 1), 'Мбит/с' ]; if (bit >= 1e3) return [ num(bit / 1e3, 0), 'Кбит/с' ]; return [ num(bit, 0), 'бит/с' ]; } function upText(sec) { sec = Math.floor(sec || 0); var d = Math.floor(sec / 86400), h = Math.floor(sec % 86400 / 3600), m = Math.floor(sec % 3600 / 60); return d ? d + ' д ' + h + ' ч' : h ? h + ' ч ' + m + ' мин' : m + ' мин'; } function level(v, warn, bad) { return v >= bad ? 'bad' : v >= warn ? 'warn' : 'ok'; } return view.extend({ load: function() { zm.injectCss(); return Promise.all([ zm.monitor().catch(function() { return null; }), zm.monitorProcs().catch(function() { return null; }) ]); }, render: function(init) { var root = E('div', { 'class': 'zm-wrap zm-mon' }); var snap = null, prev = null, procs = [], prevProc = null, prevProcTotal = 0, procPct = {}; var hist = { cpu: [], mem: [], temp: [], t: [] }; var paused = false, timer = null, ptimer = null, failed = 0, sortBy = 'cpu', mode = 'over', procErr = ''; var cpuNow = { all: 0, cores: [], user: 0, sys: 0, irq: 0, io: 0 }; var devEl = E('span', { 'class': 'zm-mon-dev' }, '—'); var liveEl = E('span', { 'class': 'zm-badge zm-ok' }, [ E('span', { 'class': 'zm-dot' }), 'обновляется каждые 2 с' ]); var pauseBtn = E('button', { 'class': 'cbi-button', 'click': function() { paused = !paused; renderLive(); if (!paused) { tick(); ptick(); } } }, 'Пауза'); var procsBtn = E('button', { 'class': 'cbi-button', 'click': function() { openProcs(); } }, 'Все процессы'); var tabSeg = E('div', { 'class': 'zm-seg zm-mon-tabs', 'role': 'tablist', 'aria-label': 'Вид монитора' }); function renderTabs() { tabSeg.innerHTML = ''; [ [ 'over', 'Обзор' ], [ 'htop', 'htop' ] ].forEach(function(o) { tabSeg.appendChild(E('div', { 'class': 'zm-seg-item' + (mode === o[0] ? ' zm-active' : ''), 'role': 'tab', 'tabindex': '0', 'aria-selected': mode === o[0] ? 'true' : 'false', 'click': function() { setMode(o[0]); }, 'keydown': function(ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); setMode(o[0]); } } }, o[1])); }); } var overTools = E('div', { 'class': 'zm-mon-head-r' }, [ liveEl, pauseBtn, procsBtn ]); var head = E('div', { 'class': 'zm-card zm-mon-head' }, [ E('div', { 'class': 'zm-mon-head-l' }, [ E('h3', {}, 'Монитор ресурсов'), devEl ]), E('div', { 'class': 'zm-mon-head-r' }, [ overTools, tabSeg, E('button', { 'class': 'cbi-button zm-mon-help', 'title': 'Как работает монитор', 'aria-label': 'Как работает монитор', 'click': function() { monInfo(); } }, '?') ]) ]); function renderLive() { liveEl.className = 'zm-badge ' + (paused ? 'zm-off' : failed > 2 ? 'zm-bad' : 'zm-ok'); liveEl.lastChild.textContent = paused ? 'на паузе' : failed > 2 ? 'роутер не отвечает' : 'обновляется каждые 2 с'; pauseBtn.textContent = paused ? 'Продолжить' : 'Пауза'; } function ring(label, color) { var R = 34, C = 2 * Math.PI * R; var arc = svg('circle', { cx: 42, cy: 42, r: R, fill: 'none', 'stroke-width': 9, 'stroke-linecap': 'round', 'stroke-dasharray': '0 ' + C, transform: 'rotate(-90 42 42)', 'class': 'zm-mon-arc' }); var txt = svg('text', { x: 42, y: 48, 'text-anchor': 'middle', 'class': 'zm-mon-ringtxt' }); var s = svg('svg', { width: 84, height: 84, viewBox: '0 0 84 84', role: 'img', 'aria-label': label }, [ svg('circle', { cx: 42, cy: 42, r: R, fill: 'none', 'stroke-width': 9, 'class': 'zm-mon-track' }), arc, txt ]); var title = E('b', { 'class': 'zm-mon-big' }, '—'), sub = E('span', { 'class': 'zm-mon-sub' }, ''); var card = E('div', { 'class': 'zm-card zm-mon-gauge' }, [ s, E('div', { 'class': 'zm-mon-gauge-t' }, [ E('span', { 'class': 'zm-mon-cap' }, label), title, sub ]) ]); card.set = function(v, center, big, small, lvl) { var f = Math.max(0, Math.min(1, v / 100)); arc.setAttribute('stroke-dasharray', (f * C) + ' ' + C); arc.setAttribute('class', 'zm-mon-arc zm-mon-' + (lvl === 'bad' || lvl === 'off' ? lvl : color)); txt.textContent = center; title.textContent = big; sub.textContent = small || ''; }; return card; } var gCpu = ring('Процессор', 'cpu'), gTemp = ring('Температура', 'temp'), gMem = ring('Память', 'mem'), gFlash = ring('Флеш', 'flash'); var gauges = E('div', { 'class': 'zm-mon-gauges' }, [ gCpu, gTemp, gMem, gFlash ]); var W = 600, H = 170, PAD = 4, STEPS = [ 0, 20, 40, 60, 80, 100 ]; function yOf(v) { return PAD + (H - 2 * PAD) * (1 - Math.max(0, Math.min(100, v)) / 100); } var pCpuA = svg('path', { 'class': 'zm-mon-area' }), pCpu = svg('path', { 'class': 'zm-mon-line zm-mon-l-cpu' }); var pMem = svg('path', { 'class': 'zm-mon-line zm-mon-l-mem' }), pTemp = svg('path', { 'class': 'zm-mon-line zm-mon-l-temp' }); var gridEls = STEPS.map(function(v) { return svg('line', { x1: 0, y1: yOf(v), x2: W, y2: yOf(v), 'class': 'zm-mon-grid' + (v ? ' zm-mon-grid-d' : '') }); }) .concat([ 1, 2, 3, 4 ].map(function(m) { return svg('line', { x1: W * m / 5, y1: 0, x2: W * m / 5, y2: H, 'class': 'zm-mon-grid zm-mon-grid-v' }); })); var chart = svg('svg', { viewBox: '0 0 ' + W + ' ' + H, preserveAspectRatio: 'none', 'class': 'zm-mon-chart', role: 'img', 'aria-label': 'Загрузка за последние 5 минут' }, gridEls.concat([ pCpuA, pMem, pTemp, pCpu ])); function tickY(v, suf) { return E('span', { 'style': 'top:' + (100 * yOf(v) / H).toFixed(2) + '%' }, v + suf); } var axL = E('div', { 'class': 'zm-mon-ax zm-mon-ax-l', 'aria-hidden': 'true' }, STEPS.map(function(v) { return tickY(v, '%'); })); var axR = E('div', { 'class': 'zm-mon-ax zm-mon-ax-r', 'aria-hidden': 'true' }, STEPS.map(function(v) { return tickY(v, '°'); })); var axX = E('div', { 'class': 'zm-mon-ax-x', 'aria-hidden': 'true' }, [ 0, 1, 2, 3, 4, 5 ].map(function(m) { return E('span', { 'style': 'left:' + (m * 20) + '%' }, m === 5 ? 'сейчас' : '−' + (5 - m) + ' мин'); })); var xLine = E('div', { 'class': 'zm-mon-xline' }); var xDots = { cpu: E('i', { 'class': 'zm-mon-xdot zm-mon-l-cpu' }), mem: E('i', { 'class': 'zm-mon-xdot zm-mon-l-mem' }), temp: E('i', { 'class': 'zm-mon-xdot zm-mon-l-temp' }) }; var xTip = E('div', { 'class': 'zm-mon-xtip', 'role': 'status' }); var plot = E('div', { 'class': 'zm-mon-plot' }, [ chart, xLine, xDots.cpu, xDots.mem, xDots.temp, xTip ]); var hover = null; function legend(cls, text) { return E('span', { 'class': 'zm-mon-leg' }, [ E('i', { 'class': cls }), text ]); } var fanEl = E('span', { 'class': 'zm-badge zm-off zm-mon-fan', 'title': 'Вентилятор', 'style': 'display:none' }); var chartCard = E('div', { 'class': 'zm-card zm-mon-chartcard zm-mon-flex' }, [ E('div', { 'class': 'zm-mon-cardhead' }, [ E('div', { 'style': 'display:flex; align-items:center; gap:10px; flex-wrap:wrap' }, [ E('h3', { 'style': 'margin:0' }, 'Последние 5 минут'), fanEl ]), E('div', { 'class': 'zm-mon-legs' }, [ legend('zm-mon-l-cpu', 'Процессор'), legend('zm-mon-l-mem', 'Память'), legend('zm-mon-l-temp', 'Температура, °C') ]) ]), E('div', { 'class': 'zm-mon-chartbox' }, [ axL, plot, axR, E('div', {}), axX, E('div', {}) ]) ]); function xOf(t) { var tl = hist.t[hist.t.length - 1]; return W * (1 - (tl - t) / WIN); } function segs(arr) { var out = [], cur = null; arr.forEach(function(v, i) { if (v == null || (i && hist.t[i] - hist.t[i - 1] > 15)) { cur = null; if (v == null) return; } if (!cur) { cur = []; out.push(cur); } cur.push([ xOf(hist.t[i]), yOf(v) ]); }); return out; } function path(arr) { return segs(arr).map(function(sg) { return sg.map(function(q, i) { return (i ? 'L' : 'M') + q[0].toFixed(1) + ' ' + q[1].toFixed(1); }).join(' '); }).join(' '); } function area(arr) { return segs(arr).filter(function(sg) { return sg.length > 1; }).map(function(sg) { return sg.map(function(q, i) { return (i ? 'L' : 'M') + q[0].toFixed(1) + ' ' + q[1].toFixed(1); }).join(' ') + ' L' + sg[sg.length - 1][0].toFixed(1) + ' ' + H + ' L' + sg[0][0].toFixed(1) + ' ' + H + ' Z'; }).join(' '); } function agoText(sec) { sec = Math.max(0, Math.round(sec)); if (sec < 3) return 'сейчас'; var m = Math.floor(sec / 60), s2 = sec % 60; return (m ? m + ' мин ' : '') + (s2 || !m ? s2 + ' с ' : '') + 'назад'; } function drawHover() { var n = hist.t.length, i = -1, best = 1e9, tt, k; if (hover != null && n) { tt = hist.t[n - 1] - (1 - hover) * WIN; for (k = 0; k < n; k++) if (Math.abs(hist.t[k] - tt) < best) { best = Math.abs(hist.t[k] - tt); i = k; } } if (i < 0 || best > 10) { plot.classList.remove('zm-mon-hover'); return; } var fx = xOf(hist.t[i]) / W, left = (100 * fx).toFixed(2) + '%'; plot.classList.add('zm-mon-hover'); xLine.style.left = left; [ 'cpu', 'mem', 'temp' ].forEach(function(k) { var v = hist[k][i]; xDots[k].style.display = v == null ? 'none' : ''; if (v != null) { xDots[k].style.left = left; xDots[k].style.top = (100 * yOf(v) / H).toFixed(2) + '%'; } }); var t = hist.t[i], tl = hist.t[n - 1]; xTip.innerHTML = ''; xTip.appendChild(E('b', {}, agoText(tl - t))); xTip.appendChild(E('span', {}, [ E('i', { 'class': 'zm-mon-l-cpu' }), 'Процессор ', E('b', {}, pct(hist.cpu[i])) ])); xTip.appendChild(E('span', {}, [ E('i', { 'class': 'zm-mon-l-mem' }), 'Память ', E('b', {}, pct(hist.mem[i])) ])); if (hist.temp[i] != null) xTip.appendChild(E('span', {}, [ E('i', { 'class': 'zm-mon-l-temp' }), 'Температура ', E('b', {}, num(hist.temp[i], 1) + ' °C') ])); xTip.classList.toggle('zm-mon-xtip-l', fx > .55); xTip.style.left = left; } function hoverAt(ev) { var r = plot.getBoundingClientRect(); if (!r.width) return; hover = Math.max(0, Math.min(1, (ev.clientX - r.left) / r.width)); drawHover(); } plot.addEventListener('pointermove', hoverAt); plot.addEventListener('pointerdown', hoverAt); plot.addEventListener('pointerleave', function(ev) { if (ev.pointerType !== 'touch') { hover = null; drawHover(); } }); document.addEventListener('pointerdown', function onDoc(ev) { if (!document.body.contains(plot)) { document.removeEventListener('pointerdown', onDoc); return; } if (hover != null && !plot.contains(ev.target)) { hover = null; drawHover(); } }); function drawChart() { var d = path(hist.cpu), noTemp = !hist.temp.some(function(v) { return v != null; }); pCpu.setAttribute('d', d); pCpuA.setAttribute('d', area(hist.cpu)); pMem.setAttribute('d', path(hist.mem)); pTemp.setAttribute('d', path(hist.temp)); axR.style.visibility = noTemp && hist.cpu.length ? 'hidden' : ''; drawHover(); } var coresBox = E('div', { 'class': 'zm-mon-cores' }); var splitBox = E('div', { 'class': 'zm-mon-split' }); var freqEl = E('span', { 'class': 'zm-mon-sub' }, ''); var coresCard = E('div', { 'class': 'zm-card' }, [ E('div', { 'class': 'zm-mon-cardhead' }, [ E('h3', {}, 'Ядра процессора'), freqEl ]), coresBox, splitBox ]); function meter(label, v, lvl) { return E('div', { 'class': 'zm-mon-meter' }, [ E('div', { 'class': 'zm-mon-meter-h' }, [ E('span', {}, label), E('b', {}, pct(v)) ]), E('div', { 'class': 'zm-mon-bar' }, [ E('div', { 'class': 'zm-mon-fill zm-mon-' + (lvl || 'acc'), 'style': 'width:' + Math.max(0, Math.min(100, v)).toFixed(1) + '%' }) ]) ]); } function tile(label, val) { return E('div', { 'class': 'zm-mon-tile' }, [ E('span', {}, label), E('b', {}, val) ]); } var memBar = E('div', { 'class': 'zm-mon-stack', 'role': 'img', 'aria-label': 'Память по компонентам' }); var memLeg = E('div', { 'class': 'zm-mon-stackleg' }); var memNote = E('span', { 'class': 'zm-mon-sub' }, ''); var memCard = E('div', { 'class': 'zm-card' }, [ E('div', { 'class': 'zm-mon-cardhead' }, [ E('h3', {}, 'Кто сколько занимает'), memNote ]), memBar, memLeg ]); var topBody = E('tbody', {}); var sortSeg = E('div', { 'class': 'zm-seg zm-mon-seg' }); function renderSort() { sortSeg.innerHTML = ''; [ [ 'cpu', 'по процессору' ], [ 'mem', 'по памяти' ] ].forEach(function(o) { sortSeg.appendChild(E('div', { 'class': 'zm-seg-item' + (sortBy === o[0] ? ' zm-active' : ''), 'click': function() { sortBy = o[0]; renderSort(); renderTop(); } }, o[1])); }); } renderSort(); var topNote = E('span', { 'class': 'zm-mon-sub' }, ''); var topCard = E('div', { 'class': 'zm-card zm-mon-topcard zm-mon-flex' }, [ E('div', { 'class': 'zm-mon-cardhead' }, [ E('h3', {}, 'Что сейчас нагружает роутер'), sortSeg ]), E('div', { 'class': 'zm-mon-tablebox' }, [ E('table', { 'class': 'zm-mon-table' }, [ E('thead', {}, [ E('tr', {}, [ E('th', {}, 'Программа'), E('th', {}, 'Компонент'), E('th', { 'class': 'zm-r' }, 'ЦП'), E('th', { 'class': 'zm-r' }, 'Память') ]) ]), topBody ]) ]), E('div', { 'class': 'zm-mon-cardfoot' }, [ topNote, E('button', { 'class': 'cbi-button', 'click': function() { openProcs(); } }, 'Все процессы →') ]) ]); var netBox = E('div', {}), sensBox = E('div', {}), infoBox = E('div', {}); var NET_WIN = 120, NW = 300, NH = 84, NET_KEY = 'zm-mon-net'; var netHist = {}, netPick = '', netList = ''; try { netPick = localStorage.getItem(NET_KEY) || ''; } catch (e) { netPick = ''; } var netSel = E('select', { 'class': 'cbi-input-select zm-mon-netsel', 'aria-label': 'Какую сеть показывать', 'change': function() { netPick = netSel.value; try { localStorage.setItem(NET_KEY, netPick); } catch (e) {} renderSnap(); } }); var pRx = svg('path', { 'class': 'zm-mon-nrx' }), pTx = svg('path', { 'class': 'zm-mon-ntx' }); var nChart = svg('svg', { viewBox: '0 0 ' + NW + ' ' + NH, preserveAspectRatio: 'none', 'class': 'zm-mon-nchart', role: 'img', 'aria-label': 'Загрузка и отдача за 2 минуты' }, [ pRx, pTx ]); var nLeg = E('div', { 'class': 'zm-mon-nleg' }); var netCard = E('div', { 'class': 'zm-card' }, [ E('div', { 'class': 'zm-mon-cardhead' }, [ E('h3', { 'style': 'margin:0' }, 'Сеть сейчас'), netSel ]), nChart, nLeg, netBox ]); function netCur(s) { var l = (s && s.net && s.net.ifs) || []; return l.filter(function(x) { return x.n === netPick; })[0] || l.filter(function(x) { return x.i; })[0] || l.filter(function(x) { return x.n === 'wan'; })[0] || l[0] || null; } function netTrack(s, p) { var l = (s.net && s.net.ifs) || [], pl = (p && p.net && p.net.ifs) || [], dt = p ? s.t - p.t : 0; l.forEach(function(x) { var o = pl.filter(function(y) { return y.d === x.d; })[0], h = netHist[x.d] || (netHist[x.d] = []); if (!o || dt <= 0 || dt > 30) return; h.push({ t: s.t, rx: Math.max(0, (x.c[0] - o.c[0]) / dt), tx: Math.max(0, (x.c[1] - o.c[1]) / dt) }); while (h.length && s.t - h[0].t > NET_WIN + 4) h.shift(); }); } function netArea(h, k, mx, tl) { if (h.length < 2) return ''; var pts = h.map(function(q) { return [ NW * (1 - (tl - q.t) / NET_WIN), NH - 2 - (NH - 6) * q[k] / mx ]; }); return 'M' + pts.map(function(q) { return q[0].toFixed(1) + ' ' + q[1].toFixed(1); }).join(' L') + ' L' + pts[pts.length - 1][0].toFixed(1) + ' ' + NH + ' L' + pts[0][0].toFixed(1) + ' ' + NH + ' Z'; } function renderNet(s) { var l = (s.net && s.net.ifs) || [], cur = netCur(s); var key = l.map(function(x) { return x.n + (x.i ? '*' : ''); }).join(','); if (key !== netList) { netList = key; netSel.innerHTML = ''; l.forEach(function(x) { netSel.appendChild(E('option', { 'value': x.n }, x.n + (x.i ? ' · интернет' : ''))); }); } netSel.style.display = l.length > 1 ? '' : 'none'; if (cur) { netSel.value = cur.n; netSel.title = cur.n + ' (' + cur.d + ')'; } var h = cur ? (netHist[cur.d] || []) : [], last = h[h.length - 1], mx = 1; h.forEach(function(q) { mx = Math.max(mx, q.rx, q.tx); }); var tl = last ? last.t : 0; pRx.setAttribute('d', netArea(h, 'rx', mx * 1.1, tl)); pTx.setAttribute('d', netArea(h, 'tx', mx * 1.1, tl)); nChart.style.display = cur ? '' : 'none'; nLeg.innerHTML = ''; if (!cur) { nLeg.appendChild(E('span', { 'class': 'zm-mon-sub' }, 'Роутер не сообщил сетевые интерфейсы')); return; } function side(cls, arrow, k) { var v = last ? rate(last[k]) : [ '…', '' ], pk = 0; h.forEach(function(q) { pk = Math.max(pk, q[k]); }); return E('span', {}, [ E('i', { 'class': cls }), arrow + ' ', E('b', {}, v[0] + ' ' + v[1]), h.length > 2 ? E('small', {}, 'пик ' + rate(pk).join(' ')) : '' ]); } nLeg.appendChild(side('zm-mon-nrx', '↓', 'rx')); nLeg.appendChild(side('zm-mon-ntx', '↑', 'tx')); } function fanIcon() { return svg('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': 2, 'stroke-linecap': 'round', 'aria-hidden': 'true' }, [ svg('circle', { cx: 12, cy: 12, r: 2 }), svg('path', { d: 'M12 10c0-4 1-7 4-7s3 4-2 7M14 12c4 0 7 1 7 4s-4 3-7-2M12 14c0 4-1 7-4 7s-3-4 2-7M10 12c-4 0-7-1-7-4s4-3 7 2' }) ]); } function renderFan(f) { if (!f) { fanEl.style.display = 'none'; return; } var on, txt; if (f.rpm >= 0) { on = f.rpm > 0; txt = on ? num(f.rpm, 0) + ' об/мин' + (f.pwm >= 0 ? ' · ' + f.pwm + '%' : '') : 'выключен'; } else if (f.pwm >= 0) { on = f.pwm > 0; txt = on ? f.pwm + '%' : 'выключен'; } else { on = f.st > 0; txt = on ? 'ступень ' + f.st + ' из ' + f.mx : 'выключен'; } fanEl.className = 'zm-badge zm-mon-fan ' + (on ? 'zm-ok' : 'zm-off'); fanEl.innerHTML = ''; fanEl.appendChild(fanIcon()); fanEl.appendChild(document.createTextNode(txt)); fanEl.title = 'Вентилятор: ' + txt; fanEl.style.display = ''; } var sensCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Датчики'), sensBox ]); infoBox.className = 'zm-mon-info'; var infoCard = E('div', { 'class': 'zm-card' }, [ E('div', { 'class': 'zm-mon-cardhead' }, [ E('h3', {}, 'Роутер') ]), infoBox ]); var overBox = E('div', { 'class': 'zm-mon-over' }, [ gauges, E('div', { 'class': 'zm-mon-row zm-mon-row-21' }, [ chartCard, coresCard ]), memCard, E('div', { 'class': 'zm-mon-row zm-mon-row-21' }, [ topCard, E('div', { 'class': 'zm-mon-col' }, [ netCard, sensCard ]) ]), infoCard ]); var htopBox = E('div', { 'class': 'zm-mon-htoppane', 'hidden': 'hidden' }); root.appendChild(head); root.appendChild(overBox); root.appendChild(htopBox); function cpuCalc(a, b) { var dt = 0, di, i; for (i = 0; i < 8; i++) dt += (b[i] || 0) - (a[i] || 0); if (dt <= 0) return null; di = (b[3] + b[4]) - (a[3] + a[4]); return { all: 100 * (dt - di) / dt, user: 100 * ((b[0] + b[1]) - (a[0] + a[1])) / dt, sys: 100 * (b[2] - a[2]) / dt, irq: 100 * ((b[5] + b[6]) - (a[5] + a[6])) / dt, io: 100 * (b[4] - a[4]) / dt }; } function cpuTemp(s) { var t = (s.temps || []).filter(function(x) { return x.label === 'Процессор'; })[0] || (s.temps || [])[0]; return t ? +t.c : null; } function memUsed(m) { return Math.max(0, m.total - m.avail); } function onSnap(s) { if (!s || s.error || !s.mem) return; prev = snap; snap = s; netTrack(s, prev); if (prev && prev.cpu && s.cpu) { var c = cpuCalc(prev.cpu, s.cpu); if (c) { cpuNow = c; cpuNow.cores = (s.cores || []).map(function(x, i) { var r = prev.cores && prev.cores[i] ? cpuCalc(prev.cores[i], x) : null; return r ? r.all : 0; }); } hist.cpu.push(cpuNow.all); hist.mem.push(100 * memUsed(s.mem) / s.mem.total); var tc = cpuTemp(s); hist.temp.push(tc == null ? null : tc); hist.t.push(s.t); while (hist.t.length && (hist.t[hist.t.length - 1] - hist.t[0] > WIN + 4 || hist.t.length > HIST_MAX)) [ 'cpu', 'mem', 'temp', 't' ].forEach(function(k) { hist[k].shift(); }); } renderSnap(); } function renderSnap() { var s = snap; if (!s) return; var ncpu = s.ncpu || (s.cores || []).length || 1; devEl.textContent = s.model || s.soc || 'Роутер'; var cl = prev ? cpuNow.all : 0; gCpu.set(cl, prev ? pct(cl) : '…', ncpu + ' ' + plural(ncpu, 'ядро', 'ядра', 'ядер') + (s.freq ? ' · ' + num(s.freq / 1000, 1) + ' ГГц' : s.fmax ? ' · до ' + num(s.fmax / 1000, 1) + ' ГГц' : ''), 'нагрузка ' + (s.load || []).map(function(x) { return num(x, 2); }).join(' · '), level(cl, 70, 90)); var tc = cpuTemp(s); if (tc == null) gTemp.set(0, '—', 'нет датчика', 'роутер не сообщает температуру', 'off'); else { var wifi = (s.temps || []).filter(function(x) { return /^Wi-Fi/.test(x.label); }).map(function(x) { return num(+x.c, 0) + '°'; }); gTemp.set(Math.min(100, tc), num(tc, 0) + '°', 'процессор ' + num(tc, 1) + ' °C', wifi.length ? 'Wi-Fi ' + wifi.join(' / ') : 'в норме до 85 °C', level(tc, 70, 85)); } var used = memUsed(s.mem), mp = 100 * used / s.mem.total; gMem.set(mp, num(mp, 0) + '%', mb(used) + ' из ' + mb(s.mem.total), 'кеш ' + mb(s.mem.cached + s.mem.buffers) + (s.tmp && s.tmp[1] ? ' · /tmp ' + mb(s.tmp[1]) : ''), level(mp, 80, 92)); if (s.flash && s.flash[0]) { var fp = 100 * s.flash[1] / s.flash[0]; gFlash.set(fp, num(fp, 0) + '%', mb(s.flash[1]) + ' из ' + mb(s.flash[0]), 'свободно ' + mb(s.flash[0] - s.flash[1]), level(fp, 85, 95)); } else gFlash.set(0, '—', 'нет данных', 'роутер не сообщил размер', 'off'); drawChart(); coresBox.innerHTML = ''; (prev ? cpuNow.cores : (s.cores || []).map(function() { return 0; })).forEach(function(v, i) { coresBox.appendChild(meter('Ядро ' + (i + 1), v, level(v, 80, 95) === 'ok' ? 'acc' : level(v, 80, 95))); }); splitBox.innerHTML = ''; [ [ 'программы', cpuNow.user ], [ 'система', cpuNow.sys ], [ 'прерывания', cpuNow.irq ], [ 'ожидание диска', cpuNow.io ] ].forEach(function(x) { splitBox.appendChild(tile(x[0], prev ? pct(x[1]) : '…')); }); freqEl.textContent = s.freq ? num(s.freq, 0) + ' МГц' : ''; netBox.innerHTML = ''; renderNet(s); renderFan(s.fan); if (s.conn && s.conn[1]) { var cp = 100 * s.conn[0] / s.conn[1]; netBox.appendChild(E('div', { 'class': 'zm-mon-meter' }, [ E('div', { 'class': 'zm-mon-meter-h' }, [ E('span', {}, 'Соединений'), E('b', {}, num(s.conn[0], 0) + ' из ' + num(s.conn[1], 0)) ]), E('div', { 'class': 'zm-mon-bar' }, [ E('div', { 'class': 'zm-mon-fill zm-mon-' + (level(cp, 70, 90) === 'ok' ? 'acc2' : level(cp, 70, 90)), 'style': 'width:' + Math.max(1, cp).toFixed(1) + '%' }) ]) ])); } netBox.appendChild(E('div', { 'class': 'zm-mon-kv' }, [ E('span', {}, 'Устройств в сети (DHCP)'), E('b', {}, String(s.leases || 0)) ])); sensBox.innerHTML = ''; if (!(s.temps || []).length) sensBox.appendChild(E('p', { 'class': 'zm-hint' }, 'Роутер не сообщает температуру: в ядре нет датчиков thermal и hwmon.')); (s.temps || []).forEach(function(t) { var c = +t.c, l = level(c, 70, 85); sensBox.appendChild(E('div', { 'class': 'zm-mon-meter' }, [ E('div', { 'class': 'zm-mon-meter-h' }, [ E('span', {}, t.label), E('b', { 'class': 'zm-mon-t-' + l }, num(c, 1) + ' °C') ]), E('div', { 'class': 'zm-mon-bar' }, [ E('div', { 'class': 'zm-mon-fill zm-mon-heat', 'style': 'width:' + Math.max(2, Math.min(100, c)).toFixed(0) + '%' }) ]) ])); }); infoBox.innerHTML = ''; [ [ 'Модель', s.model ], [ 'Чип', s.soc ], [ 'Процессор', (s.cpu_model ? s.cpu_model + ' · ' : '') + ncpu + ' ' + plural(ncpu, 'ядро', 'ядра', 'ядер') ], [ 'Частота', s.fmax ? 'до ' + num(s.fmax, 0) + ' МГц' : '' ], [ 'Оперативная память', s.mem && s.mem.total ? mb(s.mem.total) : '' ], [ 'Флеш', s.flash && s.flash[0] ? mb(s.flash[0]) : '' ], [ 'OpenWrt', s.openwrt ], [ 'Ядро Linux', s.kernel ], [ 'Работает', upText(s.t) ], [ 'Процессов', s.tasks ? String(s.tasks) : '' ] ].forEach(function(r) { if (r[1]) infoBox.appendChild(E('div', { 'class': 'zm-mon-tile', 'title': String(r[1]) }, [ E('span', {}, r[0]), E('b', {}, String(r[1])) ])); }); } function onProcs(r) { if (!r || !r.p) { procErr = r && r.error ? String(r.error) : 'роутер не ответил'; renderTop(); if (procsView) procsView.render(); return; } procErr = ''; var list = r.p.map(function(a) { return { pid: a[0], ppid: a[1], name: a[2], state: a[3], ticks: a[4], rss: a[5], thr: a[6], user: a[7], cmd: a[8] }; }); var dTot = prevProc && r.total > prevProcTotal ? r.total - prevProcTotal : 0; procPct = {}; list.forEach(function(p) { p.comp = compOf(p); var was = prevProc && prevProc[p.pid]; p.cpu = dTot && was != null && p.ticks >= was ? 100 * (p.ticks - was) / dTot : 0; procPct[p.pid] = p.cpu; }); prevProc = {}; list.forEach(function(p) { prevProc[p.pid] = p.ticks; }); prevProcTotal = r.total; procs = list; renderTop(); renderMem(); if (procsView) procsView.render(); } function procLabel(p) { if (!p.cmd || p.cmd.charAt(0) === '[') return p.name; var a = p.cmd.split(' '), b = a[0].split('/').pop(); if (/^(sh|ash|bash|busybox|ucode|lua|node|python[0-9.]*)$/.test(b) && a[1] && a[1].charAt(0) !== '-') b = a[1].split('/').pop(); return b || p.name; } function compTag(c) { return E('span', { 'class': 'zm-mon-tag', 'style': '--zm-tag:' + c.color }, c.name); } function renderTop() { var list = procs.slice().sort(function(a, b) { return sortBy === 'mem' ? b.rss - a.rss : b.cpu - a.cpu || b.rss - a.rss; }).slice(0, 10); topBody.innerHTML = ''; if (!procs.length) topBody.appendChild(E('tr', {}, [ E('td', { 'colspan': '4', 'class': 'zm-mon-empty' }, procErr ? 'Список процессов не получен: ' + procErr : 'Загружаем список процессов…') ])); list.forEach(function(p) { topBody.appendChild(E('tr', { 'title': p.cmd || p.name }, [ E('td', { 'class': 'zm-mon-mono' }, procLabel(p)), E('td', {}, [ compTag(p.comp) ]), E('td', { 'class': 'zm-r' }, [ E('b', {}, prevProcTotal && procs.length ? pct(p.cpu) : '…') ]), E('td', { 'class': 'zm-r' }, p.rss ? mb(p.rss) : '—') ])); }); topNote.textContent = procErr && procs.length ? 'Список не обновился: ' + procErr : procs.length ? 'Всего ' + procs.length + ' ' + plural(procs.length, 'процесс', 'процесса', 'процессов') : ''; } function renderMem() { if (!snap) return; var m = snap.mem, by = {}, sum = 0; procs.forEach(function(p) { if (!p.rss) return; by[p.comp.id] = (by[p.comp.id] || 0) + p.rss; sum += p.rss; }); var used = memUsed(m), rest = Math.max(0, used - sum); var parts = Object.keys(by).map(function(id) { return { c: COMP_BY_ID[id], kb: by[id] }; }).sort(function(a, b) { return b.kb - a.kb; }); var top = parts.slice(0, 6), other = parts.slice(6).reduce(function(s, x) { return s + x.kb; }, 0); memBar.innerHTML = ''; memLeg.innerHTML = ''; function seg(kb, color, text, cls) { var w = 100 * kb / m.total; if (w < .3) return; memBar.appendChild(E('div', { 'class': 'zm-mon-stackseg' + (cls ? ' ' + cls : ''), 'style': 'width:' + w.toFixed(2) + '%;' + (color ? 'background:' + color : ''), 'title': text + ' · ' + mb(kb) })); memLeg.appendChild(E('span', { 'class': 'zm-mon-leg' }, [ E('i', { 'class': cls || '', 'style': color ? 'background:' + color : '' }), text + ' · ' + mb(kb) ])); } top.forEach(function(x) { seg(x.kb, x.c.color, x.c.name); }); if (other) seg(other, '#cbd5e1', 'Остальные программы'); if (rest) seg(rest, '', 'Ядро, буферы и /tmp', 'zm-mon-seg-sys'); seg(m.avail, '', 'Свободно', 'zm-mon-seg-free'); memNote.textContent = 'память программ — примерно: общие библиотеки входят в каждую'; } var procsView = null; function openProcs() { if (procsView) return; var q = '', filt = 'all', col = 'cpu', desc = true; var search = E('input', { 'type': 'search', 'class': 'cbi-input-text zm-mon-search', 'placeholder': 'Поиск: имя, команда или номер', 'aria-label': 'Поиск процессов' }); search.addEventListener('input', function() { q = search.value.trim().toLowerCase(); view.render(); }); var filtSeg = E('div', { 'class': 'zm-seg zm-mon-seg' }); var tbody = E('tbody', {}), thead = E('tr', {}), countEl = E('span', { 'class': 'zm-mon-sub' }, ''); var COLS = [ [ 'pid', 'PID', 'zm-r' ], [ 'name', 'Программа', '' ], [ 'comp', 'Компонент', 'zm-mon-hide-xs' ], [ 'user', 'Пользователь', 'zm-mon-hide-s' ], [ 'cpu', 'ЦП', 'zm-r' ], [ 'rss', 'Память', 'zm-r' ], [ 'thr', 'Потоки', 'zm-r zm-mon-hide-s' ], [ 'cmd', 'Команда', 'zm-mon-hide-xs' ], [ '', '', '' ] ]; function renderHead() { thead.innerHTML = ''; COLS.forEach(function(c) { if (!c[0]) { thead.appendChild(E('th', {}, '')); return; } thead.appendChild(E('th', { 'class': (c[2] || '') + ' zm-mon-sortable' + (col === c[0] ? ' zm-active' : ''), 'aria-sort': col === c[0] ? (desc ? 'descending' : 'ascending') : null }, [ E('button', { 'type': 'button', 'click': function() { if (col === c[0]) desc = !desc; else { col = c[0]; desc = [ 'cpu', 'rss', 'thr' ].indexOf(c[0]) >= 0; } renderHead(); view.render(); } }, c[1] + (col === c[0] ? (desc ? ' ▼' : ' ▲') : '')) ])); }); } function renderFilt() { filtSeg.innerHTML = ''; [ [ 'all', 'Все' ], [ 'panel', 'Компоненты панели' ], [ 'system', 'OpenWrt' ], [ 'kernel', 'Ядро' ] ].forEach(function(o) { filtSeg.appendChild(E('div', { 'class': 'zm-seg-item' + (filt === o[0] ? ' zm-active' : ''), 'click': function() { filt = o[0]; renderFilt(); view.render(); } }, o[1])); }); } function keep(p) { if (filt === 'kernel' && p.comp.id !== 'kernel') return false; if (filt === 'system' && [ 'system', 'wifi', 'dns' ].indexOf(p.comp.id) < 0) return false; if (filt === 'panel' && [ 'system', 'wifi', 'kernel' ].indexOf(p.comp.id) >= 0) return false; if (!q) return true; return (p.name + ' ' + (p.cmd || '') + ' ' + p.pid + ' ' + p.comp.name).toLowerCase().indexOf(q) >= 0; } var view = { render: function() { var list = procs.filter(keep).sort(function(a, b) { var x = col === 'comp' ? a.comp.name : a[col], y = col === 'comp' ? b.comp.name : b[col], r; r = typeof x === 'number' ? x - y : String(x || '').localeCompare(String(y || ''), 'ru'); return desc ? -r : r; }); tbody.innerHTML = ''; if (!list.length) tbody.appendChild(E('tr', {}, [ E('td', { 'colspan': String(COLS.length), 'class': 'zm-mon-empty' }, !procs.length ? (procErr ? 'Список процессов не получен: ' + procErr : 'Загружаем список процессов…') : 'Ничего не найдено') ])); list.forEach(function(p) { var kernel = p.comp.id === 'kernel', prot = PROTECTED[p.name] || p.pid <= 2; tbody.appendChild(E('tr', {}, [ E('td', { 'class': 'zm-r zm-mon-mono' }, String(p.pid)), E('td', { 'class': 'zm-mon-mono zm-mon-pname', 'title': p.cmd || p.name }, procLabel(p)), E('td', { 'class': 'zm-mon-hide-xs' }, [ compTag(p.comp) ]), E('td', { 'class': 'zm-mon-hide-s' }, p.user || ''), E('td', { 'class': 'zm-r' }, [ E('b', {}, pct(p.cpu)) ]), E('td', { 'class': 'zm-r' }, p.rss ? mb(p.rss) : '—'), E('td', { 'class': 'zm-r zm-mon-hide-s' }, String(p.thr || 1)), E('td', { 'class': 'zm-mon-mono zm-mon-cmd zm-mon-hide-xs', 'title': p.cmd || '' }, p.cmd || '[' + p.name + ']'), E('td', { 'class': 'zm-r' }, [ kernel || prot ? E('span', { 'class': 'zm-mon-sub', 'title': kernel ? 'поток ядра' : 'служба, без которой роутер или панель перестанут отвечать' }, '—') : E('button', { 'class': 'cbi-button cbi-button-remove zm-mon-kill', 'aria-label': 'Завершить ' + p.name, 'click': function() { killProc(p); } }, 'Завершить') ]) ])); }); countEl.textContent = list.length === procs.length ? procs.length + ' ' + plural(procs.length, 'процесс', 'процесса', 'процессов') : 'показано ' + list.length + ' из ' + procs.length; } }; var wrapEl = E('div', { 'class': 'zm-dlg-wrap zm-mon-pwrap', 'role': 'dialog', 'aria-modal': 'true', 'aria-label': 'Все процессы', 'click': function(ev) { if (ev.target === wrapEl) close(); } }, [ E('div', { 'class': 'zm-card zm-dlg zmw-modal zm-mon-procs' }, [ E('div', { 'class': 'zm-mon-cardhead' }, [ E('h3', {}, 'Все процессы'), E('div', { 'class': 'zm-mon-head-r' }, [ countEl, E('button', { 'class': 'cbi-button', 'click': function() { close(); } }, 'Закрыть') ]) ]), E('div', { 'class': 'zm-mon-ptools' }, [ search, filtSeg ]), E('div', { 'class': 'zm-mon-tablebox zm-mon-ptable' }, [ E('table', { 'class': 'zm-mon-table' }, [ E('thead', {}, [ thead ]), tbody ]) ]), E('p', { 'class': 'zm-hint' }, 'ЦП — доля всего процессора за последние секунды. Память — занятая программой (RSS). «Завершить» сначала просит программу закрыться сама; если не закрылась — можно принудительно.') ]) ]); function onKey(ev) { if (ev.key === 'Escape') close(); } function close() { if (!procsView) return; procsView = null; document.removeEventListener('keydown', onKey); window.removeEventListener('hashchange', close); if (wrapEl.parentNode) wrapEl.parentNode.removeChild(wrapEl); } view.close = close; procsView = view; renderHead(); renderFilt(); view.render(); document.body.appendChild(wrapEl); document.addEventListener('keydown', onKey); window.addEventListener('hashchange', close); setTimeout(function() { try { search.focus(); } catch (e) {} }, 0); ptick(); } function killProc(p) { zm.dialog({ title: 'Завершить «' + procLabel(p) + '»?', intro: 'PID ' + p.pid + ' · ' + p.comp.name + (p.cmd ? ' · ' + p.cmd.slice(0, 120) : '') + '. Если это служба, она может перезапуститься сама. Программы панели лучше останавливать на их вкладках.', okText: 'Завершить', altText: 'Принудительно (KILL)', danger: true }).then(function(r) { if (!r) return; zm.monitorAction('kill', p.pid + (r === 'alt' ? ':KILL' : '')).then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast(res.alive ? '«' + res.name + '» ещё работает — можно завершить принудительно' : '«' + res.name + '» завершён', res.alive ? 'warning' : 'info'); ptick(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); }); } var htopBusy = false, htopOn = false, htopSeq = 0; var htopLog = E('pre', { 'class': 'zm-log' }); function hInfo() { return (snap && snap.htop) || {}; } function hPane(title, text, btns, extra) { htopBox.innerHTML = ''; htopBox.appendChild(E('div', { 'class': 'zm-card zm-mon-hcard' }, [ E('div', { 'class': 'zm-mon-hicon', 'aria-hidden': 'true' }, [ E('i', {}), E('i', {}), E('i', {}) ]), E('h3', {}, title), E('p', { 'class': 'zm-hint' }, text), E('div', { 'class': 'zm-mon-hbtns' }, btns || []) ].concat(extra || []))); } function hBtn(text, cls, fn) { return E('button', { 'class': 'cbi-button' + (cls ? ' ' + cls : ''), 'click': fn }, text); } function htopUrl(res) { var url = 'http://' + window.location.hostname + ':' + res.port + res.path, bg = '#0d1117'; try { var v = getComputedStyle(document.documentElement).getPropertyValue('--console').trim(); if (/^#[0-9a-f]{6}$/i.test(v)) bg = v; } catch (e) {} return url + '?disableLeaveAlert=true&fontSize=13&theme=' + encodeURIComponent(JSON.stringify({ background: bg, foreground: '#e6edf3', cursor: '#e6edf3' })); } function htopStop() { htopSeq++; if (!htopOn) return; htopOn = false; zm.monitorAction('htop_close', '').catch(function() {}); } function htopShow() { if (mode !== 'htop') return; var h = hInfo(); if (htopBusy) { hPane('Устанавливаем htop', 'Это займёт до минуты. Можно перейти на «Обзор» — установка продолжится.', [], [ htopLog ]); return; } if (!h.inst || !h.ttyd) { hPane('htop не установлен', 'htop — наглядный диспетчер процессов.\nОн откроется прямо на этой вкладке.' + (!h.ttyd ? '\nЧтобы показать его в браузере, будет установлен «Терминал».' : ''), [ hBtn('Установить htop', 'cbi-button-action', htopInstall) ], htopLog.textContent ? [ htopLog ] : []); return; } if (window.location.protocol !== 'http:') { hPane('htop откроется в новой вкладке', 'Панель открыта по https, а htop на роутере работает по http — браузер не покажет его внутри страницы.', [ hBtn('Открыть htop в новой вкладке', 'cbi-button-action', function() { var w = window.open('about:blank', '_blank'); try { if (w) w.opener = null; } catch (e) {} htopStart(function(url) { if (w) w.location.href = url; else window.open(url, '_blank', 'noopener'); }, function() { if (w) w.close(); }); }) ]); return; } hPane('Запускаем htop…', 'Несколько секунд.', []); htopStart(function(url) { var frame = E('iframe', { 'class': 'zm-term-frame zm-mon-htopframe', 'src': url, 'title': 'htop' }); htopBox.innerHTML = ''; htopBox.appendChild(E('div', { 'class': 'zm-card zm-mon-hterm' }, [ E('div', { 'class': 'zm-term' }, [ E('div', { 'class': 'zm-term-bar' }, [ E('span', { 'class': 'zm-term-dots' }, [ E('i', {}), E('i', {}), E('i', {}) ]), E('span', { 'class': 'zm-term-title' }, 'htop · ' + window.location.hostname) ]), E('div', { 'class': 'zm-term-body' }, [ frame ]) ]), E('div', { 'class': 'zm-mon-cardfoot' }, [ E('span', { 'class': 'zm-hint zm-mon-hfoot' }, 'F6 — сортировка, F5 — дерево, F9 — завершить процесс, / — поиск. Адрес одноразовый и работает только из домашней сети; ушли с вкладки — htop на роутере останавливается.'), E('div', { 'class': 'zm-mon-hbtns zm-mon-hacts' }, [ E('button', { 'class': 'cbi-button cbi-button-action', 'title': 'Если htop закрыли (F10) или связь оборвалась', 'click': function() { htopSeq++; htopOn = false; htopShow(); } }, 'Перезапустить'), hBtn('Удалить htop', 'cbi-button-remove', htopRemove) ]) ]) ])); }, null); } function htopStart(ok, fail) { var seq = ++htopSeq; zm.monitorAction('htop_open', '').then(function(res) { if (seq !== htopSeq || mode !== 'htop' || !alive()) { if (res && res.ok) zm.monitorAction('htop_close', '').catch(function() {}); if (fail) fail(); return; } if (!res || res.error) { if (fail) fail(); hPane('htop не запустился', (res && res.error) || 'Роутер не ответил.', [ hBtn('Попробовать снова', 'cbi-button-action', htopShow) ]); return; } htopOn = true; ok(htopUrl(res)); }).catch(function() { if (fail) fail(); if (seq === htopSeq && mode === 'htop') hPane('Роутер не ответил', 'htop не запустился.', [ hBtn('Попробовать снова', 'cbi-button-action', htopShow) ]); }); } function htopRefresh(then) { zm.monitor().then(function(s) { if (s && s.mem) { if (snap) snap.htop = s.htop; else onSnap(s); } }, function() {}).then(then); } function htopInstall() { if (htopBusy) return; zm.monitorAction('htop_install', '').then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return; } htopBusy = true; htopLog.textContent = ''; htopShow(); zm.pollJob('htop', htopLog, function(ok) { htopBusy = false; if (!ok) zm.toast('htop не установился — подробности в журнале', 'error'); htopRefresh(function() { if (alive()) htopShow(); }); }); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } function htopRemove() { if (htopBusy) return; zm.dialog({ title: 'Удалить htop?', intro: 'Удалится пакет htop' + (hInfo().ttyd ? ' и ttyd, если его ставили только для htop (терминал на вкладке «Терминал» не включали)' : '') + '. Монитор ресурсов продолжит работать, а при обновлении панели htop сам не вернётся — поставить его снова можно на этой вкладке.', okText: 'Удалить', danger: true }).then(function(r) { if (!r) return; htopStop(); zm.monitorAction('htop_remove', '').then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return; } htopBusy = true; htopLog.textContent = ''; hPane('Удаляем htop', 'Несколько секунд.', [], [ htopLog ]); zm.pollJob('htop', htopLog, function(ok) { htopBusy = false; zm.toast(ok ? 'htop удалён' : 'Не всё удалилось — подробности в журнале', ok ? 'info' : 'error'); htopRefresh(function() { if (alive()) htopShow(); }); }); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); }); } function setMode(m) { if (m === mode) return; mode = m; renderTabs(); overBox.hidden = m !== 'over'; overTools.hidden = m !== 'over'; htopBox.hidden = m !== 'htop'; if (m === 'htop') { if (procsView) procsView.close(); htopShow(); } else { htopStop(); htopBox.innerHTML = ''; tick(); ptick(); } } renderTabs(); window.addEventListener('hashchange', function onHash() { setTimeout(function() { if (!alive()) { window.removeEventListener('hashchange', onHash); stop(); } }, 300); }); window.addEventListener('pagehide', function() { if (htopOn) htopStop(); }); function monInfo() { return zm.warnDialog({ kind: 'info', noCheck: true, okText: 'Понятно', title: 'Как работает монитор', lead: 'Монитор работает, только пока эта страница открыта. Закрыли её — роутер ничего не собирает и не тратит на это ресурсы.', items: [ [ 'Раз в 2 секунды', 'роутер отдаёт счётчики, а проценты и графики считает браузер', '↻' ], [ 'График за 5 минут', 'хранится в браузере: после перезагрузки страницы он начнётся заново', '∿' ], [ 'Вкладку можно не держать на экране', 'график продолжит писаться, даже если переключиться на другую', '⇄' ], [ 'Пауза', 'кнопка «Пауза» или вкладка «htop» останавливают опрос', '‖' ], [ 'Монитор тоже нагружает роутер', 'немного: для каждого обновления роутер собирает данные', '≈' ] ], tip: { title: 'Совет:', text: 'чтобы следить за нагрузкой роутера, откройте монитор в отдельной вкладке браузера и не закрывайте её. Работайте в других вкладках, а сюда заглядывайте — график покажет, что происходило за последние 5 минут.' } }); } function alive() { return document.body.contains(root); } var clock = null, inflight = false; try { clock = new Worker(URL.createObjectURL(new Blob([ 'setInterval(function() { postMessage(1); }, 2000);' ], { type: 'text/javascript' }))); clock.onmessage = function() { tick(); }; } catch (e) { clock = null; } function stop() { clearTimeout(timer); clearTimeout(ptimer); if (clock) { try { clock.terminate(); } catch (e) {} clock = null; } if (procsView) procsView.close(); htopStop(); } function tick() { clearTimeout(timer); if (!alive()) { stop(); return; } if (paused || mode !== 'over') { if (!clock) timer = setTimeout(tick, 2000); return; } if (inflight) return; inflight = true; zm.monitor().then(function(s) { failed = s && s.mem ? 0 : failed + 1; onSnap(s); }, function() { failed++; }) .then(function() { inflight = false; renderLive(); if (!alive()) stop(); else if (!clock) timer = setTimeout(tick, 2000); }); } function ptick() { clearTimeout(ptimer); if (!alive()) { stop(); return; } if (paused || document.hidden || mode !== 'over') { ptimer = setTimeout(ptick, 4000); return; } zm.monitorProcs().then(onProcs, function() { onProcs(null); }) .then(function() { if (alive()) ptimer = setTimeout(ptick, procsView ? 3000 : 4000); }); } onSnap(init && init[0]); onProcs(init && init[1]); renderSnap(); renderTop(); renderMem(); renderLive(); setTimeout(tick, 1000); setTimeout(ptick, 1500); setTimeout(function() { if (alive()) zm.warnOnce('info_monitor', monInfo); }, 600); return root; } }); ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/monitor.js' cat > '/www/luci-static/resources/view/zapret-manager/tgproxy.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require view'; 'require zapret-manager.common as zm'; var E = (function(raw) { return function() { var a = Array.prototype.slice.call(arguments), i = a.length - 1; if (i >= 1 && (typeof a[i] === 'string' || typeof a[i] === 'number')) a[i] = [ String(a[i]) ]; return raw.apply(null, a); }; })(window.E); 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 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.link_mtproto || (d.secret_mtproto ? 'dd' + d.secret_mtproto : ''), version: d.mtproto_version, latest: d.mtproto_latest, newer: d.mtproto_newer === true, port: d.mtproto_port, host: d.mtproto_host, key: d.secret_mtproto || '', edit: 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, port: d.socks5_port, edit: 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, uci: d.rust_uci === true, enabled: d.rust_enabled !== false, port: d.rust_port, host: d.rust_host, key: d.rust_key || '', edit: d.rust_uci !== true }; } // Редактор порта и ключа прямо в карточке прокси var editOpen = {}, editDraft = {}, portSeq = 0; function genKey() { var b = new Uint8Array(16), i, out = ''; try { (window.crypto || window.msCrypto).getRandomValues(b); } catch (e) { for (i = 0; i < 16; i++) b[i] = Math.floor(Math.random() * 256); } for (i = 0; i < 16; i++) out += ('0' + b[i].toString(16)).slice(-2); return out; } function hexHost(h) { var out = '', i; for (i = 0; i + 1 < h.length; i += 2) out += String.fromCharCode(parseInt(h.substr(i, 2), 16)); return out; } function keyCheck(v, s) { s = String(s || '').replace(/\s+/g, '').toLowerCase(); if (!s) return { ok: false, text: 'Введите ключ или нажмите «Новый ключ»' }; if (/[^0-9a-f]/.test(s)) return { ok: false, text: 'Только цифры 0–9 и буквы a–f' }; if (v.id === 'mtproto') { if (s.length === 34 && s.slice(0, 2) === 'dd') s = s.slice(2); if (s.length > 34 && s.slice(0, 2) === 'ee') return { ok: false, text: 'Go (MTProto) не принимает ключи ee… — нужен ключ из 32 знаков' }; return s.length === 32 ? { ok: true, key: s, text: '32 знака — подходит' } : { ok: false, text: 'Нужно ровно 32 знака, сейчас ' + s.length }; } if (s.length === 32) return { ok: true, key: s, text: '32 знака — подходит, в ссылке будет dd…' }; if (s.length === 34 && s.slice(0, 2) === 'dd') return { ok: true, key: s, text: 'Ключ dd… — подходит' }; if (s.slice(0, 2) === 'ee' && s.length >= 38 && s.length % 2 === 0) { var h = hexHost(s.slice(34)); if (/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(h)) return { ok: true, key: s, text: 'FakeTLS-ключ ee…, сайт для маскировки: ' + h }; return { ok: false, text: 'После ee и 32 знаков должно идти имя сайта в hex' }; } return { ok: false, text: 'Нужно 32 знака (или dd + 32, или ee + 32 + сайт в hex), сейчас ' + s.length }; } function portCheck(v, st, p) { p = String(p || '').trim(); if (!/^\d{1,5}$/.test(p) || +p < 1 || +p > 65535) return { ok: false, text: 'Число от 1 до 65535' }; if (+p < 1024) return { ok: true, warn: true, text: 'Порт ниже 1024 часто занят службами роутера — проверим' }; return { ok: true, text: String(+p) === String(st.port) ? 'Текущий порт' : '' }; } function chip(cls, text) { return E('span', { 'class': 'zm-tg-chip ' + cls }, text); } function editPanel(v, st) { var dr = editDraft[v.id] || (editDraft[v.id] = { port: String(st.port || v.port), key: st.key || '', busy: false, net: null }); var portIn = E('input', { 'type': 'text', 'inputmode': 'numeric', 'class': 'cbi-input-text zm-tg-edit-in zm-tg-edit-port', 'value': dr.port, 'maxlength': '5', 'aria-label': 'Порт' }); var keyIn = v.kind === 'proto' ? E('input', { 'type': 'text', 'class': 'cbi-input-text zm-tg-edit-in zm-tg-edit-key', 'value': dr.key, 'spellcheck': 'false', 'autocapitalize': 'off', 'autocomplete': 'off', 'aria-label': 'Ключ' }) : null; var portMsg = E('div', { 'class': 'zm-tg-edit-msg' }), keyMsg = E('div', { 'class': 'zm-tg-edit-msg' }); var preview = E('div', { 'class': 'zm-tg-link-box zm-tg-edit-preview' }); var saveBtn = E('button', { 'class': 'cbi-button cbi-button-positive' }, 'Сохранить и перезапустить'); var host = st.host || data.lan_ip || '192.168.1.1'; var netT = null; function linkFor(k) { var p = String(+dr.port || dr.port); if (v.kind === 'socks') return 'tg://socks?server=' + host + '&port=' + p; if (!k) return ''; var sec = v.id === 'rust' && (k.slice(0, 2) === 'dd' || k.slice(0, 2) === 'ee') ? k : 'dd' + k; if (v.id === 'mtproto' && st.secret && st.secret.slice(0, 2) === 'ee' && st.key) sec = 'ee' + k + st.secret.slice(34); return 'tg://proxy?server=' + host + '&port=' + p + '&secret=' + sec; } function paint() { var pc = portCheck(v, st, dr.port), kc = keyIn ? keyCheck(v, dr.key) : { ok: true }; portMsg.innerHTML = ''; if (!pc.ok) portMsg.appendChild(chip('zm-tg-chip-bad', '✕ ' + pc.text)); else if (dr.net === 'wait') portMsg.appendChild(chip('zm-tg-chip-wait', 'проверяем, свободен ли порт…')); else if (dr.net && dr.net.free === false) portMsg.appendChild(chip('zm-tg-chip-bad', '✕ ' + (dr.net.why || 'порт занят'))); else if (dr.net && dr.net.free) portMsg.appendChild(chip('zm-tg-chip-ok', '✓ ' + (dr.net.self ? 'сейчас этот порт у самого прокси' : 'порт свободен'))); else if (pc.text) portMsg.appendChild(chip(pc.warn ? 'zm-tg-chip-wait' : 'zm-tg-chip-ok', pc.text)); if (keyIn) { keyMsg.innerHTML = ''; keyMsg.appendChild(chip(kc.ok ? 'zm-tg-chip-ok' : 'zm-tg-chip-bad', (kc.ok ? '✓ ' : '✕ ') + kc.text)); } var same = String(+dr.port) === String(st.port) && (!keyIn || (kc.key || '') === (st.key || '')); var ok = pc.ok && kc.ok && !(dr.net && dr.net.free === false) && dr.net !== 'wait'; preview.textContent = ok ? linkFor(kc.key) : 'Ссылка появится, когда порт' + (keyIn ? ' и ключ' : '') + ' будут в порядке'; preview.classList.toggle('zm-tg-edit-preview-off', !ok); saveBtn.disabled = !ok || same || dr.busy; saveBtn.textContent = dr.busy ? 'Применяем…' : same ? 'Без изменений' : 'Сохранить и перезапустить'; } function netCheck() { clearTimeout(netT); var pc = portCheck(v, st, dr.port); if (!pc.ok) { dr.net = null; paint(); return; } if (String(+dr.port) === String(st.port)) { dr.net = { free: true, self: true }; paint(); return; } dr.net = 'wait'; paint(); var mine = ++portSeq, p = String(+dr.port); netT = setTimeout(function() { zm.tgAction(v.id, 'port=' + p).then(function(r) { if (mine !== portSeq) return; dr.net = r && !r.error ? r : { free: false, why: (r && r.error) || 'роутер не ответил' }; paint(); }, function() { if (mine === portSeq) { dr.net = { free: false, why: 'роутер не ответил' }; paint(); } }); }, 450); } portIn.addEventListener('input', function() { var c = portIn.value.replace(/\D+/g, ''); if (c !== portIn.value) portIn.value = c; dr.port = c; netCheck(); }); if (keyIn) keyIn.addEventListener('input', function() { dr.key = keyIn.value.trim(); paint(); }); saveBtn.addEventListener('click', function() { if (dr.busy) return; if (busyMap[v.id]) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } var kc = keyIn ? keyCheck(v, dr.key) : { key: '' }; dr.busy = true; busyMap[v.id] = true; paint(); zm.toast('Применяем настройки ' + v.title + ' и перезапускаем его', 'warning'); zm.tgAction(v.id, 'conf=' + (+dr.port) + ':' + (kc.key || '')).then(function(r) { dr.busy = false; busyMap[v.id] = false; if (!r || r.error) { zm.toast((r && r.error) || 'Роутер не ответил', 'error', 10000); paint(); return; } zm.toast(r.same ? 'Ничего не изменилось' : r.running === false ? 'Сохранено. ' + v.title + ' сейчас не запущен — ссылка появится, когда он заработает' : v.title + ': порт и ключ применены — обновите прокси в Telegram по новой ссылке', 'info', 8000); editOpen[v.id] = false; delete editDraft[v.id]; zm.tgStatus().then(function(d) { data = d; renderCards(d); renderLinks(d); }); }, function() { dr.busy = false; busyMap[v.id] = false; zm.toast('Роутер не ответил', 'error'); paint(); }); }); var rows = [ E('div', { 'class': 'zm-tg-edit-row' }, [ E('label', { 'class': 'zm-tg-edit-label' }, 'Порт'), E('div', { 'class': 'zm-tg-edit-field' }, [ portIn, portMsg ]) ]) ]; if (keyIn) rows.push(E('div', { 'class': 'zm-tg-edit-row' }, [ E('label', { 'class': 'zm-tg-edit-label' }, 'Ключ'), E('div', { 'class': 'zm-tg-edit-field' }, [ E('div', { 'class': 'zm-tg-edit-keyline' }, [ keyIn, E('button', { 'class': 'cbi-button', 'type': 'button', 'title': 'Случайный ключ из 32 знаков', 'click': function() { dr.key = genKey(); keyIn.value = dr.key; paint(); } }, 'Новый ключ') ]), keyMsg ]) ])); var box = E('div', { 'class': 'zm-tg-edit' }, rows.concat([ E('div', { 'class': 'zm-tg-edit-label zm-tg-edit-plabel' }, 'Ссылка после сохранения'), preview, E('p', { 'class': 'zm-hint', 'style': 'margin:6px 0 0' }, v.kind === 'socks' ? 'После сохранения прокси перезапустится. В Telegram поменяйте порт у прокси или откройте новую ссылку.' : 'После сохранения прокси перезапустится, а старая ссылка перестанет работать — откройте в Telegram новую. Не запустится с новыми настройками — вернём прежние.'), E('div', { 'class': 'zm-actions' }, [ saveBtn, E('button', { 'class': 'cbi-button', 'click': function() { editOpen[v.id] = false; delete editDraft[v.id]; renderCards(data); } }, 'Отмена') ]) ])); if (String(+dr.port) === String(st.port)) dr.net = { free: true, self: true }; paint(); return box; } function renderLinks(d) { linksWrap.innerHTML = ''; VARIANTS.forEach(function(v) { var st = statusOf(d, v.id); var host = st.host || d.lan_ip, port = st.port || v.port; if (!st.installed || !st.running || !host) return; var link, extra; if (v.kind === 'socks') { link = 'tg://socks?server=' + host + '&port=' + port; extra = 'SOCKS5-адрес: ' + host + ':' + port; } else { if (!st.secret) return; link = 'tg://proxy?server=' + host + '&port=' + port + '&secret=' + st.secret; extra = 'Хост: ' + host + ' Порт: ' + port + ' Ключ: ' + st.secret; } 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 = [], pkgNote = null; if (st.uci) { var web = document.body.classList.contains('zmw-body'); actions.push(E('a', { 'class': 'cbi-button', 'href': L.url('admin/services/tg-ws-proxy-rs'), 'target': web ? '_blank' : null, 'rel': web ? 'noopener' : null }, 'Настройки пакета')); pkgNote = E('p', { 'class': 'zm-hint' }, st.enabled ? 'Установлен пакетом luci-app-tg-ws-proxy-rs: порт, ключ и остальное меняются в его настройках, обновление и удаление — через менеджер пакетов.' : 'Установлен пакетом luci-app-tg-ws-proxy-rs и выключен в его настройках — включите там «Enabled» и сохраните.'); if (String(st.port) === '1443' && d.mtproto === 'installed') pkgNote = E([ pkgNote, E('p', { 'class': 'zm-hint' }, '⚠ Порт 1443 занят и Go (MTProto) — оба прокси на одном порту не заработают. Смените порт в настройках пакета.') ]); } else if (st.installed) { if (st.edit) actions.push(E('button', { 'class': 'cbi-button' + (editOpen[v.id] ? ' zm-active' : ''), 'aria-expanded': editOpen[v.id] ? 'true' : 'false', 'click': function() { editOpen[v.id] = !editOpen[v.id]; if (!editOpen[v.id]) delete editDraft[v.id]; renderCards(data); } }, v.kind === 'socks' ? 'Изменить порт' : 'Порт и ключ')); actions.push(E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { zm.dialog({ title: 'Удалить ' + v.title + '?', danger: true, okText: 'Удалить', blocks: [ { type: 'list', title: 'Что произойдёт', items: [ v.title + ' остановится', 'Пакет и все файлы удалятся — даже если менеджер пакетов откажется', 'Telegram через этот прокси перестанет подключаться' ] } ] }).then(function(r) { if (r) 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, 'запущен', st.uci && !st.enabled ? 'выключен в пакете' : 'остановлен') : 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([]), st.installed ? E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Порт'), E('span', {}, [ String(st.port || v.port) ]) ]) : E([]), st.installed && st.secret ? E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Ключ'), E('span', { 'class': 'zm-tg-key', 'title': st.secret }, [ st.secret.length > 22 ? st.secret.slice(0, 10) + '…' + st.secret.slice(-8) : st.secret ]) ]) : E([]), pkgNote || E([]), E('div', { 'class': 'zm-actions' }, actions), st.installed && st.edit && editOpen[v.id] ? editPanel(v, st) : E([]) ])); }); 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() { zm.dialog({ title: 'Удалить sTGWS?', danger: true, okText: 'Удалить sTGWS', blocks: [ { type: 'list', title: 'Что произойдёт', items: [ 'sTGWS остановится', 'Пакет и все файлы удалятся — даже если менеджер пакетов откажется', 'Telegram через этот прокси перестанет подключаться' ] } ] }).then(function(v) { if (v) 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 E = (function(raw) { return function() { var a = Array.prototype.slice.call(arguments), i = a.length - 1; if (i >= 1 && (typeof a[i] === 'string' || typeof a[i] === 'number')) a[i] = [ String(a[i]) ]; return raw.apply(null, a); }; })(window.E); 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: 'H.O.D.C.A.', 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: 'bezmezhau', name: 'Bezmezhau', value: 'https://dns.bezmezhau.com/dns-query', boot: '77.88.8.8', alias: [ 'dns.bezmezhau.com' ] }, { 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' }, { id: 'malw', name: 'MALW', value: 'https://dns.malw.link/dns-query', boot: '77.88.8.8', alias: [ 'dns.malw.link' ] }, { id: 'zapretmoe', name: 'zapret.moe', value: 'https://dns.zapret.moe/dns-query', boot: '77.88.8.8', alias: [ 'dns.zapret.moe' ] }, { id: 'webshift', name: 'WebShift', value: 'https://d.webshiftvpn.ru/dns-query', boot: '77.88.8.8', alias: [ 'd.webshiftvpn.ru' ] }, { id: 'dagami', name: 'Dagami', value: 'https://dns.dagami.ru/dns-query', boot: '77.88.8.8', alias: [ 'dns.dagami.ru' ] }, { id: 'controld', name: 'Control D', value: 'https://freedns.controld.com/p0', boot: '76.76.2.0', alias: [ 'freedns.controld.com' ] }, { id: 'dns4eu', name: 'DNS4EU', value: 'https://unfiltered.joindns4.eu/dns-query', boot: '86.54.11.100', alias: [ 'unfiltered.joindns4.eu' ] } ], 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' }, { id: 'bezmezhau', name: 'Bezmezhau', value: 'dns.bezmezhau.com', 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.example.com) — допишем /dns-query; ссылка или адрес с путём берутся как есть, без пути — https://dns.example.com/.' } }; 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@|,;'"<>]*)?$/i }; function dohOwn(v) { if (/^[A-Za-z0-9.-]+\.[A-Za-z0-9-]+(:\d{1,5})?$/.test(v)) return 'https://' + v + '/dns-query'; if (/^https:\/\/[^\/?#]+$/i.test(v)) return v + '/'; if (!/^https:\/\//i.test(v) && /^[A-Za-z0-9.-]+\.[A-Za-z0-9-]+(:\d{1,5})?\//.test(v)) return 'https://' + v; return v; } 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: 'dev', label: 'Устройства' }, { id: 'byp', label: 'Исключения', common: true }, { id: 'set', label: 'Настройки', common: true } ]; function isSecTab(id) { return id === 'conn' || id === 'svc' || id === 'dev'; } var SEC_MAX = 9; var MODES = [ { id: 'links', label: 'Серверы' }, { id: 'sub', label: 'Подписка' }, { id: 'iface', label: 'Туннель' } ]; var SUB_FMT = [ { id: 'auto', label: 'Авто' }, { id: 'xray', label: 'Xray JSON (Happ)' }, { id: 'singbox', label: 'sing-box' } ]; var SUB_GROUP = [ { id: 'off', label: 'Нет' }, { id: 'country', label: 'По флагу страны' }, { id: 'prefix', label: 'По началу имени' } ]; var SUB_MAX = 10; var UT_IV = [ { id: '30s', label: '30 с' }, { id: '1m', label: '1 мин' }, { id: '3m', label: '3 мин' }, { id: '5m', label: '5 мин' } ]; var UT_URL_DEF = 'https://www.gstatic.com/generate_204'; var UT_URLS = [ { id: UT_URL_DEF, label: 'Google' }, { id: 'https://cp.cloudflare.com/generate_204', label: 'Cloudflare' }, { id: 'https://captive.apple.com', label: 'Apple' } ]; var LOG_LV = [ { id: 'panic', label: 'Выключен' }, { id: 'error', label: 'Только ошибки' }, { id: 'warn', label: 'Обычный' }, { id: 'info', label: 'Подробный' }, { id: 'debug', label: 'Отладка' } ]; var DRES_DEF = { udp: '1.1.1.1', dot: 'one.one.one.one', doh: 'https://cloudflare-dns.com/dns-query' }; var SEC_EXT = { sub_fmt: 'auto', sub_priority: false, ping_auto: false, sub_group: 'off', sub_plen: '2', sub_incl: '', sub_insecure: false, sub_ua: '', ut_iv: '3m', ut_tol: '50', ut_url: UT_URL_DEF, ut_guard: false, direct_fallback: false, uot: false, mixed: false, mixed_port: '2080', real_ip: false, dres_on: false, dres_type: 'udp', dres_server: '' }; var SET_EXT = { dns_ttl: '60', dns_ecs: '', src_ifs: [ 'br-lan' ], out_if: '', badwan: false, badwan_ifs: [], badwan_delay: '2000', ipv6: false, no_dhcp: false, excl_ntp: false, excl_bt: false, bt_ports: '', block_doh: false, log_level: 'panic', yacd: false, yacd_wan: false, yacd_secret: '', kill: false, hijack: false, quic_all: false, ecs_auto: false, fwd: '', p_dt: '', p_du: '', p_pt: '', p_pu: '' }; var PORT_KEYS = [ [ 'p_dt', 'TCP напрямую' ], [ 'p_du', 'UDP напрямую' ], [ 'p_pt', 'TCP через VPN' ], [ 'p_pu', 'UDP через VPN' ] ]; function portsBad(v) { return String(v || '').trim().split(/[\s,;]+/).filter(Boolean).some(function(p) { var m = /^(\d{1,5})(?:-(\d{1,5}))?$/.exec(p), a = m ? +m[1] : 0, b = m && m[2] ? +m[2] : a; return !m || a < 1 || b > 65535 || b < a; }); } function portsShort(t, u) { var r = [], cut = function(v) { v = String(v || '').trim().split(/[\s,;]+/).filter(Boolean).join(', '); return v.length > 24 ? v.slice(0, 22) + '…' : v; }; if (String(t || '').trim()) r.push('TCP ' + cut(t)); if (String(u || '').trim()) r.push('UDP ' + cut(u)); return r.join(', '); } function extFrom(def, src, into) { src = src || {}; Object.keys(def).forEach(function(k) { var v = src[k] != null ? src[k] : def[k]; into[k] = Array.isArray(def[k]) ? (Array.isArray(v) ? v.slice() : []) : typeof def[k] === 'boolean' ? !!v : String(v); }); return into; } function inclWords(v) { return String(v || '').split(/[|\n]/).map(function(w) { return w.trim().toLowerCase(); }).filter(Boolean); } function extVal(v) { return Array.isArray(v) ? v.slice().sort().join(',') : typeof v === 'string' ? v.trim() : v; } function extSig(d, def) { return Object.keys(def).map(function(k) { return extVal(d[k]); }); } function extPick(d, def) { var o = {}; Object.keys(def).forEach(function(k) { o[k] = Array.isArray(d[k]) ? d[k].slice() : typeof d[k] === 'string' ? d[k].trim() : d[k]; }); return o; } var SB_VARS = [ { id: 'tiny', label: 'Облегчённый', hint: 'Пакет sing-box-tiny из OpenWrt: меньше памяти, все протоколы Forkozz, кроме XHTTP и VMess — для них Forkozz сам поставит расширенный. Подходит почти всем.', warn: '' }, { id: 'stable', label: 'Обычный', hint: 'Полный sing-box из репозитория OpenWrt. Нужно больше места.', warn: '' }, { id: 'extended', label: 'Расширенный', hint: 'Сборка sing-box-extended с GitHub: XHTTP, VMess и другое. Forkozz ставит его сам, когда среди серверов или в подписке есть XHTTP или VMess; нужно ~40 МБ.', warn: 'Скачивается с GitHub (shtorm-7/sing-box-extended), нужно около 40 МБ свободной памяти.\n' }, { id: 'lite', label: 'Расширенный облегчённый', hint: 'Сборка sing-box-extended-lite с GitHub (yandexru45): те же XHTTP, VMess, VLESS Encryption, Reality, Hysteria2 и AmneziaWG, но без тяжёлого лишнего — 10–17 МБ вместо ~40. Для роутеров с маленьким флешем. Есть для amd64, arm64, armv7, mips и mipsel.', warn: 'Скачивается с GitHub (yandexru45/sing-box-extended-lite), нужно около 12–17 МБ на флеше. Если места мало, ставится сжатая сборка: при запуске она ненадолго берёт 35–45 МБ оперативной памяти.\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 NAIVE_RE = /^naive\+(https|quic):\/\/[^\s@]+@\S+$/i; var CAPS = {}; 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, pm = CAPS.prefixes ? /^(full|keyword|regexp?):(.*)$/i.exec(String(v || '').trim()) : null; if (pm && pm[1].toLowerCase() === 'keyword') return /^[a-z0-9._-]+$/i.test(pm[2].trim()) ? '' : '«' + raw + '» — после keyword: нужна часть имени: латинские буквы, цифры, точки и дефисы'; if (pm && pm[1].toLowerCase() !== 'full') { var rx = pm[2].trim(); return rx && rx.length <= 256 && !/[\s,;]/.test(rx) ? '' : '«' + raw + '» — после regex: нужно регулярное выражение без пробелов, запятых и точек с запятой'; } var d = String(v || '').trim().toLowerCase().replace(/^full:/, '').replace(/^\*\./, ''); if (/^(keyword|regex|regexp|domain_keyword|domain_regex):/.test(d)) return '«' + raw + '» — ' + (CAPS.prefixes ? 'пишите keyword: или regex:' : 'keyword: и regex: понимает движок Forkozz 0.9.11 и новее — обновите Forkozz или впишите домен целиком'); 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 пускайте только через что-то одно.', leaks: 'Forkozz не работает, а защита от утечек включена: трафик выбранных сервисов закрыт, DNS роутера ждёт Forkozz. Нажмите «Перезапустить» — или «Выключить», чтобы вернуть интернет напрямую.' }; var ACT_TEXT = { install: 'Устанавливаем Forkozz', update: 'Обновляем Forkozz', reinstall: 'Переустанавливаем Forkozz', remove: 'Удаляем Forkozz', stop: 'Выключаем Forkozz', start: 'Включаем Forkozz', restart: 'Перезапускаем Forkozz', apply: 'Применяем настройки', lists: 'Обновляем списки сервисов', subs: 'Обновляем серверы подписки', tsc_clean: 'Убираем остатки удалённого доступа', src_fix: 'Исправляем источники трафика', singbox: 'Меняем sing-box', singbox_reinstall: 'Переустанавливаем sing-box', singbox_remove: 'Удаляем sing-box' }; var DONE_TEXT = { install: 'Forkozz установлен', update: 'Forkozz обновлён', reinstall: 'Forkozz переустановлен', remove: 'Forkozz удалён', stop: 'Forkozz выключен', start: 'Forkozz включён', restart: 'Forkozz перезапущен', apply: 'Настройки применены', lists: 'Списки сервисов обновлены', subs: 'Серверы подписки обновлены', tsc_clean: 'Остатки удалённого доступа убраны', src_fix: 'Источники трафика исправлены', singbox: 'sing-box заменён', singbox_reinstall: '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 chkRow(head, body) { return E('div', { 'class': 'zm-row zm-chk' }, [ E('span', { 'class': 'zm-chk-h' }, [ head ]), E('span', { 'class': 'zm-chk-b' }, [ body ]) ]); } 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 ? 'нет ответа' : ms === -2 ? '…' : ''; } 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 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); var d = { 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.subs && c.subs.length ? c.subs : c.sub ? [ c.sub ] : []).join('\n'), sub_interval: c.sub_interval || '12h', iface: c.iface || '', fastest: true, exclude: c.exclude || '', items: pick.items, sel: pick.sel, own_on: c.own_on !== false && ((c.domains || []).length + (c.subnets || []).length + pick.lists.length) > 0, domains: (c.domains || []).join('\n'), subnets: (c.subnets || []).join('\n'), lists: pick.lists.join('\n'), full: (c.full || []).slice(), all: (c.all || []).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 }; extFrom(SEC_EXT, c.x, d); extFrom(SET_EXT, c.g, d); return d; } 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) { var ok = (x && typeof x === 'object' && !x.error && x.dns) ? x : null; if (ok) CAPS = ok.caps || {}; return ok; } 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([ extSig(d, SEC_EXT), extSig(d, SET_EXT), d.sec, d.mode, tl(d.links), tl(d.sub), 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), d.own_on !== false, tl(d.subnets), tl(d.lists), srt(d.full), srt(d.all || []), 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', 'excl', 'dns', 'quic_off', 'list_interval', 'lists_via' ].concat(Object.keys(SET_EXT)); 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.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 deviceScopesOverlap(a, b) { if (!a.length || !b.length) return true; function range(v) { var p = String(v).split('/'), octets = p[0].split('.').map(Number); if (octets.length !== 4 || octets.some(function(n) { return !isFinite(n) || n < 0 || n > 255; })) return null; var bits = p.length > 1 ? Number(p[1]) : 32; if (!isFinite(bits) || bits < 0 || bits > 32) return null; var size = Math.pow(2, 32 - bits), ip = octets.reduce(function(n, x) { return n * 256 + x; }, 0); var start = Math.floor(ip / size) * size; return [ start, start + size - 1 ]; } return a.some(function(x) { return b.some(function(y) { var ra = range(x), rb = range(y); return !ra || !rb || ra[0] <= rb[1] && rb[0] <= ra[1]; }); }); } 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.own_on = false; x.domains = []; x.subnets = []; x.lists = []; x.subs = []; x.x = {}; x.full = []; x.all = []; 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]); if (st.foreign) return E('div', { 'class': 'zm-wrap' }, [ zm.blockCard(E('div', { 'class': 'zm-card zm-kv' }), 'Forkozz', 'Выбранные сервисы идут через ваш сервер, подписку или туннель. Остальное — напрямую.', 'Стоит NetShift отдельным пакетом — он настраивается в своём разделе: Службы → NetShift. Панель его не трогает. Чтобы пользоваться Forkozz, удалите пакет NetShift.') ]); var draft = withBootMode(fromCfg(cfg)), savedSig = sig(draft), dirty = false, busy = false, lastAct = '', saving = false, saveToast = null; var servers = null, srvBusy = false, srvErr = '', latBusy = false; var diag = null, diagBusy = false, openGroups = {}, catWait = 0; var connKind = null, connRes = null, connBusy = false; 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, routeSource = '', routeDev = null, routeNeed = false; var svcCard = E('div', { 'class': 'zm-card' }); var ownCard = E('div', { 'class': 'zm-card' }); var fullCard = E('div', { 'class': 'zm-card' }); var allCard = 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' }); var netCard = E('div', { 'class': 'zm-card zm-kv' }); var panelCard = E('div', { 'class': 'zm-card zm-kv' }); var openFold = {}, ownUtUrl = false; var tDown = E('b', {}, '—'), tUp = E('b', {}, '—'), tAll = E('b', {}, '—'), tConn = E('b', {}, '—'), tConnLbl = E('span', {}, 'соединений'); var sbMem = E('b', {}, '—'), trafficNote = E('span', { 'class': 'zm-st-stat-sub', 'style': 'display:none' }, 'нет данных — sing-box ещё запускается'); var trafficEl = E('div', { 'class': 'zm-fk-traffic' }, [ E('div', { 'class': 'zm-fk-tile' }, [ tDown, E('span', {}, 'загрузка') ]), E('div', { 'class': 'zm-fk-tile' }, [ tUp, E('span', {}, 'отдача') ]), E('div', { 'class': 'zm-fk-tile' }, [ tAll, E('span', {}, 'всего') ]), E('div', { 'class': 'zm-fk-tile' }, [ tConn, tConnLbl ]) ]), trafficLast = null, trafficTimer = null; 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; } function linkOk(x) { return LINK_RE.test(x) || (!!CAPS.naive && NAIVE_RE.test(x)); } function linkKinds() { return 'vless, vmess, trojan, ss, socks5, hysteria2' + (CAPS.naive ? ', naive' : ''); } var taLinks = area('links', 'vless://…\nss://…\ntrojan://…', 130); var linksOpt = { value: draft.links, re: { test: linkOk }, placeholder: 'vless://…\nss://…\ntrojan://…', empty: 'Серверов пока нет — вставьте ссылки ниже и нажмите «Добавить».', busy: function() { return busy || saving; }, onChange: function(v) { draft.links = v; taLinks.value = v; touch(); renderLinkCount(); } }; Object.defineProperty(linksOpt, 'kinds', { get: linkKinds }); var linksEd = zm.linksEditor(linksOpt); var taSub = area('sub', 'https://…/sub/…\nhttps://… — вторая подписка, если есть', 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|reinstall|singbox|singbox_reinstall|tsc_clean|src_fix)$/.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: textLines(draft.sub)[0] || '', subs: textLines(draft.sub), sub_interval: draft.sub_interval, x: extPick(draft, SEC_EXT), g: extPick(draft, SET_EXT), iface: draft.iface, fastest: true, refs: refsOf(draft), own_on: draft.own_on !== false, domains: draft.domains, exclude: draft.mode === 'iface' ? '' : draft.exclude, subnets: draft.subnets, lists: draft.lists, full: draft.full, all: draft.all, 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') { var sl = textLines(draft.sub); if (!sl.length) return [ 'conn', 'Вставьте ссылку на подписку' ]; if (sl.length > SUB_MAX) return [ 'conn', 'Подписок в одной секции — не больше ' + SUB_MAX ]; var bs = sl.filter(function(u) { return !/^https?:\/\/[^\s\/]+(\/\S*)?$/i.test(u); })[0]; if (bs) return [ 'conn', 'Ссылка на подписку должна начинаться с https:// — ошибка в «' + bs.slice(0, 50) + '»' ]; var inc = inclWords(draft.sub_incl); var names = inc.length && !(cfg && cfg.x && cfg.x.sub_incl) && secExists() && cfg.mode === 'sub' && sl.join('\n') === (cfg.subs || []).join('\n') ? exclNames() : []; if (names.length && !names.some(function(n) { n = n.toLowerCase(); return inc.some(function(w) { return n.indexOf(w) >= 0; }); })) return [ 'conn', 'Под отбор «Только серверы со словами» не попал ни один сервер подписки — проверьте слова' ]; if (draft.sub_group === 'prefix' && !/^([1-9]|1[0-6])$/.test(draft.sub_plen.trim())) return [ 'conn', 'Длина начала имени для групп — число от 1 до 16' ]; } if (draft.mode !== 'iface') { if (!/^\d{1,5}$/.test(draft.ut_tol.trim()) || +draft.ut_tol > 10000) return [ 'conn', 'Допуск автовыбора — число миллисекунд от 0 до 10000' ]; if (!/^https?:\/\/[^\s\/]+(\/\S*)?$/i.test(draft.ut_url.trim() || UT_URL_DEF)) return [ 'conn', 'Адрес проверки серверов должен быть ссылкой http(s)://…' ]; } if (draft.mode === 'iface' && draft.dres_on && !DNS_RE[draft.dres_type].test(draft.dres_server.trim())) return [ 'conn', 'DNS туннеля: ' + DNS_HELP[draft.dres_type].hint ]; if (draft.mixed) { var mp = +draft.mixed_port.trim(); if (!/^\d{4,5}$/.test(draft.mixed_port.trim()) || mp < 1024 || mp > 65535) return [ 'conn', 'Порт локального прокси — число от 1024 до 65535' ]; } 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); var ownN = textLines(draft.domains).length + textLines(draft.subnets).length + textLines(draft.lists).length; if (!rf.c.length && !rf.s.length && !rf.r.length && !rf.l.length && (!ownN || draft.own_on === false) && !draft.all.length && (draft.full.length || !secExists())) return [ 'svc', ownN ? '«Свой список» выключен, а других сервисов в секции «' + secName() + '» нет — включите его или выберите сервис' : 'Выберите хотя бы один сервис, домен или адрес для секции «' + 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 ]; if (!/^\d{1,5}$/.test(draft.dns_ttl.trim()) || +draft.dns_ttl > 86400) return [ 'set', 'Время жизни DNS-записей — число секунд от 0 до 86400' ]; var ecs = draft.dns_ecs.trim(); if (ecs && !/^\d{1,3}(\.\d{1,3}){3}(\/\d{1,2})?$/.test(ecs) && !/^[0-9a-f:]*:[0-9a-f:]*(\/\d{1,3})?$/i.test(ecs)) return [ 'set', 'EDNS Client Subnet — IP-адрес или подсеть, например 203.0.113.0/24' ]; if (!draft.src_ifs.length) return [ 'set', 'Выберите хотя бы одну сеть, из которой Forkozz принимает трафик' ]; if (draft.out_if && draft.src_ifs.indexOf(draft.out_if) >= 0) return [ 'set', 'Интерфейс ' + draft.out_if + ' не может быть и источником, и выходом' ]; if (draft.badwan && !draft.badwan_ifs.length) return [ 'set', 'Выберите, за какими подключениями к интернету следить' ]; if (draft.badwan && (!/^\d{1,6}$/.test(draft.badwan_delay.trim()) || +draft.badwan_delay > 600000)) return [ 'set', 'Задержка перезапуска — число миллисекунд, например 2000' ]; if (draft.excl_bt && draft.bt_ports.trim() && draft.bt_ports.trim().split(/[\s,;]+/).some(function(p) { var m = /^(\d{1,5})(?:-(\d{1,5}))?$/.exec(p), a = m ? +m[1] : 0, b = m && m[2] ? +m[2] : a; return !m || a < 1024 || b > 65535 || b < a; })) return [ 'set', 'Порт торрент-клиента — число от 1024 до 65535 или диапазон, например 51413 или 6881-6889' ]; var badPort = CAPS.ports ? PORT_KEYS.filter(function(k) { return portsBad(draft[k[0]]); })[0] : null; if (badPort) { openFold.ports = true; return [ 'set', 'Порты ' + badPort[1] + ': нужны номера от 1 до 65535 или диапазоны через запятую, например 22, 6881-6889' ]; } var badFwd = CAPS.fwd ? textLines(draft.fwd).filter(function(l) { return !/^(\/[^\/\s]+\/\S+|\S+\s+\S+)$/.test(l); })[0] : null; if (badFwd) { openFold.dns = true; return [ 'set', 'Пересылка по зонам: «' + badFwd.slice(0, 40) + '» — нужно «зона сервер», например «ru 77.88.8.8»' ]; } if (draft.yacd && draft.yacd_wan && !/^[A-Za-z0-9._~+=-]{8,64}$/.test(draft.yacd_secret.trim())) return [ 'set', 'Для доступа к панели из интернета задайте ключ: 8–64 латинских букв, цифр или ._-~+=' ]; 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))); var zmGo = function() { 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 (!isSecTab(tab)) tabTo('conn'); renderAll(); if (st.running && secLive()) loadServers(); }).catch(function() { secBusy = false; renderSections(); zm.toast('Роутер не ответил', 'error'); }); }; if (secDirty && !force) return zm.ask('В секции «' + secName() + '» есть несохранённые изменения.\n\nОтменить их и перейти к другой секции?').then(function(ok) { return ok ? zmGo() : undefined; }); return zmGo(); } function deleteSec() { if (busy || saving) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } zm.dialog({ title: 'Удалить секцию «' + secName() + '»?', danger: true, okText: 'Удалить секцию', blocks: [ { type: 'list', title: 'Что произойдёт', items: [ 'Её правила перестанут действовать', 'Остальные секции и их выбор устройств сохранятся' ] } ] }).then(function(v) { if (v) 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 ? 'Исключения, список «Мимо Forkozz» и настройки действуют для всех секций сразу. Нажмите на секцию, чтобы настроить её подключение, сервисы и устройства.' : 'Каждая секция ведёт свои сервисы через своё подключение. На вкладке «Устройства» можно пустить через секцию весь трафик выбранных устройств или ограничить её правила отдельными устройствами.')); var tiles = list.map(function(s) { var on = s.name === draft.sec, n = secSel(cfg, s), extra = (s.domains || 0) + (s.subnets || 0), f = (s.full || []).length; var what = n ? nn(n, 'список', 'списка', 'списков') : extra ? nn(extra, 'адрес', 'адреса', 'адресов') : 'пусто'; if (f) what = (n || extra ? what + ' · ' : '') + nn(f, 'устройство', 'устройства', 'устройств'); var fa = (s.all || []).length; if (fa) what = (n || extra || f ? what + ' · ' : '') + 'весь трафик: ' + fa; return node(s.label, secConn(s), on, function() { open(s.name); }, E('span', { 'class': 'zm-lat ' + (!s.enabled ? 'zm-lat-bad' : n || extra || f || fa ? 'zm-lat-good' : 'zm-lat-none'), 'style': 'flex:0 1 auto; white-space:normal; min-width:0; text-align:right; overflow-wrap:anywhere' }, 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() + '»') + ': через неё идут 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 = servers.auto_tag || String(servers.group || '').replace(/-out$/, '-urltest-out'); if (list.indexOf(autoTag) < 0 || !ns[autoTag] || !ns[autoTag].members) autoTag = list.filter(function(t) { return ns[t] && ns[t].members; })[0]; var auto = autoTag ? ns[autoTag] : null; var isAuto = !!(autoTag && servers.now === autoTag); var curTag = servers.now, seen = {}; while (ns[curTag] && ns[curTag].members && ns[curTag].now && !seen[curTag]) { seen[curTag] = true; curTag = ns[curTag].now; } return { ns: ns, list: list, autoTag: autoTag, auto: auto, isAuto: isAuto, curTag: curTag, cur: ns[curTag] || {} }; } function serverMembers(tags, ns) { var seen = {}, result = []; function add(t) { if (seen[t]) return; seen[t] = true; if (ns[t] && ns[t].members) ns[t].members.forEach(add); else result.push(t); } tags.forEach(add); return result; } 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 = 0, 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; f += (s.full || []).length; } }); 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) 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() { zm.dialog({ title: 'Удалить sing-box?', danger: true, okText: 'Удалить sing-box', intro: 'sing-box ' + st.singbox + ' (' + sbn + ') остался после Forkozz. Сам по себе он ничего не делает.', blocks: [ { type: 'list', title: 'Что произойдёт', items: [ 'Программа sing-box и её пакет удалятся', 'Её настройки и кэш удалятся' ] } ] }).then(function(v) { if (v) 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 secLists(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 ((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, 'подсеть', 'подсети', 'подсетей')); if (s.own_off) parts.push('свой список выключен'); if ((s.full || []).length) parts.push('только для ' + nn(s.full.length, 'устройства', 'устройств', 'устройств')); if ((s.all || []).length) parts.push('весь трафик: ' + nn(s.all.length, 'устройство', 'устройства', 'устройств')); return { names: names, extra: parts }; } function secConnText(s, x) { if (x && x.fallback_direct) return [ 'Напрямую', 'VPN недоступны · проверяем раз в 2 минуты' ]; 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.fallback_direct) return badge('zm-warn', 'напрямую'); 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 ? 'проверяем…' : 'нет данных'); } var SVC_SHOW = 6, secOpen = {}; function secChips(s) { var l = secLists(s), box = E('div', { 'class': 'zm-fk-chips' }), names = l.names; var cut = !secOpen[s.name] && names.length > SVC_SHOW ? SVC_SHOW - 1 : names.length; names.slice(0, cut).forEach(function(n) { box.appendChild(E('span', { 'class': 'zm-fk-chip', 'title': n }, n)); }); if (cut < names.length) box.appendChild(E('span', { 'class': 'zm-fk-chip zm-fk-chip-more', 'title': names.slice(cut).join(', '), 'click': function(ev) { ev.stopPropagation(); secOpen[s.name] = true; box.parentNode.replaceChild(secChips(s), box); } }, 'ещё ' + (names.length - cut))); l.extra.forEach(function(t) { box.appendChild(E('span', { 'class': 'zm-fk-chip zm-fk-chip-x', 'title': t }, t)); }); if (!names.length && !l.extra.length) box.appendChild(E('span', { 'class': 'zm-fk-chip zm-fk-chip-x' }, 'ничего не выбрано')); return box; } function secBox(s) { var x = secState && (secState.sections || []).filter(function(e) { return e.name === s.name; })[0]; var c = secConnText(s, x); var val = s.mode !== 'iface' && x && x.server && x.delay > 0 && !x.fallback_direct ? [ x.server, ' · ', E('span', { 'class': 'zm-lat ' + latClass(x.delay) }, x.delay + ' мс') ] : [ c[0] ]; return E('div', { 'class': 'zm-st-stat zm-fk-sec' + (s.main ? ' zm-fk-sec-main' : '') + (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', 'title': s.label }, [ s.label ]), secStateBadge(s, x) ]), E('span', { 'class': 'zm-st-stat-value', 'title': c[0] }, val), E('span', { 'class': 'zm-st-stat-sub', 'title': c[1] }, [ c[1] ]), secChips(s) ]); } function topBox() { var bp = cfg.bypass || {}, bn = (bp.domains || []).length + (bp.subnets || []).length, e = (cfg.excl || []).length; var sb = st.singbox ? E('b', { 'title': 'sing-box ' + st.singbox }, [ SB_NAMES[st.singbox_pkg] || 'установлен вручную', E('span', { 'style': 'display:block; font-weight:500; opacity:.6; font-size:12px' }, st.singbox.replace(/-extended.*$/, '')) ]) : 'не установлен'; var dn = cfg.dns ? dnsLabel(cfg.dns) : '—', dt = cfg.dns ? (DNS_TYPES[cfg.dns.type] || '') + (cfg.dns.detour ? ' · через VPN' : '') : ''; var live = st.running && !busy; function info(k, v) { return E('div', {}, [ E('span', {}, k), typeof v === 'string' ? E('b', { 'title': v }, v) : v ]); } return E('div', { 'class': 'zm-st-stat zm-fk-top' }, [ E('span', { 'class': 'zm-fk-sec-head' }, [ E('span', { 'class': 'zm-st-stat-label' }, [ 'Общее · для всех секций' ]), E('span', { 'class': 'zm-fk-top-badges' }, [ statusBadge(), st.newer && !busy ? badge('zm-warn', 'есть версия ' + st.latest) : E([]) ]) ]), live ? trafficEl : E([]), live ? trafficNote : E([]), E('div', { 'class': 'zm-fk-info' }, [ info('DNS', dn + (dt ? ' · ' + dt : '')), info('Исключения', bn ? nn(bn, 'строка', 'строки', 'строк') : 'нет'), info('Мимо Forkozz', e ? nn(e, 'устройство', 'устройства', 'устройств') : 'нет устройств'), info('Движок', st.version || '—'), info('sing-box', sb), live ? info('Память sing-box', sbMem) : E([]) ]) ]); } function rate(b) { return fmtBytes(Math.max(0, Math.round(b))) + '/с'; } function trafficTick() { trafficTimer = null; if (!document.body.contains(trafficEl) || !st.running) { trafficLast = null; return; } if (document.hidden || busy) { trafficTimer = setTimeout(trafficTick, 3000); return; } zm.forkopAction('stats', '').then(function(r) { if (r && r.ok) { var now = Date.now(); if (trafficLast && r.down >= trafficLast.down && r.up >= trafficLast.up) { var dt = Math.max(1, (now - trafficLast.t) / 1000); tDown.textContent = '↓ ' + rate((r.down - trafficLast.down) / dt); tUp.textContent = '↑ ' + rate((r.up - trafficLast.up) / dt); } trafficLast = { t: now, down: r.down, up: r.up }; tAll.textContent = fmtBytes(r.down + r.up); tConn.textContent = String(r.conns || 0); tConnLbl.textContent = nn(r.conns || 0, 'соединение', 'соединения', 'соединений').replace(/^\d+\s+/, ''); sbMem.textContent = r.mem > 0 ? fmtBytes(r.mem) : '—'; trafficNote.style.display = 'none'; } else trafficNote.style.display = ''; }).catch(function() {}).then(function() { if (!trafficTimer && document.body.contains(trafficEl)) trafficTimer = setTimeout(trafficTick, 3000); }); } function trafficStart() { if (!trafficTimer) trafficTimer = setTimeout(trafficTick, trafficLast ? 3000 : 300); } 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])); }); var withSecs = st.installed && configured() && secs().length; if (!withSecs) 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 (withSecs) { var allSecs = secs(), mainSec = allSecs.filter(function(s) { return s.main; })[0] || allSecs[0]; mainCard.appendChild(topBox()); var cols = allSecs.length === 4 ? 2 : Math.min(allSecs.length, 3); mainCard.appendChild(E('div', { 'class': 'zm-st-stats zm-fk-secs zm-fk-cols-' + cols }, [ mainSec ].concat(allSecs.filter(function(s) { return s !== mainSec; })).map(secBox))); if (st.running && !busy) trafficStart(); } 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('Версия', 'Движок ' + (st.version || '—'), sb) ])); } if (busy) { mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Можно закрыть страницу — роутер всё доделает сам.')); return; } var b = []; 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 МБ свободной памяти. Всё скачивается только из официальных источников: GitHub и репозиторий OpenWrt. DNS по умолчанию — Quad9 (9.9.9.9).')); 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)); else b.push(E('button', { 'class': 'cbi-button', 'title': 'Скачать движок Forkozz ' + (st.version || '') + ' заново и доставить недостающие пакеты. Настройки, секции и sing-box останутся', 'click': function() { zm.ask('Переустановить Forkozz ' + (st.version || '') + '?\n\nДвижок скачается с GitHub заново, недостающие пакеты доставятся. Настройки, секции и sing-box останутся.' + (st.running ? '\nНа время переустановки Forkozz остановится.' : '')).then(function(ok) { if (ok) act('reinstall'); }); } }, 'Переустановить')); b.push(E('button', { 'class': 'cbi-button cbi-button-remove', 'click': removeDialog }, 'Удалить')); mainCard.appendChild(E('div', { 'class': 'zm-actions' }, b)); if (!configured()) mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Три шага: подключение → сервисы → «Сохранить и включить».')); } function removeDialog() { zm.forkopAction('tunnels', '').then(function(res) { return res || {}; }, function() { return {}; }).then(function(info) { var tl = info.tunnels || [], many = tl.length > 1, names = tl.map(function(t) { return t.name; }).join(', '); var foreign = tl.filter(function(t) { return !t.owned; }).map(function(t) { return t.name; }); var routeAll = tl.some(function(t) { return t.route_all; }); var sb = info.singbox || st.singbox, users = info.singbox_users || ''; var awgFree = !!info.awg_installed && !info.awg_busy; var T = many ? 'туннели' : 'туннель', Tcap = many ? 'Туннели' : 'Туннель'; var blocks = []; if (tl.length) blocks.push({ type: 'choice', id: 'tun', value: 'stop', title: Tcap + ' AmneziaWG: ' + names, hint: 'Через ' + (many ? 'них' : 'него') + ' Forkozz вёл выбранные сервисы. Без Forkozz ' + (many ? 'они' : 'он') + ' обычно не ' + (many ? 'нужны' : 'нужен') + '.', options: [ { id: 'stop', label: 'Выключить ' + T, hint: 'Ключи и настройки останутся. Включить снова можно на вкладке AmneziaWG.' }, { id: 'delete', label: 'Удалить ' + T, hint: foreign.length ? 'Удалятся только созданные в панели. ' + foreign.join(', ') + ' — не из панели, он будет выключен.' : 'Вместе с ключами и зоной файрвола. AmneziaWG останется на роутере.' }, { id: 'purge', label: 'Удалить ' + T + ' и AmneziaWG', disabled: !awgFree, hint: awgFree ? 'AmneziaWG больше нигде не используется — уберём ' + T + ', пакеты и модуль ядра.' : 'Недоступно: ' + (info.awg_busy || 'AmneziaWG не установлен') + '.' }, { id: 'keep', label: 'Оставить включённым', warn: routeAll, hint: routeAll ? 'Осторожно: у туннеля включён маршрут «весь трафик». Через него пойдёт весь интернет роутера, а если он упадёт — интернет пропадёт.' : 'Только если ' + T + ' нужен вам для другого. Маршрутов через ' + (many ? 'них' : 'него') + ' нет, поэтому интернет устройств туда не уйдёт и не пропадёт.' } ] }); if (sb) blocks.push({ type: 'switch', id: 'sb', value: !users, disabled: !!users, label: 'Удалить sing-box ' + String(sb).replace(/-extended.*$/, ''), hint: users ? 'Останется: он нужен другим программам (' + users + ').' : 'Через него Forkozz вёл трафик. Больше на роутере он никому не нужен.' }); if (!tl.length && awgFree) blocks.push({ type: 'switch', id: 'awg', value: false, label: 'Удалить AmneziaWG', hint: 'Он установлен, но туннелей на нём нет и другим разделам он не нужен.' }); blocks.push({ type: 'list', title: 'Что произойдёт', items: function(v) { var out = [ 'Forkozz удалится со всеми настройками: подключения, секции, сервисы, исключения', 'Правила файрвола, маршруты и расписание Forkozz уберутся, DNS роутера вернётся к обычному' ]; if (tl.length) out.push(v.tun === 'purge' ? Tcap + ' ' + names + ' и AmneziaWG удалятся' : v.tun === 'delete' ? Tcap + ' ' + names + (many ? ' удалятся' : ' удалится') + (foreign.length ? ' (кроме ' + foreign.join(', ') + ')' : '') : v.tun === 'keep' ? Tcap + ' ' + names + (many ? ' останутся включёнными' : ' останется включённым') : Tcap + ' ' + names + (many ? ' выключатся' : ' выключится') + ', настройки останутся'); if (sb) out.push(v.sb ? 'sing-box удалится' : 'sing-box останется на роутере, но будет остановлен'); if (v.awg) out.push('AmneziaWG удалится'); out.push('Весь трафик пойдёт напрямую через провайдера'); return out; } }); blocks.push({ type: 'note', text: 'Сайты, которые шли через Forkozz, могут минуту не открываться, пока устройства забудут старые адреса. После удаления панель проверит, что ничего не осталось.' }); return zm.dialog({ title: 'Удалить Forkozz?', danger: true, okText: 'Удалить Forkozz', blocks: blocks }); }).then(function(v) { if (!v) return; act('remove', 'tun=' + (v.tun || 'stop') + ';sb=' + (v.sb === false ? 'keep' : 'remove') + (v.awg ? ';awg=remove' : '')); }); } function renderTabs() { tabBar.innerHTML = ''; var show = !!st.installed; tabBar.style.display = show ? '' : 'none'; var one = secs().length <= 1 && draft.sec !== 'new'; tabBar.className = 'zm-tabsplit' + (one ? ' zm-tabs-one' : ''); (one ? [ null ] : [ false, true ]).forEach(function(common) { tabBar.appendChild(E('div', { 'class': 'zm-actions zmw-tabs zm-tabgrp' }, TABS.filter(function(t) { return common === null || !!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 && !saving && (dirty || !configured() || draft.sec === 'new') ? '' : 'none'; } function renderSaveBar() { var isNew = draft.sec === 'new'; var show = !!st.installed && !saving && (dirty || !configured() || isNew); var runText = 'Сохраняем настройки' + (secs().length > 1 ? ' секции «' + secName() + '»' : '') + '…'; if (saving && st.installed) { if (saveToast) saveToast.set(runText); else saveToast = zm.busyToast(runText); } else if (saveToast) { saveToast.close(); saveToast = null; } saveBar.innerHTML = ''; saveBar.style.display = show ? '' : 'none'; saveBar.classList.remove('zm-savebar-run', 'zm-savebar-done'); 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 !linkOk(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 fold(id, title, summary, build) { var open = !!openFold[id], box = E('div', { 'class': 'zm-fold' + (open ? ' zm-fold-open' : '') }); box.appendChild(E('div', { 'class': 'zm-fold-head', 'click': function() { openFold[id] = !openFold[id]; renderPanes(); } }, [ E('span', { 'class': 'zm-svc-chev' }, '›'), E('span', {}, title), summary ? E('span', { 'class': 'zm-fold-sum' }, summary) : E([]) ])); if (open) { var body = E('div', { 'class': 'zm-fold-body' }); build(body); box.appendChild(body); } return box; } function inp(key, ph, width, tail) { var el = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'autocapitalize': 'off', 'spellcheck': 'false', 'placeholder': ph || '', 'style': 'width:' + (width || 260) + 'px; max-width:100%' }); el.value = draft[key]; el.addEventListener('input', function() { draft[key] = el.value; touch(); }); return tail ? E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap' }, [ el, E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, tail) ]) : el; } function hint(text, top) { return E('p', { 'class': 'zm-hint', 'style': 'margin-top:' + (top == null ? 2 : top) + 'px' }, text); } function netInfo() { return (cfg && cfg.net) || { lan_ip: '', nets: [], devs: [] }; } function lanIp() { return netInfo().lan_ip || 'IP-роутера'; } function devFoot(d) { var k = { bridge: 'мост', eth: 'порт', ppp: 'PPP', tun: ({ wireguard: 'WireGuard', amneziawg: 'AmneziaWG' }[d.proto] || 'туннель'), wifi: 'Wi-Fi' }[d.kind] || ''; return (d.net ? 'сеть ' + d.net + (k ? ' · ' + k : '') : k) + (d.missing ? ' · не найден' : ''); } function devTiles(list, isOn, click) { return E('div', { 'class': 'zm-nodes zm-nodes-3' }, list.map(function(d) { var on = isOn(d.name), up = d.up && !d.missing; return node(d.name, devFoot(d), on, function() { click(d.name, on); }, d.kind ? E('span', { 'class': 'zm-lat ' + (up ? 'zm-lat-good' : 'zm-lat-bad') }, up ? 'активен' : 'выключен') : E('span')); })); } function withMissing(list, names) { var have = {}; list.forEach(function(d) { have[d.name] = true; }); return list.concat(names.filter(function(n) { return n && !have[n]; }).map(function(n) { return { name: n, kind: '', up: false, missing: true, net: '' }; })); } function connExtSummary() { var p = []; if (draft.mode === 'sub') { if (textLines(draft.sub).length > 1) p.push('подписок: ' + textLines(draft.sub).length); if (draft.sub_priority) p.push('по порядку подписок'); if (draft.sub_fmt !== 'auto') p.push(draft.sub_fmt === 'xray' ? 'формат Happ' : 'формат sing-box'); if (draft.sub_group !== 'off') p.push(draft.sub_group === 'country' ? 'группы по странам' : 'группы по имени'); if (inclWords(draft.sub_incl).length) p.push('отбор по словам'); if (draft.sub_insecure) p.push('без проверки сертификата'); } if (draft.mode !== 'iface') { if (draft.ut_iv !== '3m' || draft.ut_tol.trim() !== '50' || (draft.ut_url.trim() || UT_URL_DEF) !== UT_URL_DEF) p.push('свой автовыбор'); if (draft.ut_guard && !(draft.mode === 'sub' && draft.sub_priority)) p.push('быстрая замена при обрыве'); if (draft.ping_auto && !(draft.mode === 'sub' && draft.sub_priority)) p.push('авто по пингу'); if (draft.direct_fallback) p.push('напрямую при отказе VPN'); if (draft.uot) p.push('UDP поверх TCP'); } else if (draft.dres_on) p.push('DNS ' + dnsName(draft.dres_type, draft.dres_server.trim())); if (draft.mixed) p.push('прокси :' + draft.mixed_port.trim()); if (draft.real_ip) p.push('настоящий IP'); return p.join(' · '); } function renderConnExt(box) { var many = secs().length > 1 || draft.sec === 'new'; if (draft.mode === 'sub') { box.appendChild(E('h4', {}, 'Подписка')); box.appendChild(row('Формат', seg(SUB_FMT, draft.sub_fmt, function(v) { set('sub_fmt', v); }))); box.appendChild(hint('Какой формат просить у сервиса первым. «Авто» подходит почти всем. Xray JSON (Happ) — если часть серверов (например, с XHTTP) сервис отдаёт только приложению Happ; sing-box — если сервис умеет отдавать его напрямую.')); box.appendChild(row('Группы', seg(SUB_GROUP, draft.sub_group, function(v) { set('sub_group', v); }))); if (draft.sub_group === 'prefix') box.appendChild(row('Символов', inp('sub_plen', '2', 70, 'первые символы имени, по которым серверы собираются в группу'))); box.appendChild(hint('Серверы собираются в группы, и в каждой работает автовыбор лучшего. Удобно, когда в подписке десятки серверов: в карточке «Серверы» выбираете страну, а не отдельный сервер.')); box.appendChild(row('Только со словами', inp('sub_incl', 'Германия | NL | 🇫🇮', 340))); box.appendChild(hint('Из подписки останутся только серверы, в имени которых есть хоть одно из слов (через |, регистр не важен). Убрать отдельные серверы удобнее маркерами в карточке «Серверы».')); box.appendChild(row('User-Agent', inp('sub_ua', 'подбирается сам', 340))); box.appendChild(hint('Нужен редко: если сервис отдаёт подписку только определённому приложению, например Happ/2.0.')); box.appendChild(sw(draft.sub_insecure, 'Не проверять сертификат подписки', 'Только для панелей по IP-адресу с самоподписанным сертификатом. Небезопасно: подписку могут подменить по дороге.', function() { set('sub_insecure', !draft.sub_insecure); })); } if (draft.mode !== 'iface') { box.appendChild(E('h4', {}, 'Автовыбор сервера')); var prio = draft.mode === 'sub' && draft.sub_priority, am = prio ? 'priority' : draft.ping_auto ? 'ping' : draft.ut_guard ? 'guard' : 'latency'; var AM = [ { id: 'latency', label: 'По задержке' }, { id: 'guard', label: 'По задержке + быстрая замена' }, { id: 'ping', label: 'По пингу' } ]; if (draft.mode === 'sub') AM.push({ id: 'priority', label: 'По порядку подписок' }); box.appendChild(row('Режим «Авто»', seg(AM, am, function(v) { draft.sub_priority = v === 'priority'; draft.ut_guard = v === 'guard'; draft.ping_auto = v === 'ping'; touch(); renderPanes(); }))); var ivl = (UT_IV.filter(function(x) { return x.id === draft.ut_iv; })[0] || { label: '3 мин' }).label; box.appendChild(hint(am === 'priority' ? 'Чем выше ссылка, тем она главнее: пока в первой подписке есть рабочий сервер, работает только она, отказали все — включается следующая. Раз в 2 минуты Forkozz проверяет подписки и сам вернётся к главной. Внутри подписки берётся сервер с меньшей задержкой. Ручной выбор сервера отключает этот порядок до возврата «Авто».' : am === 'ping' ? 'Forkozz замеряет пинг серверов и включает самый быстрый. С отвечающего сервера не уходит — раз в 15 минут сверяет, нет ли быстрее на величину допуска. Оборвался — за 20–40 секунд переходит на самый быстрый из рабочих. Ручной выбор сервера отключает режим до возврата «Авто».' : am === 'guard' ? 'Как «По задержке», но ещё каждые 20 секунд проверяется текущий сервер. Не ответил дважды подряд — Forkozz сразу перепроверяет все и переходит на самый быстрый из живых: обрыв замечается за полминуты.' : 'Раз в ' + ivl + ' Forkozz открывает проверочный адрес через каждый сервер и замеряет задержку. Работает сервер с наименьшей задержкой; тот, что не ответил, не выбирается. Если сервер оборвался между проверками, замена произойдёт при следующей проверке — до ' + ivl + '. Чтобы не ждать, выберите «быструю замену».', 0)); if (!prio && am !== 'ping') box.appendChild(row('Плановая проверка', seg(UT_IV, draft.ut_iv, function(v) { set('ut_iv', v); }))); box.appendChild(row('Допуск', inp('ut_tol', '50', 80, 'мс — на сервер быстрее меньше чем на столько Forkozz не переходит'))); box.appendChild(hint('Допуск бережёт от лишних переключений: Forkozz уходит на другой сервер, только если тот быстрее текущего больше чем на это число миллисекунд.', 0)); var url = draft.ut_url.trim() || UT_URL_DEF, preset = UT_URLS.some(function(u) { return u.id === url; }) && !ownUtUrl; box.appendChild(row('Проверка через', seg(UT_URLS.concat([ { id: 'own', label: 'Свой адрес' } ]), preset ? url : 'own', function(v) { ownUtUrl = v === 'own'; if (!ownUtUrl) set('ut_url', v); else renderPanes(); }))); if (!preset) box.appendChild(row('Адрес', inp('ut_url', 'https://…/generate_204', 340))); box.appendChild(sw(draft.direct_fallback, 'Напрямую при отказе всех VPN', 'Если не отвечает ни один сервер секции, её сервисы, домены и адреса временно идут напрямую. Forkozz проверяет серверы раз в 2 минуты и вернёт секцию на рабочий сервер, как только он появится. Выбор устройств не меняется. По умолчанию выключено.', function() { set('direct_fallback', !draft.direct_fallback); })); box.appendChild(sw(draft.uot, 'UDP поверх TCP', 'Для серверов Shadowsocks и SOCKS: UDP (звонки, игры, QUIC) пойдёт внутри TCP. Включайте, если сервер не пропускает UDP.', function() { set('uot', !draft.uot); })); } else { box.appendChild(E('h4', {}, 'DNS туннеля')); box.appendChild(sw(draft.dres_on, 'Свой DNS для сайтов секции', 'Адреса сайтов этой секции будут искаться через сам туннель указанным DNS — ответ совпадёт со страной туннеля, а провайдер его не увидит.', function() { if (!draft.dres_on && !DNS_RE[draft.dres_type].test(draft.dres_server.trim())) draft.dres_server = DRES_DEF[draft.dres_type]; set('dres_on', !draft.dres_on); })); if (draft.dres_on) { box.appendChild(row('Протокол', seg([ { id: 'doh', label: 'DoH' }, { id: 'dot', label: 'DoT' }, { id: 'udp', label: 'UDP' } ], draft.dres_type, function(v) { if (!DNS_RE[v].test(draft.dres_server.trim())) draft.dres_server = DRES_DEF[v]; set('dres_type', v); }))); box.appendChild(row('Сервер', inp('dres_server', DNS_HELP[draft.dres_type].ph, 320))); } } box.appendChild(E('h4', {}, 'Для программ и роутера')); box.appendChild(sw(draft.mixed, 'Локальный прокси HTTP/SOCKS5', 'Программы в сети (браузер, Telegram, торрент-клиент) смогут ходить через подключение ' + (many ? 'секции «' + secName() + '»' : 'Forkozz') + ' целиком, если указать им прокси ' + lanIp() + ':' + (draft.mixed_port.trim() || '2080') + '. Работает на всех устройствах домашней сети, без логина.', function() { set('mixed', !draft.mixed); })); if (draft.mixed) box.appendChild(row('Порт', inp('mixed_port', '2080', 90, many ? 'у каждой секции — свой' : ''))); box.appendChild(sw(draft.real_ip, 'Узнавать настоящий IP сайтов', 'Forkozz сам найдёт IP-адрес сайта перед выбором маршрута — тогда правила по подсетям срабатывают и для запросов по домену. Нужно редко.', function() { set('real_ip', !draft.real_ip); })); } 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' }, 'Ссылки ' + linkKinds() + ' — можно сразу несколько, по одной на строку. Для серверов с XHTTP и VMess Forkozz сам поставит расширенный sing-box' + (CAPS.naive ? ', для NaiveProxy (naive+https://логин:пароль@сервер) — клиент NaiveProxy' : '') + '. После правок нажмите «Сохранить» внизу.')); 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'), '.' ])); } connCard.appendChild(fold('conn', 'Дополнительно', connExtSummary(), renderConnExt)); 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; var srvAgain = false; function loadServers() { if (!secLive()) return; if (srvBusy) { srvAgain = true; return; } srvAgain = false; srvBusy = true; renderServers(); var forSec = cfg && cfg.sec; zm.forkopAction('servers', forSec || '').then(function(res) { if (!cfg || cfg.sec !== forSec) { srvBusy = false; if (srvAgain) loadServers(); else renderServers(); 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(); if (srvAgain) loadServers(); }).catch(function() { srvBusy = false; srvErr = 'роутер не ответил'; renderServers(); if (srvAgain) loadServers(); }); } function testLatency() { if (latBusy) return; var forSec = (cfg && cfg.sec) || '', tries = 0; latBusy = true; if (servers && servers.nodes) Object.keys(servers.nodes).forEach(function(t) { servers.nodes[t].delay = -2; }); renderServers(); function done(text, kind) { latBusy = false; renderServers(); renderMain(); if (text) zm.toast(text, kind); } function score(r) { var ns = (r && r.nodes) || {}, ok = 0, all = 0, blk = 0; Object.keys(ns).forEach(function(t) { if (!ns[t].members) { all++; if (ns[t].delay > 0) ok++; else if (ns[t].blocked) blk++; } }); return 'отвечают ' + ok + ' из ' + all + (blk ? ', порт открыт, но не пускают — ' + blk : ''); } function poll() { setTimeout(function() { tries++; zm.forkopAction('servers', forSec).then(function(r) { if (!cfg || ((cfg.sec || '') !== forSec)) { latBusy = false; return; } if (r && !r.error) { servers = r; srvErr = ''; } var running = !!(r && r.lat && r.lat.running); if (running && tries < (r && r.lat_mode === 'fast' ? 90 : 240)) { renderServers(); poll(); return; } var le = r && r.lat && r.lat.error; done(running ? 'Проверка идёт дольше обычного — задержки появятся сами' : le ? 'Задержка не проверена: ' + le : 'Проверка закончена: ' + score(r), running || le ? 'warning' : 'info'); }).catch(function() { if (tries < 90) poll(); else done('Роутер не ответил', 'error'); }); }, tries ? 1200 : 800); } zm.forkopAction('latency', forSec).then(function(res) { if (res.error) { done(res.error, 'error'); return; } poll(); }).catch(function() { done('Роутер не ответил', 'error'); }); } function pickServer(tag, name) { var forSec = (cfg && cfg.sec) || ''; zm.forkopAction('select', forSec + '|' + tag).then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast('Выбрано: ' + name, 'info'); if (servers && cfg && (cfg.sec || '') === forSec) { servers.now = tag; servers.fell = ''; if (servers.fallback) servers.fallback.direct = false; var nt = servers.nodes && servers.nodes[tag]; var se = secState && (secState.sections || []).filter(function(e) { return e.name === forSec; })[0]; if (se) { se.fallback_direct = false; se.auto = !!(nt && nt.members); if (nt && nt.members) { var an = servers.nodes[nt.now]; se.server = an ? an.name || nt.now : ''; se.delay = an ? an.delay : -1; } else { se.server = nt ? nt.name || tag : name; se.delay = nt ? nt.delay : -1; } } renderServers(); renderMain(); } loadServers(); loadSecState(false); }).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; } var picking = false; function srvAskDead(t, n) { var ns = (servers && servers.nodes) || {}, best = null, S = srvState(); (S.list || []).forEach(function(x) { var m = ns[x]; if (!m || m.members || m.hidden || x === t || x === S.curTag || m.rpend || m.blocked || !(m.delay > 0)) return; if (!best || m.delay < best.ms) best = { tag: x, name: nodeName(m), ms: m.delay }; }); var autoTag = S.autoTag && S.autoTag !== S.curTag ? S.autoTag : ''; zm.deadNodeDialog({ noun: 'сервер', app: 'Forkozz', name: nodeName(n), blocked: !!n.blocked, pol: servers.node_pol, failText: 'сервисы этой секции не пойдут через VPN', best: best, autoText: autoTag ? 'Включить автовыбор' : '' }).then(function(v) { if (v === 'best') { if (best) pickServer(best.tag, best.name); else if (autoTag) pickServer(autoTag, 'автовыбор'); } else if (v === 'force') pickServer(t, nodeName(n)); }); } function pickChecked(t, n) { if (!n || n.members) { pickServer(t, n ? nodeName(n) : t); return; } if (picking) return; if (n.blocked || n.delay === 0) { srvAskDead(t, n); return; } picking = true; var forSec = (cfg && cfg.sec) || '', bt = zm.busyToast('Проверяем сервер «' + nodeName(n) + '»', 'запрос через сервер — до 15 секунд'); zm.forkopAction('probe', forSec + '|' + t).then(function(r) { picking = false; bt.close(); if (!r || r.error || r.alive == null) { pickServer(t, nodeName(n)); return; } if (r.alive) { n.delay = r.delay; n.blocked = false; pickServer(t, nodeName(n)); return; } n.delay = 0; n.blocked = r.tcp > 0; n.tcp = r.tcp; n.rpend = false; renderServers(); srvAskDead(t, n); }).catch(function() { picking = false; bt.close(); pickServer(t, nodeName(n)); }); } function fkLat(n) { if (n.blocked) return E('span', { 'class': 'zm-lat zm-lat-blk', 'title': 'Порт сервера открыт' + (n.tcp > 0 ? ' (TCP ' + n.tcp + ' мс)' : '') + ', но запрос через сервер не прошёл — скорее всего, сервер блокирует провайдер' }, 'порт открыт, не\u00a0пускает'); if (n.rpend && n.tcp > 0) return E('span', { 'class': 'zm-lat zm-lat-none', 'title': 'TCP ' + n.tcp + ' мс — проверяем запросом через сервер' }, n.tcp + ' мс…'); return E('span', { 'class': 'zm-lat ' + (n.delay === 0 ? 'zm-lat-bad' : latClass(n.delay)), 'title': n.tcp > 0 && n.delay > 0 ? 'Запрос через сервер: ' + n.delay + ' мс · TCP: ' + n.tcp + ' мс' : '' }, latText(n.delay)); } 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; var direct = !!(servers.fallback && servers.fallback.direct); if (direct) srvCard.appendChild(E('p', { 'class': 'zm-hint' }, 'VPN секции недоступны — её сервисы, домены и адреса временно идут напрямую. Forkozz проверяет серверы раз в 2 минуты и вернёт VPN после восстановления.')); if (sub) srvCard.appendChild(zm.subMeta({ title: subName(sub.title) || 'Подписка', used: sub.used, total: sub.total, unlimited: sub.unlimited, expire: sub.expire, updated: sub.updated, count: { label: 'Серверов', n: manual.length, all: S.list.filter(function(t) { return t !== autoTag; }).length }, fmt: sub.fmt })); if (!direct && servers.home) srvCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show zm-home-banner', 'role': 'note' }, [ E('span', {}, [ 'Сервер «', String(servers.home), '» не отвечал — Forkozz перешёл на ' + (isAuto ? 'автовыбор' : '«' + nodeName(cur) + '»') + ' и вернётся на него, когда он ответит (проверка раз в 10 минут).' ]), E('span', { 'class': 'zm-banner-acts' }, [ ns[servers.home_tag] ? E('button', { 'class': 'cbi-button cbi-button-action', 'click': function() { pickChecked(servers.home_tag, ns[servers.home_tag]); } }, 'Вернуть сейчас') : E([]), E('button', { 'class': 'cbi-button', 'click': function() { zm.forkopAction('home_forget', (cfg && cfg.sec) || '').then(function(res) { if (!res || res.error) { zm.toast((res && res.error) || 'Роутер не ответил', 'error'); return; } servers.home = ''; servers.home_tag = ''; zm.toast('Возвращаться не будем — остаёмся на текущем сервере', 'info'); renderServers(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } }, 'Не возвращаться') ]) ])); else if (!direct && 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', {}, [ direct ? 'Напрямую' : curTag ? nodeName(cur) : '—' ]), !direct && cur.type ? E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, [ String(cur.type).toLowerCase() ]) : E([]), !direct && cur.delay >= 0 ? fkLat(cur) : E([]) ]))); var tags = (isAuto ? serverMembers(auto.members || [], ns) : 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 && !latBusy) { var rank = function(t) { var d = ns[t] ? ns[t].delay : -1; return ns[t] && ns[t].rpend ? 1e8 : d > 0 ? d : ns[t] && ns[t].blocked ? 1e9 : d === 0 ? 1e9 + 1 : 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) pickChecked(t, ns[t] || n); }, fkLat(n), n.delay === 0 ? 'zm-node-dead' + (n.blocked ? ' zm-node-blk' : '') : ''); }))); var lt = tags.filter(function(t) { return ns[t] && !ns[t].members; }), lw = lt.filter(function(t) { return ns[t].delay !== -2; }).length; var acts = [ E('button', { 'class': 'cbi-button', 'disabled': latBusy ? '' : null, 'click': testLatency }, latBusy ? (lt.length ? 'Проверяем ' + lw + ' из ' + lt.length : 'Проверяем…') : 'Проверить задержку') ]; 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(); })); var lm = servers.lat_mode === 'fast' ? 'fast' : 'real'; srvCard.appendChild(row('Проверка серверов', seg([ { id: 'fast', label: 'Только TCP' }, { id: 'real', label: 'Через сервер' } ], lm, function(v) { if (v === lm || latBusy) { if (latBusy) zm.toast('Дождитесь окончания проверки', 'warning'); return; } zm.forkopAction('latmode', v).then(function(res) { if (!res || res.error) { zm.toast((res && res.error) || 'Роутер не ответил', 'error'); return; } servers.lat_mode = res.mode; zm.toast(res.mode === 'fast' ? 'Серверы проверяются только по TCP' : 'Серверы проверяются запросом через сервер', 'info'); renderServers(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); }))); srvCard.appendChild(E('p', { 'class': 'zm-hint' }, lm === 'fast' ? 'Только TCP: открывается ли порт сервера. Быстро и почти не нагружает роутер, но сервер, который блокирует провайдер, может показывать хороший пинг. Подходит для слабых роутеров и больших подписок.' : 'Сначала проверяется, открыт ли порт сервера (TCP), затем — настоящий запрос через него; цифра — задержка. «Порт открыт, не пускает» — сервер отвечает, но трафик не проходит (обычно блокирует провайдер). По этой проверке выбирает сервер и «Авто по пингу».')); if (manual.length > 1) { var np = servers.node_pol === 'switch' || servers.node_pol === 'hold' ? servers.node_pol : 'back'; srvCard.appendChild(row('Если сервер не отвечает', seg([ { id: 'back', label: 'Перейти и вернуться' }, { id: 'switch', label: 'Перейти на рабочий' }, { id: 'hold', label: 'Держать выбранный' } ], np, function(v) { zm.forkopAction('nodepol', v).then(function(res) { if (!res || res.error) { zm.toast((res && res.error) || 'Роутер не ответил', 'error'); return; } servers.node_pol = res.pol; if (res.pol !== 'back') { servers.home = ''; servers.home_tag = ''; } zm.toast(res.pol === 'hold' ? 'Выбранный сервер держим, даже если он не отвечает' : res.pol === 'switch' ? 'Если выбранный сервер не отвечает — переходим на рабочий' : 'Если выбранный сервер не отвечает — переходим на рабочий и возвращаемся', 'info'); renderServers(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); }))); srvCard.appendChild(E('p', { 'class': 'zm-hint' }, np === 'hold' ? 'Forkozz остаётся на выбранном вручную сервере, даже если тот не отвечает, — пока вы не выберете другой. Для серверов, которые ненадолго пропадают.' : np === 'switch' ? 'Если выбранный вручную сервер перестал отвечать, Forkozz через 2–4 минуты переходит на рабочий и остаётся на нём.' : 'Если выбранный вручную сервер перестал отвечать, Forkozz через 2–4 минуты переходит на рабочий, а выбранный проверяет раз в 10 минут и возвращается на него, как только он ответит. Перед выбором сервер проверяется.')); } srvCard.appendChild(E('p', { 'class': 'zm-hint' }, isAuto ? servers.priority ? 'Авто: используется первая доступная подписка в порядке ссылок. После её восстановления Forkozz возвращается к ней, даже если резервная быстрее. Нажатие на сервер включает ручной выбор.' : servers.ping ? 'Авто по пингу: включён самый быстрый сервер. Если он оборвётся, Forkozz за 20–40 секунд перейдёт на самый быстрый из рабочих. Нажмите на сервер, чтобы закрепить его.' : 'Авто: Forkozz сам выбирает лучший из этих серверов. Нажмите на сервер, чтобы закрепить его.' : servers.priority ? 'Нажмите на сервер, чтобы переключиться. Чтобы снова использовать автоматический порядок подписок, выберите «Авто».' : 'Нажмите на сервер, чтобы переключиться. Выбор держится до перезагрузки роутера.')); 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(); }, 1200); zm.catalogWatch(c, function() { reload(); }); }); } }, 'Обновить каталог')); if (!c.off) b.push(E('button', { 'class': 'cbi-button', 'click': function() { zm.askText({ title: 'Свой каталог списков', intro: 'Ссылка на lists.json в формате splify2-lists — например, из форка каталога.', placeholder: 'https://…/lists.json', hint: 'Оставьте поле пустым, чтобы вернуть каталог по умолчанию.', value: c.custom ? c.url : '', okText: 'Подключить' }).then(function(u) { if (u === null) return; zm.toast('Скачиваем каталог…', 'info'); call('catalog_src', u.trim(), 'Каталог подключён', function() { reload(); }); }); } }, 'Сменить источник')); b.push(E('button', { 'class': 'cbi-button', 'click': function() { var turnOn = !!c.off; if (turnOn) zm.toast('Включаем каталог списков — скачиваем его…', 'info'); call('catalog_src', turnOn ? 'on' : 'off', turnOn ? 'Каталог списков включён' : 'Каталог списков выключен — остались только встроенные сервисы', 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, draft.full, 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; if (!deviceScopesOverlap(draft.full, s.full || [])) 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 (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; } var domOpen = null, domData = {}, domCards = {}, domPanelEl = null, domFlags = null, domFlagsBusy = false, DOM_SHOW = 500, DOM_PART = 4000; function domItem(id) { var r = null; (draft.items || []).forEach(function(x) { if (x.id === id) r = x; }); return r; } function domWord(n) { return plural(n, 'домен', 'домена', 'доменов'); } function domNum(n) { return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ' '); } function domFlagsLoad() { if (domFlags || domFlagsBusy) return; domFlagsBusy = true; zm.steerAction('svc_domflags', '').then(function(r) { domFlagsBusy = false; if (!r || r.error) return; domFlags = r; if (document.body.contains(svcCard)) renderSvc(); }).catch(function() { domFlagsBusy = false; }); } function domIcon(s) { if (!domFlags || !domFlags[s.id]) return null; var b = E('button', { 'type': 'button', 'class': 'zm-svc-dom' + (domOpen === s.id ? ' zm-active' : ''), 'title': 'Домены списка', 'aria-label': 'Домены списка «' + s.name + '»', 'click': function(ev) { ev.stopPropagation(); ev.preventDefault(); domToggle(s.id); } }, [ E('i'), E('i'), E('i') ]); return b; } function domLoad(id) { zm.steerAction('svc_dom', id + ':0:' + DOM_SHOW).then(function(r) { if (r.error) { domData[id] = { error: String(r.error) }; domPlace(); return; } var n = parseInt(r.count, 10) || 0; domData[id] = { count: n, ips: parseInt(r.ips, 10) || 0, src: String(r.src || ''), list: String(r.domains || '').split(/\s+/).filter(function(x) { return x; }) }; if (!n) { var it = domItem(id); if (domFlags) domFlags[id] = false; if (domOpen === id) domOpen = null; zm.toast('В списке «' + (it ? it.name : id) + '» только IP-подсети' + (domData[id].ips ? ' (' + domNum(domData[id].ips) + ')' : '') + ' — доменов нет', 'info'); renderSvc(); return; } domPlace(); }).catch(function() { domData[id] = { error: 'Роутер не ответил' }; domPlace(); }); } function domToggle(id) { domOpen = domOpen === id ? null : id; if (domOpen && domData[domOpen] && domData[domOpen].error) delete domData[domOpen]; domPlace(); } function domDownload(id) { var d = domData[id]; if (!d || d.dl) return; d.dl = true; domPlace(); var parts = []; function done(err) { d.dl = false; domPlace(); if (err) zm.toast(err, 'error'); } var ips = [], ipTotal = 0; function save() { var name = ((domItem(id) || {}).name || id), head = '# ' + name + ': ' + domNum(d.count) + ' ' + domWord(d.count) + (ipTotal ? ', ' + domNum(ipTotal) + ' ' + plural(ipTotal, 'IP-подсеть', 'IP-подсети', 'IP-подсетей') : ''); var text = head + '\n\n# Домены (вместе с поддоменами)\n' + parts.join('\n') + '\n' + (ips.length ? '\n# IP-подсети\n' + ips.join('\n') + '\n' : ''); var url = URL.createObjectURL(new Blob([ text ], { type: 'text/plain;charset=utf-8' })); var a = E('a', { 'href': url, 'download': (ips.length ? 'list-' : 'domains-') + id + '.txt', 'style': 'display:none' }); document.body.appendChild(a); a.click(); setTimeout(function() { URL.revokeObjectURL(url); if (a.parentNode) a.parentNode.removeChild(a); }, 1500); done(); } function nextIp(off) { zm.steerAction('svc_dom', id + ':' + off + ':' + DOM_PART + ':ip').then(function(r) { if (r.error) { done(String(r.error)); return; } var list = String(r.domains || '').split(/\s+/).filter(function(x) { return x; }); if (list.length) ips = ips.concat(list); ipTotal = parseInt(r.ips, 10) || 0; if (list.length && off + DOM_PART < ipTotal) { nextIp(off + DOM_PART); return; } ipTotal = ips.length; save(); }).catch(function() { done('Роутер не ответил'); }); } function next(off) { zm.steerAction('svc_dom', id + ':' + off + ':' + DOM_PART).then(function(r) { if (r.error) { done(String(r.error)); return; } var list = String(r.domains || '').split(/\s+/).filter(function(x) { return x; }); if (list.length) parts.push(list.join('\n')); var total = parseInt(r.count, 10) || 0; if (list.length && off + DOM_PART < total) { next(off + DOM_PART); return; } if ((parseInt(r.ips, 10) || 0) > 0) nextIp(0); else save(); }).catch(function() { done('Роутер не ответил'); }); } next(0); } function domPanel(id) { var d = domData[id], it = domItem(id) || { name: id }, body = []; var sub = !d ? 'загружаем…' : d.error ? 'не удалось получить список' : domNum(d.count) + ' ' + domWord(d.count) + (d.ips ? ' · ' + domNum(d.ips) + ' ' + plural(d.ips, 'IP-подсеть', 'IP-подсети', 'IP-подсетей') : ''); var subTip = d && !d.error ? 'Домены действуют вместе с поддоменами' + (d.ips ? '; IP-подсети — адреса и диапазоны, которые идут тем же путём' : '') + (d.src ? '. Источник: ' + d.src : '') : ''; var btns = []; if (d && d.count) btns.push(E('button', { 'type': 'button', 'class': 'cbi-button', 'title': d.ips ? 'Домены и IP-подсети одним файлом .txt' : 'Все домены списка файлом .txt', 'disabled': d.dl ? '' : null, 'click': function() { domDownload(id); } }, d.dl ? 'Скачиваем…' : d.ips ? 'Скачать список доменов и IP' : 'Скачать список доменов')); btns.push(E('button', { 'type': 'button', 'class': 'cbi-button', 'click': function() { domToggle(id); } }, 'Свернуть')); if (!d) body.push(E('p', { 'class': 'zm-hint zm-dom-more' }, 'Читаем список с роутера…')); else if (d.error) body.push(E('div', { 'class': 'zm-refresh-banner zm-show', 'style': 'margin-top:12px' }, d.error)); else { body.push(E('div', { 'class': 'zm-dom-list' }, d.list.map(function(x) { return E('div', { 'class': 'zm-dom-item' }, x); }))); if (d.count > d.list.length) body.push(E('p', { 'class': 'zm-hint zm-dom-more' }, 'Показаны первые ' + domNum(d.list.length) + ' из ' + domNum(d.count) + '. Весь список' + (d.ips ? ' вместе с IP-подсетями' : '') + ' — по кнопке «' + (d.ips ? 'Скачать список доменов и IP' : 'Скачать список доменов') + '».')); } return E('div', { 'class': 'zm-dom-panel' }, [ E('div', { 'class': 'zm-dom-head' }, [ E('div', { 'class': 'zm-dom-title' }, [ E('b', {}, 'Домены · ' + it.name), E('span', { 'class': 'zm-dom-sub', 'title': subTip || null }, sub) ]), E('div', { 'class': 'zm-dom-btns' }, btns) ]) ].concat(body)); } function domPlace() { if (domPanelEl && domPanelEl.parentNode) domPanelEl.parentNode.removeChild(domPanelEl); domPanelEl = null; Object.keys(domCards).forEach(function(k) { var b = domCards[k] && domCards[k].querySelector('.zm-svc-dom'); if (b) b.classList.toggle('zm-active', k === domOpen); }); var card = domOpen && domCards[domOpen]; if (!card || !card.parentNode || !document.body.contains(card)) return; if (domData[domOpen] === undefined) { domData[domOpen] = null; domLoad(domOpen); } domPanelEl = domPanel(domOpen); var n = card, top = card.offsetTop; while (n.nextElementSibling && n.nextElementSibling.offsetTop === top) n = n.nextElementSibling; card.parentNode.insertBefore(domPanelEl, n.nextSibling); } var domResizeT = null; window.addEventListener('resize', function() { if (!domOpen) return; clearTimeout(domResizeT); domResizeT = setTimeout(domPlace, 150); }); var findEl = null, findQ = '', findRes = null, findBusy = false, findT = null, findRetryT = null, findSeq = 0, findTries = 0; function findSvg(cls, svg) { var el = E('span', { 'class': cls, 'aria-hidden': 'true' }); el.innerHTML = svg; return el; } var FIND_LOUPE = ''; var FIND_CROSS = ''; function findKey(ev) { if (ev.key === 'Escape' && findEl) { ev.preventDefault(); findClose(); } } function findClose() { clearTimeout(findT); clearTimeout(findRetryT); document.removeEventListener('keydown', findKey); if (findEl && findEl.parentNode) findEl.parentNode.removeChild(findEl); findEl = null; } function findOpen() { if (findEl) return; var input = E('input', { 'type': 'text', 'class': 'zm-find-input', 'placeholder': 'Сайт или IP, например fast.com или 1.2.3.4', 'aria-label': 'Сайт или IP-адрес', 'autocomplete': 'off', 'autocapitalize': 'off', 'spellcheck': 'false', 'enterkeyhint': 'search' }); input.value = findQ; var body = E('div', { 'class': 'zm-find-body' }); var box = E('div', { 'class': 'zm-find zmw-modal', 'role': 'dialog', 'aria-modal': 'true', 'aria-label': 'Найти сайт или IP в списках' }, [ E('div', { 'class': 'zm-find-field' }, [ findSvg('zm-find-ico', FIND_LOUPE), input, E('button', { 'type': 'button', 'class': 'zm-find-x', 'title': 'Закрыть', 'aria-label': 'Закрыть', 'click': findClose }, [ findSvg('zm-find-x-ico', FIND_CROSS) ]) ]), body ]); findEl = E('div', { 'class': 'zm-find-wrap', 'click': function(ev) { if (ev.target === findEl) findClose(); } }, [ box ]); findEl._body = body; input.addEventListener('input', function() { findQ = input.value; clearTimeout(findT); findT = setTimeout(function() { findRun(false); }, 600); }); input.addEventListener('keydown', function(ev) { if (ev.key === 'Enter') { ev.preventDefault(); clearTimeout(findT); findRun(false); } }); document.body.appendChild(findEl); document.addEventListener('keydown', findKey); findDraw(); setTimeout(function() { try { input.focus(); input.select(); } catch (e) {} }, 0); } function findRun(auto) { var q = String(findQ || '').trim(); clearTimeout(findRetryT); if (!q) { findSeq++; findRes = null; findBusy = false; findDraw(); return; } if (!auto) findTries = 0; var seq = ++findSeq; if (!auto) { findBusy = true; findDraw(); } zm.steerAction('svc_find', q).then(function(r) { if (seq !== findSeq) return; findBusy = false; findRes = r || {}; findDraw(); if (!findRes.error && (parseInt(findRes.missing, 10) || 0) > 0 && findTries < 8 && findEl) { findTries++; findRetryT = setTimeout(function() { if (findEl && seq === findSeq) findRun(true); }, 5000); } }).catch(function() { if (seq !== findSeq) return; findBusy = false; findRes = { error: 'Роутер не ответил' }; findDraw(); }); } function findToggle(id) { if (busy || saving) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } var it = domItem(id); if (!it) return; var el = svcTile(it); if (el && el.click) el.click(); findDraw(); } function findOwnHas(r) { var h = String(r.host || ''), ip = String(r.ip || ''); if (h) { var hit = tokLines(draft.domains).some(function(d) { d = String(d).toLowerCase().replace(/^(domain:|full:)/, '').replace(/^(\*\.|\.)/, ''); return d && (h === d || (h.length > d.length && h.slice(-(d.length + 1)) === '.' + d)); }); if (hit) return true; } return !h && !!ip && tokLines(draft.subnets).indexOf(ip) >= 0; } function findAddOwn(r) { if (busy || saving) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } var h = String(r.host || ''), ip = String(r.ip || r.q || ''); if (h) { var dl = String(draft.domains || '').replace(/\s+$/, ''); draft.domains = (dl ? dl + '\n' : '') + h; taDomains.value = draft.domains; } else { var sl = String(draft.subnets || '').replace(/\s+$/, ''); draft.subnets = (sl ? sl + '\n' : '') + ip; taSubnets.value = draft.subnets; } draft.own_on = true; touch(); renderPanes(); zm.toast((h || ip) + ' добавлен в «Свой список» секции «' + secName() + '» — сохраните изменения', 'info'); findDraw(); } function findDraw() { if (!findEl) return; var body = findEl._body, q = String(findQ || '').trim(), r = findRes, out = []; body.innerHTML = ''; if (!q) { out.push(E('div', { 'class': 'zm-find-empty' }, [ E('b', {}, 'Какой сайт или IP нужен?'), E('span', {}, 'Впишите сайт или IPv4-адрес — покажем, какой пункт включить, чтобы он шёл через VPN.') ])); } else if (findBusy || !r) { out.push(E('div', { 'class': 'zm-find-empty' }, [ E('span', { 'class': 'zm-find-spin', 'aria-hidden': 'true' }), E('span', {}, 'Ищем в списках…') ])); } else if (r.error) { out.push(E('div', { 'class': 'zm-find-empty zm-find-err' }, [ E('span', {}, String(r.error)) ])); } else { var sel = draft.sel || {}, taken = takenMap(); var hits = (r.hits || []).filter(function(h) { return h.id !== 'custom' && !!domItem(h.id); }); var domOnly = hits.filter(function(h) { return h.by === 'dom'; }); if (domOnly.length) hits = domOnly; var WIDE_IDS = { cloudflare: 1, c_itdoginfo_cloudflare: 1, cloudfront: 1, c_itdoginfo_cloudfront: 1, digitalocean: 1, hetzner: 1, ovh: 1, discord: 1 }; function findWide(h) { var m = /\/(\d+)$/.exec(String(h.line || '')); return h.by === 'ip' && (!!WIDE_IDS[h.id] || (m && +m[1] <= 16)); } var inOwn = findOwnHas(r), many = secs().length > 1 || draft.sec === 'new'; hits.sort(function(a, b) { return (a.by === b.by ? 0 : a.by === 'dom' ? -1 : 1) || ((+a.size || 0) - (+b.size || 0)); }); var best = null; hits.forEach(function(h) { if (findWide(h)) return; if (!best || (best.by !== 'dom' && h.by === 'dom') || (best.by === h.by && (+h.size || 0) < (+best.size || 0))) best = h; }); var anyOn = inOwn || hits.some(function(h) { return !!sel[h.id] || (!!sel.russia_inside && h.id !== 'russia_inside' && !h.group && zm.riCovers(h.id)); }); var isIp = !!r.ip && !r.host; if (hits.length) { out.push(E('div', { 'class': 'zm-find-title' }, anyOn ? (isIp ? 'Уже включено — адрес ' + r.ip + ' идёт через VPN' : 'Уже включено — сайт идёт через VPN') : many ? (isIp ? 'Адрес ' + r.ip + ' есть в подсетях — включите любой пункт в секции «' : 'Включите любой в секции «') + secName() + '»' : (isIp ? 'Адрес ' + r.ip + ' есть в подсетях — включите любой пункт' : 'Включите любой — сайт пойдёт через VPN'))); var listEl = E('div', { 'class': 'zm-find-list' }); hits.forEach(function(h) { var on = !!sel[h.id], inc = !on && !!sel.russia_inside && h.id !== 'russia_inside' && !h.group && zm.riCovers(h.id); var by = !on ? taken[h.id] : ''; var size = +h.size || 0; var why = by ? 'уже идёт через секцию «' + by + '»' : inc ? 'входит во «Всё сразу» — уже идёт через VPN' : (h.by === 'dom' ? 'в списке есть ' + h.line : findWide(h) ? (isIp ? 'общая подсеть ' + h.line : 'общий адрес ' + (r.ip || '') + ' (' + h.line + ')') + ' — через VPN пойдут и другие сайты' : (isIp ? 'в подсети ' + h.line : 'адрес ' + (r.ip || '') + ' в подсети ' + h.line)) + (h.group ? ' · ' + String(h.group).replace(/\s*\(.*\)\s*$/, '') : '') + (size > 20000 ? ' · большой список' : ''); var ic = zm.svcIco ? zm.svcIco(h.name, h.id) : null; listEl.appendChild(E('div', { 'class': 'zm-find-row' + (on || inc ? ' zm-on' : '') + (by ? ' zm-find-taken' : ''), 'role': 'button', 'tabindex': '0', 'click': function() { findToggle(h.id); }, 'keydown': function(ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); findToggle(h.id); } } }, [ E('div', { 'class': 'zm-svc-ico', 'style': ic ? 'background:' + ic.color : '' }, ic ? ic.ico : String(h.name || '?').slice(0, 2).toUpperCase()), E('div', { 'class': 'zm-find-text' }, [ E('div', { 'class': 'zm-find-name' }, [ String((domItem(h.id) || h).name), best === h && hits.length > 1 && !anyOn ? E('span', { 'class': 'zm-find-best' }, 'лучший выбор') : '' ]), E('div', { 'class': 'zm-find-why' }, why) ]), E('span', { 'class': 'zm-find-tag zm-find-tag-' + (h.by === 'dom' ? 'dom' : 'ip') }, h.by === 'dom' ? 'домен' : 'IP-адрес'), E('div', { 'class': 'zm-switch' + (on || inc ? ' zm-switch-on' : '') + (inc ? ' zm-switch-inc' : '') }, [ E('span') ]) ])); }); out.push(listEl); } else { out.push(E('div', { 'class': 'zm-find-empty' }, [ E('b', {}, inOwn ? (isIp ? 'Адрес уже в «Своём списке»' : 'Сайт уже в «Своём списке»') : isIp ? 'Адреса ' + r.ip + ' нет в подсетях списков' : 'Сайта нет ни в одном списке'), E('span', {}, inOwn ? 'Он идёт через VPN' + (many ? ' секции «' + secName() + '»' : '') + (draft.own_on === false ? ', когда «Свой список» включён' : '') + '.' : isIp ? 'Пункты из одних доменов по IP не проверить — попробуйте найти сайт по имени или добавьте адрес в «Свой список».' : 'Добавьте ' + (r.host || 'адрес') + ' в «Свой список» — он пойдёт через VPN.') ])); } if ((parseInt(r.missing, 10) || 0) > 0) out.push(E('div', { 'class': 'zm-find-note' }, [ E('span', { 'class': 'zm-find-spin', 'aria-hidden': 'true' }), 'Докачиваем ещё ' + r.missing + ' ' + plural(+r.missing, 'список', 'списка', 'списков') + ' — результаты обновятся сами' ])); out.push(E('div', { 'class': 'zm-find-foot' }, [ E('button', { 'type': 'button', 'class': 'cbi-button' + (inOwn ? '' : ' cbi-button-action'), 'disabled': inOwn || busy || saving ? '' : null, 'click': function() { findAddOwn(r); } }, inOwn ? 'Уже в «Своём списке»' : 'Добавить в «Свой список»') ])); } out.forEach(function(n) { body.appendChild(n); }); } document.addEventListener('keydown', function(ev) { if (!(ev.ctrlKey || ev.metaKey) || ev.altKey || ev.shiftKey || String(ev.key).toLowerCase() !== 'k' && ev.code !== 'KeyK') return; if (!svcCard || !document.body.contains(svcCard) || svcCard.offsetParent === null) return; ev.preventDefault(); if (findEl) findClose(); else findOpen(); }); 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 domKeep(s, zm.svcCard({ name: s.name, key: s.id, on: false, sub: 'входит в «Всё сразу» секции «' + ra + '»', cls: 'zm-svc-taken', extra: domIcon(s), title: s.name + ' уже идёт через «Всё сразу» секции «' + ra + '»', click: function() { zm.toast('«' + s.name + '» входит в «Всё сразу» секции «' + ra + '». Чтобы пустить «' + s.name + '» здесь, выключите «Всё сразу» там.', 'warning'); } })); if (by && !draft.sel[s.id]) return domKeep(s, zm.svcCard({ name: s.name, key: s.id, on: false, sub: 'в секции «' + by + '»', cls: 'zm-svc-taken', extra: domIcon(s), title: s.name + ' уже идёт через секцию «' + by + '»', click: function() { zm.toast('«' + s.name + '» уже идёт через секцию «' + by + '». Сначала выключите его там — один сервис работает только в одной секции.', 'warning'); } })); return domKeep(s, 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), extra: domIcon(s), click: function() { var nsel = {}; for (var k in draft.sel) if (draft.sel[k]) nsel[k] = true; zm.svcPickToggle(nsel, s.id); set('sel', nsel); } })); } function domKeep(s, el) { if (domFlags && domFlags[s.id]) domCards[s.id] = el; return el; } 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 ((draft.full || []).length) svcCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show', 'role': 'note', 'style': 'margin:0 0 12px' }, [ E('span', {}, '⚠ Сервисы ' + (many ? 'секции «' + secName() + '»' : 'ниже') + ' идут через VPN только с ' + nn(draft.full.length, 'устройства', 'устройств', 'устройств') + ' (вкладка «Устройства» → «Только для этих устройств»). Остальные устройства открывают их напрямую.'), E('button', { 'class': 'cbi-button', 'click': function() { tabTo('dev'); } }, 'Показать'), E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { set('full', []); zm.toast('Ограничение снято — сохраните, и сервисы пойдут через VPN со всех устройств', 'info'); } }, 'Для всех устройств') ])); 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, 'сервер', 'сервера', 'серверов')), '. Сервисы, выбранные в других секциях, помечены их названием.' ])); var remote = list.filter(function(s) { return !!s.group; }); var svcs = list.filter(function(s) { return !s.group && !s.cat && CATEGORY_IDS.indexOf(s.id) < 0; }); var cats = list.filter(function(s) { return !s.group && (s.cat || CATEGORY_IDS.indexOf(s.id) >= 0); }); domCards = {}; domFlagsLoad(); svcCard.appendChild(E('div', { 'class': 'zm-find-head' }, [ E('h4', { 'style': 'margin:0' }, 'Сервисы'), E('button', { 'type': 'button', 'class': 'zm-find-open', 'title': 'Найти сайт или IP-адрес в списках (Ctrl+K)', 'aria-label': 'Найти сайт или IP-адрес в списках', 'click': findOpen }, [ findSvg('zm-find-ico', FIND_LOUPE), 'Найти сайт или IP' ]) ])); 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()); if (domOpen && !domCards[domOpen]) domOpen = null; domPlace(); if (findEl) findDraw(); 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 = ''; var ownOn = draft.own_on !== false; var ownCnt = tokLines(draft.domains).length + tokLines(draft.subnets).length + textLines(draft.lists).length; ownCard.appendChild(E('h3', {}, [ many ? 'Свой список · «' + secName() + '» ' : 'Свой список ', !ownCnt ? badge('zm-off', 'пуст') : ownOn ? badge('zm-ok', 'включён · ' + nn(ownCnt, 'строка', 'строки', 'строк')) : badge('zm-warn', 'выключен · ' + nn(ownCnt, 'строка', 'строки', 'строк')) ])); ownCard.appendChild(zm.swRow(ownOn, 'Вести свой список через VPN', ownOn ? 'Свои домены, адреса и внешние списки идут через подключение секции — так же, как выбранные сервисы.' : ownCnt ? 'Выключено: свои домены, адреса и внешние списки сохранены, но идут напрямую. Включите — и они снова пойдут через VPN.' : 'По умолчанию выключено. Впишите ниже домены, адреса или ссылки на списки и включите — они пойдут через VPN.', function() { set('own_on', !ownOn); })); var ownDim = ownOn ? '' : 'opacity:.55'; [ taDomains, taSubnets, taLists ].forEach(function(t) { t.style.opacity = ownOn ? '' : '.55'; }); ownCard.appendChild(E('h4', { 'style': 'margin:4px 0 8px;' + ownDim }, 'Домены')); ownCard.appendChild(taDomains); ownCard.appendChild(E('p', { 'class': 'zm-hint' }, CAPS.prefixes ? 'Один на строку: поддомены включаются сами (example.com — это и www.example.com). Только сам домен, без поддоменов — full:example.com; всё, где в имени есть слово, — keyword:tracker; шаблон — regex:^ad[0-9]+\\.example\\.com$. Русские домены — в punycode.' : 'Один на строку, домен целиком: поддомены включаются сами (example.com — это и www.example.com). keyword: и regex: Forkozz не понимает; русские домены — в 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-кэш или перезапустите браузер. Проверить, куда пойдёт сайт, можно в «Куда пойдёт запрос» на вкладке «Подключение».')); var legacy = (cfg && cfg.legacy_bypass) || []; if (legacy.length) bypCard.appendChild(E('h4', { 'style': 'margin:18px 0 4px' }, 'Исключения, созданные раньше')); legacy.forEach(function(x) { var details = E('details', { 'style': 'margin:8px 0' }, [ E('summary', { 'style': 'cursor:pointer' }, x.label + (x.label !== x.name ? ' (' + x.name + ')' : '') + ' — ' + (x.enabled ? 'включена' : 'выключена')), E('p', { 'class': 'zm-hint' }, 'Секция исключений, созданная раньше. Работает как прежде. Здесь только просмотр: поля выше — отдельные исключения Forkozz, эту секцию они не меняют.') ]); [ [ 'Домены', x.domains ], [ 'IP-адреса и подсети', x.subnets ], [ 'Community-списки', x.community ], [ 'Локальные и удалённые списки', x.lists ] ].forEach(function(item) { if (!(item[1] || []).length) return; details.appendChild(E('h4', { 'style': 'margin:12px 0 0' }, item[0])); var ta = E('textarea', { 'class': 'zm-config-editor', 'readonly': '', 'spellcheck': 'false', 'rows': Math.min(8, item[1].length), 'style': 'min-height:' + (30 + 21 * Math.min(8, item[1].length)) + 'px', 'aria-label': item[0] + ' — ' + x.name }); ta.value = item[1].join('\n'); details.appendChild(ta); }); bypCard.appendChild(details); }); } function devCard(card, key, title, hint) { card.innerHTML = ''; var list = draft[key], devs = (cfg && cfg.devices) || [], known = {}, assigned = {}, akey = key === 'all' ? 'all' : 'full', taken = {}; secs().forEach(function(s) { if (key === 'all' && s.name !== draft.sec) (s.all || []).forEach(function(ip) { taken[ip] = s.label; }); (s.name === draft.sec ? draft[akey] : s[akey] || []).forEach(function(ip) { if (!assigned[ip]) assigned[ip] = []; assigned[ip].push(s.label); }); }); card.appendChild(E('h3', {}, [ title + ' ', list.length ? badge(key === 'excl' ? 'zm-warn' : 'zm-ok', 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 && taken[ip]) { zm.toast('Это устройство уже целиком идёт через секцию «' + taken[ip] + '». Сначала уберите его там — весь трафик устройства можно отдать только одной секции.', 'warning'); return; } if (i >= 0) l.splice(i, 1); else l.push(ip); set(key, l); } var grid = E('div', { 'class': 'zm-nodes' }); function foot(ip, base) { var labels = assigned[ip] || []; return base + (labels.length ? ' · ' + labels.join(', ') : '') + (draft.excl.indexOf(ip) >= 0 && key !== 'excl' ? ' · мимо Forkozz' : ''); } devs.forEach(function(d) { known[d.ip] = true; grid.appendChild(node(d.name || d.ip, foot(d.ip, d.name ? d.ip : 'без имени'), list.indexOf(d.ip) >= 0, function() { toggle(d.ip); })); }); list.concat(Object.keys(assigned)).forEach(function(ip) { if (known[ip]) return; known[ip] = true; grid.appendChild(node(ip, foot(ip, 'добавлен вручную'), list.indexOf(ip) >= 0, function() { toggle(ip); })); }); if (!grid.childNodes.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(); var m = v.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/); if (!m || m.slice(1, 5).some(function(x) { return +x > 255; }) || m[5] != null && +m[5] > 32) { zm.toast('Введите IP-адрес или подсеть, например 192.168.1.100 или 192.168.1.0/24', '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(allCard, 'all', 'Весь трафик устройства через VPN · «' + secName() + '»', 'Весь трафик выбранных устройств идёт через подключение этой секции, а не только выбранные сервисы. Если подключение недоступно — интернета у них не будет. Одно устройство — только в одной секции.'); if (!draft.isMain && !draft.enabled) allCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Секция выключена: устройства из этого списка ходят как обычно.')); devCard(fullCard, 'full', 'Только для этих устройств · «' + secName() + '»', 'Ограничение для сервисов секции: если выбрать устройства, её сервисы, домены и адреса пойдут через VPN только с них, остальные устройства — напрямую. Ничего не выбрано — сервисы секции идут через VPN со всех устройств. На список «Весь трафик» выше это не влияет.'); if (!draft.isMain && !draft.enabled) fullCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Секция выключена: её правила не действуют. Выбор устройств в остальных секциях сохраняется.')); devCard(exclCard, 'excl', 'Мимо 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') v = dohOwn(v); 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(); })); if (CAPS.hijack) dnsCard.appendChild(sw(draft.hijack, 'Перехватывать DNS устройств', 'Устройства, где DNS прописан вручную (например, 8.8.8.8), всё равно будут спрашивать адреса у роутера — иначе выбранные сервисы на них идут мимо Forkozz. Зашифрованный DNS это не трогает — для него переключатель ниже.' + (draft.no_dhcp ? ' ⚠ Сейчас включено «Не менять настройки DNS роутера» — перехват почти ничего не даст.' : ''), function() { set('hijack', !draft.hijack); })); dnsCard.appendChild(sw(draft.block_doh, 'Не пускать приложения в обход DNS роутера', 'Прямые запросы к публичным DoH (Cloudflare, Google, Quad9, AdGuard, Яндекс и другим) будут закрыты — браузеры и приложения вернутся к DNS роутера, и их сайты пойдут по правилам Forkozz.', function() { set('block_doh', !draft.block_doh); })); var ds = [], fz = CAPS.fwd ? textLines(draft.fwd).length : 0; if (draft.dns_ttl.trim() !== '60') ds.push('кэш ' + draft.dns_ttl.trim() + ' с'); if (CAPS.ecs_auto && draft.ecs_auto) ds.push('ECS авто'); else if (draft.dns_ecs.trim()) ds.push('ECS ' + draft.dns_ecs.trim()); if (fz) ds.push(nn(fz, 'зона', 'зоны', 'зон') + ' мимо Forkozz'); dnsCard.appendChild(fold('dns', 'Дополнительно', ds.join(' · '), function(box) { box.appendChild(row('Время жизни записи', inp('dns_ttl', '60', 80, 'секунд'))); box.appendChild(hint('Сколько устройства помнят адрес сайта, полученный от Forkozz. Меньше — быстрее подхватываются смены адресов, больше — меньше запросов. Обычно 60.')); box.appendChild(row('EDNS Client Subnet', inp('dns_ecs', CAPS.ecs_auto && draft.ecs_auto ? 'запасная, если не определится' : '203.0.113.0/24', 220))); box.appendChild(hint('Подсеть, которую DNS-сервер получит вместе с запросом, чтобы выдать ближайший к ней узел CDN. Обычно оставляют пустым.')); if (CAPS.ecs_auto) box.appendChild(sw(draft.ecs_auto, 'Определять подсеть сама', 'Forkozz возьмёт внешний адрес роутера — даже за NAT провайдера — и запомнит его. Поле выше тогда нужно только на случай, если адрес не определится.', function() { set('ecs_auto', !draft.ecs_auto); })); if (CAPS.fwd) { box.appendChild(E('h4', { 'style': 'margin:16px 0 4px' }, 'Пересылка по зонам')); box.appendChild(hint('Домены этих зон роутер спросит у указанного DNS напрямую, мимо Forkozz. По строке на зону: «зона сервер». Через VPN по доменам такие сайты не пойдут.', 0)); var fta = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false', 'autocapitalize': 'off', 'rows': '3', 'placeholder': 'ru 77.88.8.8\nlan 192.168.1.1', 'style': 'min-height:76px' }); fta.value = draft.fwd; fta.addEventListener('input', function() { draft.fwd = fta.value; touch(); }); box.appendChild(fta); } })); } function renderNet() { var ni = netInfo(), devs = ni.devs || []; var isWan = function(d) { return /^(wan|wan6|wwan)/.test(d.net || '') || d.name === 'wan' || /^pppoe-/.test(d.name); }; netCard.innerHTML = ''; netCard.appendChild(E('h3', {}, 'Сеть')); netCard.appendChild(E('h4', { 'style': 'margin:4px 0' }, 'Откуда принимать трафик')); netCard.appendChild(hint('Устройства из этих сетей идут через Forkozz. Обычно это br-lan — вся домашняя сеть. Гостевую или отдельную сеть можно добавить сюда же. Свой VPN-сервер на роутере (WireGuard, AmneziaWG, OpenVPN) тоже можно отметить — тогда его клиенты пойдут через Forkozz.', 0)); var outIfs = {}; secs().forEach(function(s) { if (s.iface) outIfs[s.iface] = true; }); if (draft.mode === 'iface' && draft.iface) outIfs[draft.iface] = true; (cfg && cfg.tunnels || []).forEach(function(t) { if (t.steer) outIfs[t.name] = true; }); var isSrcTun = function(d) { return d.kind === 'tun' && !outIfs[d.name] && !/^(zmwarp|zm_|zmv_)/.test(d.name); }; var src = withMissing(devs.filter(function(d) { return (d.kind === 'bridge' || d.kind === 'eth' || isSrcTun(d)) && !isWan(d); }), draft.src_ifs); netCard.appendChild(devTiles(src, function(n) { return draft.src_ifs.indexOf(n) >= 0; }, function(n, on) { if (on && draft.src_ifs.length === 1) { zm.toast('Нужна хотя бы одна сеть', 'warning'); return; } set('src_ifs', on ? draft.src_ifs.filter(function(x) { return x !== n; }) : draft.src_ifs.concat([ n ])); })); netCard.appendChild(E('h4', { 'style': 'margin:16px 0 4px' }, 'Выход в интернет')); netCard.appendChild(hint('Через какой интерфейс sing-box выходит к серверам. «Авто» — как сам роутер. Свой нужен, если подключений к интернету несколько (второй провайдер, модем).', 0)); var outs = withMissing(devs.filter(function(d) { return (d.kind === 'eth' || d.kind === 'ppp' || d.kind === 'bridge') && draft.src_ifs.indexOf(d.name) < 0 && d.name !== 'br-lan' && !/^(lan|tun|wg|vpn|awg|oc|zm)/.test(d.name); }), [ draft.out_if ]); var outBox = devTiles(outs, function(n) { return draft.out_if === n; }, function(n, on) { set('out_if', on ? '' : n); }); outBox.insertBefore(node('Авто', 'как у роутера', !draft.out_if, function() { if (draft.out_if) set('out_if', ''); }), outBox.firstChild); netCard.appendChild(outBox); netCard.appendChild(sw(draft.badwan, 'Перезапускать при переподключении интернета', 'Когда выбранное подключение (wan, pppoe, модем) поднимается заново, Forkozz перезапускается сам — помогает при нестабильном провайдере, когда после обрыва VPN не оживает.', function() { if (!draft.badwan && !draft.badwan_ifs.length) draft.badwan_ifs = (ni.nets || []).filter(function(x) { return x.name === 'wan'; }).map(function(x) { return x.name; }); set('badwan', !draft.badwan); })); if (draft.badwan) { var nets = (ni.nets || []).filter(function(x) { return x.name !== 'lan' && x.name !== 'loopback' && x.proto && x.proto !== 'none'; }); var have = {}; nets.forEach(function(x) { have[x.name] = true; }); draft.badwan_ifs.forEach(function(n) { if (!have[n]) nets.push({ name: n, proto: '', dev: '', missing: true }); }); netCard.appendChild(E('div', { 'class': 'zm-nodes zm-nodes-3' }, nets.map(function(x) { var on = draft.badwan_ifs.indexOf(x.name) >= 0; return node(x.name, x.missing ? 'не найдена' : [ x.proto, x.dev ].filter(Boolean).join(' · '), on, function() { set('badwan_ifs', on ? draft.badwan_ifs.filter(function(v) { return v !== x.name; }) : draft.badwan_ifs.concat([ x.name ])); }); }))); netCard.appendChild(row('Пауза перед перезапуском', inp('badwan_delay', '2000', 90, 'мс'))); } netCard.appendChild(sw(draft.ipv6, 'Поддержка IPv6', 'Маршрутизация и DNS для IPv6. Включайте, только если провайдер даёт роутеру рабочий IPv6 — иначе сайты могут открываться дольше.', function() { set('ipv6', !draft.ipv6); })); netCard.appendChild(sw(draft.no_dhcp, 'Не менять настройки DNS роутера', '⚠ Для опытных. Forkozz не будет перенаправлять dnsmasq на себя — тогда это нужно сделать вручную (сервер 127.0.0.42), иначе сайты по доменам пойдут мимо Forkozz.', function() { var zmGo = function() { set('no_dhcp', !draft.no_dhcp); }; if (!draft.no_dhcp) return zm.ask('Forkozz перестанет сам настраивать DNS роутера.\n\nБез ручной настройки dnsmasq сервисы по доменам пойдут напрямую. Продолжить?').then(function(ok) { return ok ? zmGo() : undefined; }); return zmGo(); })); } function renderPanel() { panelCard.innerHTML = ''; var on = draft.yacd, live = cfg && cfg.g && cfg.g.yacd && st.running, url = 'http://' + lanIp() + ':9090/ui/'; panelCard.appendChild(E('h3', {}, [ 'Панель sing-box ', live ? badge('zm-ok', 'включена') : E([]) ])); panelCard.appendChild(sw(on, 'Веб-панель (Zashboard)', 'Соединения, трафик и серверы всех секций в реальном времени, с ручным выбором сервера. Панель скачивается с GitHub после запуска Forkozz; если не получится — останется стандартная панель sing-box.', function() { set('yacd', !on); })); if (live) panelCard.appendChild(row('Адрес', E('a', { 'href': url, 'target': '_blank', 'rel': 'noopener' }, url))); else if (on) panelCard.appendChild(hint('Откроется по адресу ' + url + ' после сохранения.')); if (!on) return; panelCard.appendChild(sw(draft.yacd_wan, 'Доступ из интернета', 'Панель будет доступна и с внешнего адреса роутера — нужен ключ, а порт 9090 откройте в файрволе сами. Без этого панель видна только из домашней сети.', function() { if (!draft.yacd_wan && !draft.yacd_secret.trim()) draft.yacd_secret = genKey(); set('yacd_wan', !draft.yacd_wan); })); if (draft.yacd_wan) panelCard.appendChild(row('Ключ', E('span', { 'style': 'display:inline-flex; gap:8px; flex-wrap:wrap; align-items:center' }, [ inp('yacd_secret', '8–64 символа', 260), E('button', { 'class': 'cbi-button', 'click': function() { set('yacd_secret', genKey()); } }, 'Новый') ]))); } function genKey() { var a = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789', r = '', b = new Uint8Array(20); try { window.crypto.getRandomValues(b); } catch (e) { for (var i = 0; i < 20; i++) b[i] = Math.floor(Math.random() * 256); } for (var j = 0; j < 20; j++) r += a[b[j] % a.length]; return r; } function renderSet() { renderDns(); renderNet(); renderPanel(); miscCard.innerHTML = ''; miscCard.appendChild(E('h3', {}, 'Прочее')); if (CAPS.kill) miscCard.appendChild(sw(draft.kill, 'Защита от утечек', 'Пока Forkozz перезапускается или sing-box упал, сайты выбранных сервисов не откроются вовсе — вместо того чтобы уйти напрямую мимо VPN. Если Forkozz не поднимется, DNS роутера будет ждать его. «Выключить» и удаление защиту снимают.', function() { var zmGo = function() { set('kill', !draft.kill); }; if (!draft.kill) return zm.ask('Включить защиту от утечек?\n\nПока Forkozz не работает (перезапуск, сбой sing-box), трафик выбранных сервисов будет закрыт, а DNS роутера — ждать Forkozz.\n\nЕсли Forkozz не запустится, сайты по именам перестанут открываться, пока его не починить или не нажать «Выключить».').then(function(ok) { return ok ? zmGo() : undefined; }); return zmGo(); })); if (CAPS.quic_all) { var qm = draft.quic_all ? 'all' : draft.quic_off ? 'vpn' : 'off'; miscCard.appendChild(row('Отключить QUIC', seg([ { id: 'off', label: 'Нет' }, { id: 'vpn', label: 'Для VPN' }, { id: 'all', label: 'Везде' } ], qm, function(v) { draft.quic_all = v === 'all'; set('quic_off', v !== 'off'); }))); miscCard.appendChild(hint({ off: 'QUIC (HTTP/3) работает как обычно — и через VPN, и напрямую. Если сервисы через VPN тормозят, выберите «Для VPN».', vpn: 'Сервисы, которые идут через VPN, перейдут с QUIC на обычный HTTPS — через сервер так стабильнее. Остальное не меняется.', all: 'QUIC выключен во всей сети — и через VPN, и напрямую: приложения сразу переходят на обычный HTTPS. Помогает, если какие-то сервисы через раз открываются мимо VPN.' }[qm], 0)); } else miscCard.appendChild(sw(draft.quic_off, 'Отключить QUIC для выбранных сервисов', 'Приложения перейдут на обычный HTTPS — через сервер так стабильнее.', function() { set('quic_off', !draft.quic_off); })); miscCard.appendChild(sw(draft.excl_bt, 'Торренты — напрямую', 'Трафик BitTorrent пойдёт мимо VPN, даже если устройство выбрано в секции. Некоторые сервисы блокируют подписку, заметив торренты.', function() { set('excl_bt', !draft.excl_bt); })); if (draft.excl_bt) { miscCard.appendChild(row('Порт торрент-клиента', inp('bt_ports', '51413', 160, 'необязательно'))); miscCard.appendChild(hint('Зашифрованные соединения и DHT по протоколу не распознать — укажите порт из настроек клиента (qBittorrent: «Соединение»), и они тоже пойдут мимо VPN.', 0)); } miscCard.appendChild(sw(draft.excl_ntp, 'Синхронизация времени — напрямую', 'Запросы NTP не пойдут через VPN: часы устройств сверяются с ближайшим сервером времени.', function() { set('excl_ntp', !draft.excl_ntp); })); if (CAPS.ports) { var psum = [], pd = portsShort(draft.p_dt, draft.p_du), pv = portsShort(draft.p_pt, draft.p_pu); if (pd) psum.push('напрямую: ' + pd); if (pv) psum.push('VPN только: ' + pv); miscCard.appendChild(fold('ports', 'Порты', psum.join(' · '), function(box) { box.appendChild(E('h4', { 'style': 'margin:2px 0 4px' }, 'Всегда напрямую')); box.appendChild(hint('Соединения на эти порты не пойдут через VPN, даже к сайтам из списков. Например, 22 — SSH, 25 — почта.', 0)); box.appendChild(row('TCP', inp('p_dt', '22, 25', 240))); box.appendChild(row('UDP', inp('p_du', '3478-3479', 240))); box.appendChild(E('h4', { 'style': 'margin:16px 0 4px' }, 'Через VPN — только эти')); box.appendChild(hint('К сайтам из списков через VPN пойдут только эти порты, остальные — напрямую. Пусто — все порты.', 0)); box.appendChild(row('TCP', inp('p_pt', '80, 443', 240))); box.appendChild(row('UDP', inp('p_pu', '443', 240))); box.appendChild(hint('Через запятую: номера от 1 до 65535 или диапазоны, например 6881-6889.')); })); } 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, vars = SB_VARS.filter(function(x) { return x.id !== 'lite' || st.sb_lite !== 'arch' || sv === 'lite'; }), has = !!st.singbox; var curVar = vars.some(function(x) { return x.id === sv; }) ? sv : ''; var restartNote = st.enabled ? 'Forkozz перезапустится — на полминуты выбранные сервисы пойдут напрямую.\n' : ''; sbCard.appendChild(row('Версия', has ? E('span', {}, [ E('b', {}, SB_NAMES[sv] || 'установлен вручную'), ' · ' + st.singbox ]) : E('span', {}, 'не установлен'))); var sbSeg = seg(vars, curVar, function(v) { var it = vars.filter(function(x) { return x.id === v; })[0]; if (v === 'lite' && st.sb_lite === 'engine') { zm.toast('Этот движок Forkozz ещё не умеет ставить облегчённый расширенный sing-box — сначала обновите Forkozz', 'warning', 8000); return; } return zm.ask((has ? 'Заменить sing-box на ' : 'Установить ') + it.label.toLowerCase() + (has ? '' : ' sing-box') + '?\n\n' + it.warn + restartNote + (has ? 'Если новый sing-box не установится, останется прежний.' : '')).then(function(ok) { if (!ok) return; act('singbox', v); }); }); sbSeg.classList.add('zm-seg-wrap'); sbCard.appendChild(row('Вариант', sbSeg)); if (has && curVar) { var cur = vars.filter(function(x) { return x.id === curVar; })[0]; sbCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'disabled': busy ? '' : null, 'click': function() { return zm.ask('Переустановить ' + cur.label.toLowerCase() + ' sing-box?\n\n' + (curVar === 'extended' ? 'Скачается свежая сборка с GitHub (около 40 МБ).\n' : curVar === 'lite' ? 'Скачается свежая сборка с GitHub (около 10–17 МБ).\n' : 'Пакет скачается из репозитория OpenWrt заново.\n') + restartNote + 'Если не установится, останется прежний.').then(function(ok) { if (!ok) return; act('singbox_reinstall', curVar); }); } }, curVar === 'extended' || curVar === 'lite' ? 'Переустановить (обновить сборку)' : 'Переустановить') ])); } 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)); }))); sbCard.appendChild(row('Журнал', seg(LOG_LV, LOG_LV.some(function(x) { return x.id === draft.log_level; }) ? draft.log_level : '', function(v) { set('log_level', v); }))); sbCard.appendChild(hint('Сколько sing-box пишет в системный журнал. По умолчанию выключен — меньше нагрузки на память и флеш роутера; ошибки запуска Forkozz всё равно видно. «Подробный» и «Отладка» — чтобы разобраться с проблемой, потом верните «Выключен».')); } 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', {}, 'Проверка')); checkCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Роутер проверит службу, sing-box, DNS и сервер каждой секции.')); if (diag && diag.checks) { var allChecks = diag.checks.concat(diag.extra || []); var fails = allChecks.filter(function(c) { return c.verdict === 'fail'; }).length, warns = allChecks.filter(function(c) { return c.verdict === 'warn'; }).length; checkCard.appendChild(chkRow(E('span', { 'class': 'zm-label' }, 'Итог'), fails ? badge('zm-bad', 'есть поломка') : warns ? badge('zm-warn', 'работает, есть замечания') : badge('zm-ok', 'всё в порядке'))); var CLS = { ok: 'zm-ok', warn: 'zm-warn', fail: 'zm-bad' }, TXT = { ok: 'ок', warn: 'внимание', fail: 'ошибка' }; var FIX_TEXT = { tsc_clean: 'Убрать остатки', src_fix: 'Убрать лишнее и перезапустить' }; allChecks.forEach(function(c) { if (!CLS[c.verdict]) return; checkCard.appendChild(chkRow(badge(CLS[c.verdict], TXT[c.verdict]), E('span', {}, [ String(c.what || '') + (c.why ? ' — ' + c.why : ''), FIX_TEXT[c.fix] ? E('div', { 'style': 'margin-top:6px' }, [ E('button', { 'class': 'cbi-button cbi-button-action', 'disabled': busy ? '' : null, 'click': function() { act(c.fix); } }, FIX_TEXT[c.fix]) ]) : '' ]))); }); } checkCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'disabled': diagBusy ? '' : null, 'click': function() { runDiag(false); } }, diagBusy ? 'Проверяем…' : 'Проверить'), E('button', { 'class': 'cbi-button' + (connKind === 'conns' ? ' cbi-button-action' : ''), 'disabled': connBusy ? '' : null, 'click': function() { loadConns('conns'); } }, 'Соединения'), E('button', { 'class': 'cbi-button' + (connKind === 'sites' ? ' cbi-button-action' : ''), 'disabled': connBusy ? '' : null, 'click': function() { loadConns('sites'); } }, 'Сайты сейчас'), connKind ? E('button', { 'class': 'cbi-button', 'click': function() { connKind = null; connRes = null; renderCheck(); } }, 'Скрыть') : '' ])); if (!connKind) return; checkCard.appendChild(E('p', { 'class': 'zm-hint' }, connKind === 'conns' ? 'Что прямо сейчас идёт через sing-box: устройство, адрес назначения и секция (или «напрямую»). Список берётся из памяти роутера в момент нажатия, никуда не сохраняется и не отправляется.' : 'Сайты, с которыми устройства соединены прямо сейчас, и через какую секцию они идут. Берутся из текущих соединений sing-box, никуда не сохраняются.')); if (connBusy || !connRes) { checkCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Загружаем…')); return; } if (connRes.error) { checkCard.appendChild(E('p', { 'class': 'zm-hint' }, '⚠ ' + connRes.error)); return; } var LIMIT = 200, lines = [], total = 0, conns = connRes.conns || []; function outBadge(o) { return badge(o ? 'zm-ok' : 'zm-off', o ? o : 'напрямую'); } if (connKind === 'conns') { var agg = {}, keys = []; conns.forEach(function(c) { var k = [ c.src, c.host || c.dst, c.dport, c.proto, c.out ].join('|'); if (!agg[k]) { agg[k] = { c: c, n: 0 }; keys.push(k); } agg[k].n++; }); total = keys.length; keys.slice(0, LIMIT).forEach(function(k) { var a = agg[k], c = a.c, dst = c.host || c.dst, v6 = !c.host && String(c.dst).indexOf(':') >= 0; lines.push([ outBadge(c.out), (c.dev ? c.dev + ' (' + c.src + ')' : String(c.src)) + ' → ' + (c.dport ? (v6 ? '[' + dst + ']' : dst) + ':' + c.dport : dst) + ' · ' + String(c.proto || '') + (a.n > 1 ? ' · ×' + a.n : '') ]); }); if (!total) checkCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Соединений сейчас нет. Откройте на устройстве выбранный сервис и нажмите «Соединения» ещё раз.')); else checkCard.appendChild(row('Соединений', E('span', {}, String(connRes.total || conns.length) + (connRes.truncated ? ' · показана часть' : '')))); } else { var sites = {}, order = []; conns.forEach(function(c) { var h = c.host || c.dst; if (!h) return; var k = h + '|' + (c.out || ''); if (!sites[k]) { sites[k] = { h: h, out: c.out, n: 0, dev: {} }; order.push(k); } sites[k].n++; sites[k].dev[c.dev || c.src] = true; }); order.sort(function(a, b) { return (sites[a].out ? 0 : 1) - (sites[b].out ? 0 : 1) || (sites[a].h < sites[b].h ? -1 : sites[a].h > sites[b].h ? 1 : 0); }); total = order.length; order.slice(0, LIMIT).forEach(function(k) { var x = sites[k], dv = Object.keys(x.dev); lines.push([ outBadge(x.out), x.h + ' · ' + (dv.length > 2 ? dv.length + ' устройства' : dv.join(', ')) + (x.n > 1 ? ' · ×' + x.n : '') ]); }); if (!total) checkCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Сейчас ни один сайт не открыт. Откройте на устройстве любой сайт и нажмите «Сайты сейчас» ещё раз.')); } if (lines.length) { var box = E('div', { 'style': 'max-height:340px; overflow:auto; margin-top:8px; padding:4px 12px; border:1px solid rgba(127,127,127,.2); border-radius:10px' }); lines.forEach(function(l) { box.appendChild(E('div', { 'class': 'zm-row', 'style': 'flex-wrap:nowrap; align-items:flex-start' }, [ l[0], E('span', { 'style': 'min-width:0; overflow-wrap:anywhere; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:12px' }, [ l[1] ]) ])); }); checkCard.appendChild(box); if (total > LIMIT) checkCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Показаны первые ' + LIMIT + ' из ' + total + '.')); } } function loadConns(kind) { if (connBusy) return; connKind = kind; connRes = null; connBusy = true; renderCheck(); zm.forkopAction('conns', '').then(function(res) { connBusy = false; connRes = res || { error: 'Роутер не ответил' }; renderCheck(); }).catch(function() { connBusy = false; connRes = { error: 'Роутер не ответил' }; renderCheck(); }); } 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.action === 'source_required') return { tone: 'warn', cls: 'zm-warn', label: 'нужен IP устройства', title: 'маршрут зависит от устройства', note: 'Укажите IP устройства над полем сайта и повторите проверку.' }; if (r.source) lines.push([ 'Устройство', r.source ]); var wh = (r.where || []).map(function(x) { var y = {}; for (var k in x) y[k] = x[k]; if (sl.length < 2 && y.state === 'on') y.sec = ''; return y; }); var whl = zm.routeWhere(wh, { tags: { x: 'исключение', on: 'действует', dis: 'секция выключена', off: 'сервис не выбран' } }); var offSvc = wh.filter(function(x) { return x.state === 'off'; }).map(function(x) { return x.name; }).filter(function(n, i, a) { return n && a.indexOf(n) === i; }); if (r.verdict === 'bypass') { if (whl) lines.push([ 'Списки', whl ]); return { tone: 'warn', cls: 'zm-warn', label: 'напрямую · исключение', title: 'адрес в исключениях — идёт мимо VPN', lines: lines, note: 'Проверьте вкладки «Исключения» и «Устройства → Мимо Forkozz». Чтобы запрос снова шёл через VPN, уберите соответствующее исключение и сохраните.' }; } if (r.verdict === 'off') return { tone: 'off', cls: 'zm-off', label: 'Forkozz не работает', title: 'запрос пойдёт напрямую', note: 'Запустите Forkozz и проверьте ещё раз.' }; if (r.probe) lines.push([ 'Проверен', [ E('b', {}, r.probe), ' — у самого ' + r.target + ' нет адреса, а правило списка действует и на поддомены' ] ]); 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 (whl) lines.push([ 'Списки', whl ]); if (r.action === 'fallback_direct') return { tone: 'warn', cls: 'zm-warn', label: 'напрямую · VPN недоступны', title: 'сработал резервный прямой выход секции', lines: lines, note: 'Forkozz продолжает проверять VPN раз в 2 минуты и вернёт секцию через рабочий сервер после восстановления.' }; 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: offSvc.length ? 'Адрес есть в списке ' + (offSvc.length > 1 ? 'сервисов ' : 'сервиса ') + offSvc.map(function(n) { return '«' + n + '»'; }).join(', ') + ' — выберите его в секции на вкладке «Сервисы», чтобы он шёл через VPN.' : r.ip ? 'Чтобы IP шёл через VPN, добавьте его подсеть на вкладке «Сервисы».' : 'Чтобы сайт шёл через VPN, включите его сервис или добавьте домен на вкладке «Сервисы». Если домен уже в списке — устройство могло запомнить старый адрес: очистите DNS-кэш или подождите пару минут.' }; } if (r.verdict === 'noaddr') { var onAny = wh.some(function(x) { return x.state === 'on'; }); return { tone: onAny ? 'ok' : 'warn', cls: onAny ? 'zm-ok' : 'zm-warn', label: 'у имени нет адреса', title: onAny ? 'домен в списке — его поддомены пойдут через VPN' : 'по самому имени никто не ходит', lines: lines, note: 'DNS роутера работает, но у «' + r.target + '» нет собственного IP-адреса: сайты и приложения ходят на его поддомены (например, static.' + r.target + '). Введите поддомен, чтобы увидеть путь.' }; } return { tone: 'bad', cls: 'zm-bad', label: 'не удалось определить', title: 'роутер не смог узнать адрес домена', lines: lines, note: 'Проверьте, что DNS роутера работает (кнопка «Проверить» выше).' }; } function routeByDev() { return routeNeed || ((cfg && cfg.excl) || []).length > 0 || ((cfg && cfg.sections) || []).some(function(x) { return !!x && x.enabled && ((x.full || []).length > 0 || (x.all || []).length > 0); }); } 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 + '|' + (routeDev ? routeSource.trim() : '')).then(function(r) { if (r && r.action === 'source_required' && !routeDev) { routeNeed = true; setTimeout(renderRoute, 0); } return r; }); }, verdict: routeVerdict }); var byDev = routeByDev(); if (routeBox.parentNode === routeCard && byDev === routeDev) return; if (routeDev && !byDev) routeBox.reset(); routeDev = byDev; routeCard.innerHTML = ''; routeCard.appendChild(E('h3', {}, 'Куда пойдёт запрос')); routeCard.appendChild(E('p', { 'class': 'zm-hint' }, byDev ? 'Введите сайт или IP-адрес назначения. Часть правил действует только для выбранных устройств — укажите также IP устройства, с которого идёт запрос.' : 'Введите сайт или IP-адрес назначения.')); if (byDev) { var sourceInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'placeholder': '192.168.1.100', 'value': routeSource }); sourceInput.addEventListener('input', function() { routeSource = sourceInput.value; routeBox.reset(); }); routeCard.appendChild(row('IP устройства', sourceInput)); } 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(allCard); panes.dev.appendChild(fullCard); panes.dev.appendChild(exclCard); panes.set.appendChild(dnsCard); panes.set.appendChild(netCard); panes.set.appendChild(miscCard); panes.set.appendChild(sbCard); panes.set.appendChild(panelCard); 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 E = (function(raw) { return function() { var a = Array.prototype.slice.call(arguments), i = a.length - 1; if (i >= 1 && (typeof a[i] === 'string' || typeof a[i] === 'number')) a[i] = [ String(a[i]) ]; return raw.apply(null, a); }; })(window.E); 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 hd = document.documentElement; if (hd.getAttribute('data-darkmode') === 'true') { hd.classList.add('zm-theme-dark'); 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 lum = function(c) { var m = String(c || '').match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); return m ? (0.299 * (+m[1]) + 0.587 * (+m[2]) + 0.114 * (+m[3])) / 255 : -1; }; var lb = lum(bg), lt = lum(getComputedStyle(document.body).color); if ((lb >= 0 && lb < 0.5) || lt > 0.7) 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') + (/^[0-9]{6,}$/.test('__ZM_BUILD__') ? '?v=__ZM_BUILD__' : ''); 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: #0b6fa4; background: rgba(34,158,217,.1); border: 1px solid rgba(34,158,217,.25); border-radius: 999px; padding: 5px 13px; transition: background .15s; } html.zm-theme-dark .zm-header-links a { color: #5cc3f5; } .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; } html.zm-theme-dark .zm-ok { background: rgba(63,185,80,.15); color: #3fb950; } html.zm-theme-dark .zm-ok .zm-dot { background: #3fb950; } html.zm-theme-dark .zm-bad { background: rgba(248,81,73,.15); color: #ff7b72; } html.zm-theme-dark .zm-bad .zm-dot { background: #f85149; } html.zm-theme-dark .zm-warn { background: rgba(210,153,34,.16); color: #e3b341; } html.zm-theme-dark .zm-warn .zm-dot { background: #d29922; } html.zm-theme-dark .zm-off { background: rgba(139,148,158,.16); color: #a3acb7; } html.zm-theme-dark .zm-off .zm-dot { background: #8b949e; } html.zm-theme-dark .zm-lat-good { color: #3fb950; } html.zm-theme-dark .zm-lat-mid { color: #e3b341; } html.zm-theme-dark .zm-lat-bad { color: #ff7b72; } html.zm-theme-dark .zm-tile.zm-active { color: #3fb950; } html.zm-theme-dark .zm-tile.zm-tile-off { color: #ff7b72; } html.zm-theme-dark .zm-refresh-banner { color: #e3b341; } .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-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-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-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-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; } .zm-toast-busy { border-left-color: #1aa3ff; cursor: default; } .zm-toast-spin { flex-shrink: 0; align-self: center; width: 18px; height: 18px; box-sizing: border-box; border-radius: 50%; border: 2px solid rgba(255,255,255,.25); border-top-color: #58b7ff; animation: zm-savebar-spin .8s linear infinite; } html.zm-theme-dark .zm-toast-spin { border-color: rgba(0,0,0,.14); border-top-color: #0b72d1; } .zm-toast-main, .zm-toast-sub { display: block; } .zm-toast-sub { margin-top: 2px; font-size: .82em; font-weight: 500; opacity: .72; } 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 E = (function(raw) { return function() { var a = Array.prototype.slice.call(arguments), i = a.length - 1; if (i >= 1 && (typeof a[i] === 'string' || typeof a[i] === 'number')) a[i] = [ String(a[i]) ]; return raw.apply(null, a); }; })(window.E); 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; } zm.dialog({ title: 'Удалить ByeTube?', danger: true, okText: 'Удалить ByeTube', blocks: [ { type: 'switch', id: 'pkgs', value: true, label: 'Удалить byedpi и hev-socks5-tunnel', hint: 'Без ByeTube они не нужны. hev-socks5-tunnel останется, если им пользуется Mixomo.' }, { type: 'list', title: 'Что произойдёт', items: function(v) { return [ 'ByeTube удалится со всеми настройками и расписанием', 'Его правила файрвола и DNS уберутся', v.pkgs ? 'byedpi и hev-socks5-tunnel удалятся' : 'byedpi и hev-socks5-tunnel останутся на роутере', 'YouTube пойдёт напрямую через провайдера' ]; } } ] }).then(function(v) { if (v) runRemoveByeTube(v.pkgs ? 'purge' : 'remove'); }); } function runRemoveByeTube(mode) { if (removeBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } removeBusy = true; zm.toast('Удаляем ByeTube', 'warning'); callBytetubeAction(mode).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_on != null ? st.ipv6_on : 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)) ]) ]; if (st.enabled && st.hev === false && st.hev_log) kids.push(E('p', { 'class': 'zm-hint zm-ab-warn' }, 'hev-socks5-tunnel не запускается: ' + st.hev_log + (/ipv6/i.test(st.hev_log) ? ' — выключите IPv6 в настройках ниже и перезапустите ByeTube' : ' — подробности в журнале роутера'))); 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 включён'); }) ])), cfg.ipv6 && st.ipv6_ok === false ? E('p', { 'class': 'zm-hint zm-ab-warn' }, 'На роутере IPv6 выключен — ByeTube работает только по IPv4, эта настройка пока не действует. Когда IPv6 на роутере включат, ByeTube начнёт использовать его сам после перезапуска.') : '', 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 false; } return Promise.resolve(setStrategy(v, 'Своя стратегия')).then(function(ok) { if (ok) customBar.set(false); return ok; }); } }); 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 false; } 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 false; } return bt.listSet(o.kind, ta.value).then(function(res) { if (res.error) { bt.toast(res.error, 'error'); return false; } o.apply(res); return reload().then(function() { ed.sync(); bt.toast('Список сохранён', 'info'); }); }); } function onReset() { return zm.ask('Вернуть встроенный список? Ваши правки будут удалены.').then(function(ok) { if (!ok) 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 false; } 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; zm.ask(q, { okText: 'Применить' }).then(function(ok) { if (ok) 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' _zmi_ok "Страницы LuCI записаны" 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 sed -i "s/__ZM_BUILD__/$ZM_BUILD_ID/g" /www/luci-static/resources/zapret-manager/common.js /www/luci-static/resources/bytetube/common.js 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 } _zmi_say "Подключаем панель к LuCI" /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 _zmi_ok "LuCI видит панель" else _zmi_step "LuCI пока не видит панель — перезапускаем службу rpcd" /etc/init.d/rpcd restart >/dev/null 2>&1 || true if _zm_rpcd_wait 15; then _zmi_ok "LuCI видит панель" else _zmi_warn "Служба rpcd не подхватила панель. Если страница покажет «Object not found», выполните: /etc/init.d/rpcd restart"; 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 _zmi_say "Проверяем пакеты, которые нужны панели" _zmi_pkg curl "Без него панель не сможет ничего скачивать." || true _zmi_pkg unzip "Без него не установятся Zapret и Mixomo." || true _zmi_say "Записываем файлы Web UI" 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', K_ANIM = 'zmw.anim', K_NAV = 'zmw.nav'; 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.textContent = 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 sessionAlive(s) { function ask() { return ubusOnce('session', 'access', { scope: 'ubus', object: 'zapret-manager', 'function': 'status' }, s) .then(function (r) { return r[0] === 0 && r[1] && r[1].access === true ? 1 : 0; }, function (e) { return e && e.rpcCode === -32002 ? 0 : -1; }); } return ask().then(function (a) { if (a === 1) return true; return new Promise(function (res) { setTimeout(res, 2500); }).then(ask).then(function (b) { return b !== 0; }); }); } 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 && sid) { var was = sid; return sessionAlive(was).then(function (alive) { if (!alive && sid === was) { e.authLost = true; authLost(); } throw e; }); } 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 и Web'\)/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 inst.floatBanner(E('div', { 'class': 'zm-refresh-banner zm-show' }, [ E('span', {}, 'Состав компонентов изменился — обновите раздел, чтобы увидеть актуальное состояние.'), E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function () { refreshShellStatus(); route(true); } }, 'Обновить') ])); }; 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: '', adblock: '', list: '', globe: '', send: '', layers: '', play: '', cpu: '', logout: '', sun: '', moon: '', ink: '', bento: '', minimal: '', retro: '', depth: '', graphite: '', oled: '', stars: '', matrix: '', palette: '', refresh: '', menu: '', close: '', external: '', eye: '', gear: '', eyeOff: '', user: '', lock: '', arrow: '', alert: '', fork: '', route: '', tunnel: '', terminal: '', phone: '', activity: '', clock: '', trash: '', info: '', archive: '', 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' }, { id: 'graphite', name: 'Графит', ico: 'graphite', dark: true, fonts: 'Space+Grotesk:wght@400;500;700', color: '#141416' }, { id: 'oled', name: 'OLED', ico: 'oled', dark: true, fonts: 'Inter:wght@400;500;600;700;800', color: '#000000' }, { id: 'matrix', name: 'Матрица', ico: 'matrix', dark: true, fonts: 'Share+Tech+Mono', color: '#000000' } ]; 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')); if (dust) dust.recolor(); 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 dust = null; function animOn() { return sget(K_ANIM) === '1'; } function dustRgb() { var v = '', x = document.createElement('canvas').getContext('2d'), m; try { v = getComputedStyle(document.documentElement).getPropertyValue('--a1').trim(); } catch (e) {} if (!x) return [ 56, 140, 200 ]; x.fillStyle = '#388cc8'; if (v) x.fillStyle = v; m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(x.fillStyle); if (m) return [ parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16) ]; m = /(\d+),\s*(\d+),\s*(\d+)/.exec(x.fillStyle); return m ? [ +m[1], +m[2], +m[3] ] : [ 56, 140, 200 ]; } function dustStart() { if (dust) return; var c = E('canvas', { 'class': 'zmw-dust', 'aria-hidden': 'true' }); var ctx = c.getContext && c.getContext('2d'); if (!ctx) return; var dots = [], mx = -9999, my = -9999, w = 0, h = 0, raf = 0, rgb = [ 56, 140, 200 ], dk = false; function seed() { return { x: Math.random() * (w || 1), y: Math.random() * (h || 1), vx: (Math.random() - 0.5) * 0.45, vy: (Math.random() - 0.5) * 0.45, r: 1.3 + Math.random() * 2.3 }; } function size() { var d = Math.min(window.devicePixelRatio || 1, 2), n; w = window.innerWidth; h = window.innerHeight; c.width = Math.round(w * d); c.height = Math.round(h * d); ctx.setTransform(d, 0, 0, d, 0, 0); n = Math.max(36, Math.min(110, Math.round(w * h / 18000))); while (dots.length < n) dots.push(seed()); if (dots.length > n) dots.length = n; } function frame() { raf = 0; if (document.hidden) return; ctx.clearRect(0, 0, w, h); var i, j, d, dx, dy, dist, pull, base = 'rgba(' + rgb.join(',') + ',', la = dk ? 0.42 : 0.4, da = dk ? 0.8 : 0.78; ctx.lineWidth = 1; for (i = 0; i < dots.length; i++) { d = dots[i]; dx = mx - d.x; dy = my - d.y; dist = Math.sqrt(dx * dx + dy * dy) || 1; if (dist < 180) { pull = (180 - dist) / 180; d.vx += (dx / dist) * pull * 0.08; d.vy += (dy / dist) * pull * 0.08; } d.vx *= 0.985; d.vy *= 0.985; if (Math.abs(d.vx) < 0.08) d.vx += (Math.random() - 0.5) * 0.04; if (Math.abs(d.vy) < 0.08) d.vy += (Math.random() - 0.5) * 0.04; d.x += d.vx; d.y += d.vy; if (d.x < -10) d.x = w + 10; if (d.x > w + 10) d.x = -10; if (d.y < -10) d.y = h + 10; if (d.y > h + 10) d.y = -10; ctx.beginPath(); ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2); ctx.fillStyle = base + da + ')'; ctx.fill(); for (j = i + 1; j < dots.length; j++) { dx = dots[j].x - d.x; dy = dots[j].y - d.y; if (dx > 120 || dx < -120 || dy > 120 || dy < -120) continue; dist = Math.sqrt(dx * dx + dy * dy); if (dist < 120) { ctx.beginPath(); ctx.moveTo(d.x, d.y); ctx.lineTo(dots[j].x, dots[j].y); ctx.strokeStyle = base + ((1 - dist / 120) * la).toFixed(3) + ')'; ctx.stroke(); } } } raf = requestAnimationFrame(frame); } function move(e) { mx = e.clientX; my = e.clientY; } function leave() { mx = -9999; my = -9999; } function vis() { if (!document.hidden && !raf) raf = requestAnimationFrame(frame); } window.addEventListener('pointermove', move, { passive: true }); window.addEventListener('pointerleave', leave); document.addEventListener('mouseleave', leave); window.addEventListener('blur', leave); window.addEventListener('resize', size); document.addEventListener('visibilitychange', vis); dust = { el: c, recolor: function () { rgb = dustRgb(); dk = document.documentElement.classList.contains('zm-theme-dark'); }, stop: function () { if (raf) cancelAnimationFrame(raf); raf = 0; window.removeEventListener('pointermove', move); window.removeEventListener('pointerleave', leave); document.removeEventListener('mouseleave', leave); window.removeEventListener('blur', leave); window.removeEventListener('resize', size); document.removeEventListener('visibilitychange', vis); if (c.parentNode) c.parentNode.removeChild(c); document.documentElement.classList.remove('zm-dust-on'); dust = null; } }; document.documentElement.classList.add('zm-dust-on'); dustPlace(); size(); dust.recolor(); vis(); } function dustPlace() { if (!dust) return; var host = loginEl || document.body; if (dust.el.parentNode !== host) host.insertBefore(dust.el, host.firstChild); } function dustSync() { if (animOn()) dustStart(); else if (dust) dust.stop(); } var themeMenuEl = null; function closeThemeMenu() { if (!themeMenuEl) return; themeMenuEl.remove(); themeMenuEl = null; themeMenuBtn = null; window.removeEventListener('resize', placeThemeMenu); document.removeEventListener('click', themeMenuOutside, true); document.removeEventListener('keydown', themeMenuKey, true); } var themeMenuBtn = null; function placeThemeMenu() { var m = themeMenuEl, b = themeMenuBtn, pad = 8, vw, vh, r, w, top, left; if (!m) return; vw = document.documentElement.clientWidth || window.innerWidth; vh = window.innerHeight; r = b && b.isConnected ? b.getBoundingClientRect() : { top: 0, bottom: 56, right: vw - pad }; m.style.right = 'auto'; m.style.width = vw <= 480 ? (vw - pad * 2) + 'px' : ''; w = m.offsetWidth; left = vw <= 480 ? pad : Math.min(Math.max(pad, Math.round(r.right - w)), vw - w - pad); top = Math.round(r.bottom + 8); m.style.left = Math.max(pad, left) + 'px'; m.style.top = top + 'px'; m.style.maxHeight = Math.max(200, vh - top - pad) + 'px'; } 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)); }); items.push(animOpt()); themeMenuEl = E('div', { 'class': 'zmw-theme-menu', 'role': 'menu' }, items); function animOpt() { var on = animOn(); var b = E('button', { 'type': 'button', 'role': 'menuitemcheckbox', 'aria-checked': on ? 'true' : 'false', 'class': 'zmw-theme-opt zmw-anim-opt' + (on ? ' zmw-anim-on' : ''), 'title': 'Созвездие на фоне: точки соединяются линиями и тянутся к курсору', 'click': function (e) { e.stopPropagation(); sset(K_ANIM, animOn() ? '0' : '1', true); dustSync(); b.parentNode.replaceChild(animOpt(), b); } }, [ icon('stars'), E('span', {}, [ 'Анимация' ]), E('span', { 'class': 'zmw-anim-sw', 'aria-hidden': 'true' }, [ E('i') ]) ]); return b; } 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); themeMenuBtn = btn || null; placeThemeMenu(); setTimeout(function () { document.addEventListener('click', themeMenuOutside, true); document.addEventListener('keydown', themeMenuKey, true); window.addEventListener('resize', placeThemeMenu); }, 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: 'adblock', title: 'Блокировка рекламы', sub: 'Реклама, трекеры и фишинг — для всех устройств в сети', icon: 'adblock', group: 'Сеть', dot: 'adblock' }, { 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: 'tailscale', title: 'Удалённый доступ', sub: '', icon: 'phone', group: 'Сеть', dot: 'ts' }, { id: 'terminal', title: 'Терминал', sub: 'Командная строка роутера в браузере (ttyd)', icon: 'terminal', group: 'Сеть', dot: 'term' }, { id: 'system', title: 'Система', sub: 'Параметры роутера, время, зеркала и обновления', icon: 'cpu', group: 'Сервис', view: 'system', part: 'main' }, { id: 'backup', title: 'Резервная копия', sub: 'Сохранение и восстановление настроек', icon: 'archive', group: 'Сервис', view: 'system', part: 'backup' }, { id: 'monitor', title: 'Монитор ресурсов', sub: 'Процессор, память, температура и процессы', icon: 'activity', group: 'Сервис' }, { id: 'watch', title: 'Фоновые проверки', sub: 'Что роутер проверяет сам по расписанию', icon: 'clock', group: 'Сервис', view: 'system', part: 'watch' }, { id: 'cleanup', title: 'Очистка и удаление', sub: 'Освобождение памяти и удаление компонентов', icon: 'trash', group: 'Сервис', view: 'system', part: 'cleanup' }, { id: 'sysinfo', title: 'Системная информация', sub: 'Отчёт о роутере, сети и компонентах', icon: 'info', group: 'Сервис', view: 'system', part: 'sysinfo' } ]; 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 NAV_LOCK = { dashboard: 1 }, NAV_BUILTIN = { dashboard: 1, system: 1, watch: 1, monitor: 1, cleanup: 1, sysinfo: 1, backup: 1 }; var navHidden = navParse(sget(K_NAV)), navEdit = null, navSaving = false, navGroups = [], navGear = null, navNav = null, navHealth = null; function navParse(v) { var o = {}; String(v || '').split(/[\s,]+/).forEach(function (k) { if (/^[a-z0-9_]{1,32}$/.test(k) && !NAV_LOCK[k]) o[k] = 1; }); return o; } function navList(o) { return ROUTES.map(function (r) { return r.id; }).filter(function (k) { return o[k]; }); } function navCount() { return navList(navHidden).length; } function navApply() { var edit = !!navEdit, cur = edit ? navEdit : navHidden; if (navNav) navNav.classList.toggle('zmw-nav-editing', edit); ROUTES.forEach(function (r) { var a = navLinks[r.id]; if (!a) return; var off = !!cur[r.id]; a.hidden = !edit && off; a.classList.toggle('zmw-nav-off', edit && off); if (a._eye) { a._eye.innerHTML = ''; a._eye.appendChild(icon(off ? 'eyeOff' : 'eye')); a._eye.setAttribute('aria-pressed', off ? 'false' : 'true'); a._eye.title = off ? 'Показать в меню' : 'Скрыть из меню'; } }); navGroups.forEach(function (g) { g.el.hidden = !edit && g.ids.every(function (k) { return navHidden[k]; }); }); if (navGear) { var n = navCount(); navGear.classList.toggle('zmw-on', edit); navGear.classList.toggle('zmw-has', n > 0); navGear.title = edit ? 'Сохранить меню' : n ? 'Настроить меню · скрыто: ' + n : 'Настроить меню'; navGear.setAttribute('aria-label', edit ? 'Готово' : 'Настроить меню'); navGear.setAttribute('aria-expanded', edit ? 'true' : 'false'); } } function navOpen() { if (navEdit) { navDone(); return; } navEdit = {}; Object.keys(navHidden).forEach(function (k) { navEdit[k] = 1; }); navApply(); if (navNav) navNav.scrollTop = 0; } function navToggle(id) { if (!navEdit || NAV_LOCK[id]) return; if (navEdit[id]) delete navEdit[id]; else navEdit[id] = 1; navApply(); } function navUninstalled() { if (!navEdit) return; var h = navHealth || {}, d = h.dots || {}, n = 0; ROUTES.forEach(function (r) { if (!r.dot || NAV_BUILTIN[r.id]) return; var st = typeof d[r.dot] === 'number' ? d[r.dot] : h[r.dot]; if (st === 0 && !navEdit[r.id]) { navEdit[r.id] = 1; n++; } }); navApply(); toast(n ? 'Отмечено неустановленных и невключённых: ' + n + ' — нажмите «Готово»' : (navHealth ? 'Неустановленных разделов нет' : 'Состояние компонентов ещё загружается — попробуйте через пару секунд'), 'info'); } function navAll() { if (navEdit) { navEdit = {}; navApply(); } } function navDone() { if (!navEdit) return; var next = navEdit, was = navList(navHidden).join(' '), now = navList(next).join(' '); navEdit = null; navHidden = next; sset(K_NAV, now || null, true); navApply(); if (was === now) return; navSaving = true; ubus('zapret-manager', 'ui_nav_set', { ids: now }).then(function (r) { var d = r && r[1]; if (r && r[0] === 6) throw new Error('нет прав — выйдите из панели и войдите снова'); if (!r || r[0] !== 0) throw new Error('роутер не ответил (код ' + (r ? r[0] : '?') + ')'); if (!d || d.error || !d.ok) throw new Error((d && d.error) || 'неизвестный ответ'); toast(now ? 'Меню сохранено · скрыто: ' + navList(next).length : 'Меню сохранено · показаны все разделы', 'info'); }).catch(function (e) { toast('Меню не сохранилось на роутере: ' + ((e && e.message) || e) + '. В этом браузере оно уже изменено', 'error'); }).then(function () { navSaving = false; }); } function navFromHealth(h) { navHealth = h; if (!h || typeof h.nav_hidden !== 'string' || navEdit || navSaving) return; var srv = navList(navParse(h.nav_hidden)).join(' '); if (srv === navList(navHidden).join(' ')) return; navHidden = navParse(srv); sset(K_NAV, srv || null, true); navApply(); } document.addEventListener('keydown', function (ev) { if (navEdit && ev.key === 'Escape') { ev.preventDefault(); navDone(); } }); var alertsEl = E('div', { 'class': 'zmw-alerts' }), alertsKey = ''; var termKeep = { host: null, frame: null, src: '' }; var TERM_SHOW = 'margin:0 0 18px', TERM_HIDE = 'margin:0; height:0; overflow:hidden; visibility:hidden; border-width:0; box-shadow:none'; function termHide() { if (termKeep.host) termKeep.host.setAttribute('style', TERM_HIDE); } function termDrop() { if (termKeep.host && termKeep.host.parentNode) termKeep.host.parentNode.removeChild(termKeep.host); termKeep.host = null; termKeep.frame = null; termKeep.src = ''; } function termOpen(src, title) { if (!viewEl || !sid || currentRoute() !== 'terminal') return false; if (termKeep.frame && termKeep.src !== src) termDrop(); if (!termKeep.host || termKeep.host.parentNode !== viewEl) { termDrop(); termKeep.frame = E('iframe', { 'class': 'zm-term-frame', 'src': src }); termKeep.host = E('div', { 'class': 'zmw-term-keep zm-term', 'style': TERM_HIDE }, [ E('div', { 'class': 'zm-term-bar' }, [ E('span', { 'class': 'zm-term-dots' }, [ E('i', {}), E('i', {}), E('i', {}) ]), E('span', { 'class': 'zm-term-title' }, title || 'ttyd') ]), E('div', { 'class': 'zm-term-body' }, [ termKeep.frame ]) ]); termKeep.src = src; viewEl.insertBefore(termKeep.host, viewEl.firstChild); } termKeep.host.setAttribute('style', TERM_SHOW); return true; } window.zmTermKeep = { open: termOpen, hide: termHide, drop: termDrop }; function viewReset() { viewEl.style.minHeight = ''; var n = viewEl.firstChild, next; while (n) { next = n.nextSibling; if (n !== termKeep.host) viewEl.removeChild(n); n = next; } viewEl.appendChild(alertsEl); } document.addEventListener('click', function (ev) { var btn = ev.target && ev.target.closest ? ev.target.closest('#zmw-view .zmw-tabs .cbi-button') : null; if (!btn || !viewEl) return; var bar = btn.closest('.zmw-tabs'), y0 = bar.getBoundingClientRect().top; viewEl.style.minHeight = viewEl.offsetHeight + 'px'; requestAnimationFrame(function () { requestAnimationFrame(function () { var nb = document.contains(bar) ? bar : viewEl.querySelector('.zmw-tabs'); if (!nb) { viewEl.style.minHeight = ''; return; } var target = window.scrollY + nb.getBoundingClientRect().top - y0, vtop = viewEl.getBoundingClientRect().top + window.scrollY; viewEl.style.minHeight = ''; var need = target + window.innerHeight - vtop; if (viewEl.offsetHeight < need) viewEl.style.minHeight = Math.ceil(need) + 'px'; if (Math.abs(window.scrollY - target) > 1) window.scrollTo(0, target); }); }); }, true); 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; function navEdges() { var more = nav.scrollHeight - nav.clientHeight > 2; nav.classList.toggle('zmw-fade-t', more && nav.scrollTop > 2); nav.classList.toggle('zmw-fade-b', more && nav.scrollTop + nav.clientHeight < nav.scrollHeight - 2); } nav.addEventListener('scroll', navEdges, { passive: true }); window.addEventListener('resize', navEdges); setTimeout(navEdges, 0); setTimeout(navEdges, 600); navNav = nav; navGroups = []; ROUTES.forEach(function (r) { if (r.group !== lastGroup) { var gEl = E('div', { 'class': 'zmw-nav-group' }, [ E('span', {}, [ r.group ]) ]); if (!navGroups.length) { navGear = E('button', { 'class': 'zmw-nav-gear', 'type': 'button', 'aria-label': 'Настроить меню', 'aria-expanded': 'false', 'click': function (ev) { ev.preventDefault(); navOpen(); } }, [ icon('gear'), E('span', { 'class': 'zmw-nav-gear-done' }, [ 'Готово' ]), E('i', {}) ]); gEl.appendChild(navGear); } navGroups.push({ el: gEl, ids: [] }); nav.appendChild(gEl); lastGroup = r.group; } navGroups[navGroups.length - 1].ids.push(r.id); var dot = r.dot ? E('span', { 'class': 'zmw-nav-dot' }) : null; var tail = NAV_LOCK[r.id] ? E('span', { 'class': 'zmw-nav-lock' }, [ 'всегда' ]) : E('span', { 'class': 'zmw-nav-eye', 'role': 'switch', 'aria-label': 'Показывать «' + r.title + '» в меню' }); 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, tail ]); if (!NAV_LOCK[r.id]) a._eye = tail; a.addEventListener('click', function (ev) { if (navEdit) { ev.preventDefault(); navToggle(r.id); return; } closeDrawer(); }); navLinks[r.id] = a; if (dot) navDots[r.dot] = dot; nav.appendChild(a); }); nav.appendChild(E('div', { 'class': 'zmw-nav-editfoot' }, [ E('button', { 'class': 'zmw-nav-btn zmw-nav-btn-wide', 'type': 'button', 'click': function () { navUninstalled(); } }, [ 'Скрыть неустановленные' ]), E('button', { 'class': 'zmw-nav-btn', 'type': 'button', 'click': function () { navAll(); } }, [ 'Показать все' ]), E('button', { 'class': 'zmw-nav-btn zmw-nav-btn-done', 'type': 'button', 'click': function () { navDone(); } }, [ 'Готово' ]) ])); navApply(); deviceEl = E('div', { 'class': 'zmw-device' }, [ E('span', { 'class': 'zmw-device-model' }, [ 'Роутер' ]), E('span', { '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 ', E('b', {}, [ '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 ]) ]); 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 openDrawer() { document.body.classList.add('zmw-drawer-open'); } function closeDrawer() { if (navEdit) navDone(); 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 memInsert(r) { 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); } function fmtPair(used, total) { var a = fmtSize(used), b = fmtSize(total), ua = a.split(' ')[1], ub = b.split(' ')[1]; if (ua && ua === ub) return a.split(' ')[0] + ' / ' + b; var k = [ 'Б', 'КБ', 'МБ', 'ГБ', 'ТБ' ].indexOf(ub), v = k > 0 && isFinite(used) ? used / Math.pow(1024, k) : NaN; if (v >= 0.1) return (v >= 100 ? Math.round(v) : v.toFixed(1).replace(/\.0$/, '')) + ' / ' + b; if (isFinite(used) && used >= 10 * 1024) a = a.replace(/\.\d+ /, ' '); return a + ' / ' + b; } function memFit(v) { v.classList.remove('zmw-mem-fit'); if (v.clientWidth && v.scrollWidth > v.clientWidth) v.classList.add('zmw-mem-fit'); } 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 === 'ram' ? 0 : key === 'flash' ? 1 : 2; memInsert(r); } r.el.querySelector('.zmw-mem-val').textContent = fmtPair(used, total); [].forEach.call(memEl.querySelectorAll('.zmw-mem-val'), memFit); 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 + ': занято ' + fmtSize(used) + ' из ' + fmtSize(total) + ' (' + 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') ]) ]) }; memInsert(r); } 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: -2, 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') ]) ]) }; memInsert(r); } 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 applyBoard(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); } function applySys(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); } var sharedSysAt = 0, sharedHealthAt = 0; window.addEventListener('zm:sysinfo', function (ev) { var d = (ev && ev.detail) || {}; if (!shell || !sid) return; sharedSysAt = Date.now(); if (d.board) applyBoard(d.board); if (d.sys) applySys(d.sys); }); function refreshMemory() { if (!shell || !sid) return; if (Date.now() - sharedSysAt < 9000) return; callBoardInfo().then(applyBoard).catch(function () {}); callSysInfo().then(applySys).catch(function () {}); } var statusBusy = false; var DOT_TEXT = { 1: 'работает', 2: 'ошибка — не работает', 3: 'работает частично', 4: 'установлен, не запущен или не настроен' }; 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(applyHealth).catch(function () {}).then(function () { statusBusy = false; }); } function applyHealth(h) { if (!h) return; navFromHealth(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; else if (k === 'awg' && h.awg === 1) title = 'установлен, туннелей нет'; else if (k === 'awg' && h.awg === 3) title = 'установлен не полностью — откройте вкладку и нажмите «Доустановить»'; if (h.dots && h.dots[k] === 4) title = null; setDot(navDots[k], h.dots && typeof h.dots[k] === 'number' ? h.dots[k] : h[k], title); }); } window.addEventListener('zm:health', function (ev) { var h = ev && ev.detail; if (!shell || !sid || !h || typeof h.zapret !== 'number') return; sharedHealthAt = Date.now(); applyHealth(h); }); 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' : st === 4 ? ' zmw-idle' : ''); 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 = ''; }).catch(function () {}); callUpd().then(applyUpd).catch(function () {}); } function applyUpd(u) { if (!u || u.error || !shell || !updateEl) return; if (u.current) verEl.textContent = 'by StressOzz · v' + u.current; var on = !!(u.latest && u.current && u.latest !== u.current && u.newer !== false); updateEl.hidden = !on; if (on) updateEl.textContent = 'Доступна версия ' + u.latest; } window.addEventListener('zm:update', function (ev) { if (sid) applyUpd(ev && ev.detail); }); 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 && Date.now() - sharedHealthAt > 25000) refreshShellStatus(); }, 30000); setInterval(function () { if (!document.hidden) refreshMemory(); }, 10000); setInterval(function () { if (!document.hidden && sid) callUpd().then(applyUpd).catch(function () {}); }, 600000); } 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 navReveal() { try { var al = navLinks[currentRoute()], nv = al && al.parentNode, pad = 26; if (!nv || nv.scrollHeight <= nv.clientHeight || !al.offsetHeight) return; var t = al.offsetTop - nv.offsetTop, h = al.offsetHeight; if (t - pad < nv.scrollTop) nv.scrollTop = Math.max(0, t - pad); else if (t + h + pad > nv.scrollTop + nv.clientHeight) nv.scrollTop = t + h + pad - nv.clientHeight; } catch (e) {} } 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); }); navReveal(); setTimeout(navReveal, 400); titleEl.textContent = r.title; subEl.textContent = r.sub || ''; subEl.hidden = !r.sub; document.title = r.title + ' · Zapret Manager'; poll._reset(); ui.hideModal(); refreshShellStatus(); termHide(); viewReset(); viewEl.appendChild(skeleton()); if (!force) window.scrollTo(0, 0); fetchSrc('view.zapret-manager.' + (r.view || id)) .then(function (src) { return evaluate('view.zapret-manager.' + (r.view || id), src); }) .then(function (Cls) { if (token !== routeToken) return; var v = (typeof Cls === 'function' && Cls.__zmClass) ? new Cls() : Cls; if (r.part) v.zmPart = r.part; 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 ]); viewReset(); viewEl.appendChild(page); }); }) .catch(function (err) { if (token !== routeToken || (err && err.authLost)) return; console.error(err); viewReset(); viewEl.appendChild(errorCard(err, function () { route(true); })); }); } var loginEl = null, authLostShown = false; function matrixRain(c, msg) { var x = c.getContext && c.getContext('2d'); if (!x) return function () {}; var chars = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789ZAPRETMANAGER'; var who = sget(K_USER) || 'root', lines = [ '> follow the white rabbit_', '> knock, knock, ' + who + '_', '> the router has you…_' ]; var size = 16, drops = [], w = 0, h = 0, li = 0, n = 0, timer = 0; function fit() { var r = c.getBoundingClientRect(), d = Math.min(window.devicePixelRatio || 1, 2); w = Math.max(1, Math.round(r.width)); h = Math.max(1, Math.round(r.height)); c.width = w * d; c.height = h * d; x.setTransform(d, 0, 0, d, 0, 0); x.fillStyle = '#000'; x.fillRect(0, 0, w, h); drops = []; for (var i = 0; i < Math.ceil(w / size); i++) drops.push(Math.random() * -h / size); } function step() { x.fillStyle = 'rgba(0,0,0,0.08)'; x.fillRect(0, 0, w, h); x.font = size + 'px monospace'; for (var i = 0; i < drops.length; i++) { var y = drops[i] * size; x.fillStyle = Math.random() > .975 ? '#d8ffe0' : '#00ff41'; x.fillText(chars.charAt(Math.floor(Math.random() * chars.length)), i * size, y); if (y > h && Math.random() > .975) drops[i] = 0; drops[i]++; } } function frame() { if (!document.body.contains(c)) { stop(); return; } if (document.documentElement.getAttribute('data-theme') !== 'matrix' || document.hidden) return; if (!w) fit(); step(); if (msg && ++n % 64 === 0) { li = (li + 1) % lines.length; msg.textContent = lines[li]; } } function onResize() { w = 0; } function stop() { clearInterval(timer); window.removeEventListener('resize', onResize); } window.addEventListener('resize', onResize); timer = setInterval(frame, 50); return stop; } 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 mxRain = E('canvas', { 'class': 'zmw-mx-rain', 'aria-hidden': 'true' }); var mxMsg = E('div', { 'class': 'zmw-mx-msg', 'aria-hidden': 'true' }, [ '> follow the white rabbit_' ]); var card = E('form', { 'class': 'zmw-login-card', 'autocomplete': 'on' }, [ logo('zmw-logo-lg'), E('div', { 'class': 'zmw-mx-title' }, [ 'ZAPRET_MANAGER' ]), E('p', { 'class': 'zmw-mx-sub' }, [ 'wake up, ' + (sget(K_USER) || 'root') + '… · ' + location.hostname ]), 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); mxMsg.textContent = '> access granted_'; 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' }, [ mxRain, E('div', { 'class': 'zmw-orbs', 'aria-hidden': 'true' }, [ E('i'), E('i'), E('i') ]), E('div', { 'class': 'zmw-grid-bg', 'aria-hidden': 'true' }), card, mxMsg, 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); dustPlace(); loginEl.zmMxStop = matrixRain(mxRain, mxMsg); applyTheme(); setTimeout(function () { (user.value ? pass : user).focus(); }, 60); } function closeLogin() { if (!loginEl) return; var el = loginEl; loginEl = null; authLostShown = false; dustPlace(); if (el.zmMxStop) setTimeout(el.zmMxStop, 400); 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(); termDrop(); 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(); termDrop(); 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(); dustSync(); 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: hidden; } .zmw-side > .zmw-brand, .zmw-side > .zmw-update, .zmw-side > .zmw-side-foot { flex-shrink: 0; } .zmw-side > .zmw-nav { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; margin: 0 -8px; padding: 0 8px; } .zmw-nav.zmw-fade-t { -webkit-mask-image: linear-gradient(to bottom, transparent, #000 22px); mask-image: linear-gradient(to bottom, transparent, #000 22px); } .zmw-nav.zmw-fade-b { -webkit-mask-image: linear-gradient(to top, transparent, #000 22px); mask-image: linear-gradient(to top, transparent, #000 22px); } .zmw-nav.zmw-fade-t.zmw-fade-b { -webkit-mask-image: linear-gradient(to bottom, transparent, #000 22px, #000 calc(100% - 22px), transparent); mask-image: linear-gradient(to bottom, transparent, #000 22px, #000 calc(100% - 22px), transparent); } .zmw-nav::-webkit-scrollbar { width: 6px; } .zmw-nav::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 6px; } .zmw-nav::-webkit-scrollbar-track { background: transparent; } @media (max-height: 520px) { .zmw-side { overflow-y: auto; } .zmw-side > .zmw-nav { flex: none; overflow: visible; -webkit-mask-image: none; mask-image: none; } } .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: 20px; line-height: 1.15; letter-spacing: -.01em; white-space: nowrap; } .zmw-brand-name b { font-weight: inherit; background: linear-gradient(90deg, #1a8cff, #7c5cff); -webkit-background-clip: text; background-clip: text; color: transparent; } html[data-theme="dark"] .zmw-brand-name b, html[data-theme="depth"] .zmw-brand-name b, html.zm-theme-dark .zmw-brand-name b { background: linear-gradient(90deg, #38bdf8, #a78bfa); -webkit-background-clip: text; background-clip: text; } .zmw-brand-sub { font-size: 12.5px; color: var(--muted); margin-top: 2px; } .zmw-brand .zmw-logo svg { width: 36px; height: 36px; } @media (max-width: 960px) { .zmw-brand-name { font-size: 18px; } .zmw-brand .zmw-logo svg { width: 32px; height: 32px; } } .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-group { display: flex; align-items: center; justify-content: space-between; gap: 8px; opacity: 1; } .zmw-nav-group > span { opacity: .8; } .zmw-nav-group[hidden], .zmw-nav-item[hidden] { display: none; } .zmw-nav-gear { position: relative; height: 24px; min-width: 24px; margin: -4px -6px -4px 0; padding: 0; flex: none; display: inline-flex; align-items: center; justify-content: center; gap: 0; border-radius: 8px; cursor: pointer; outline: none; box-sizing: border-box; border: 1px solid var(--border); background: var(--surface-2); color: var(--muted); font: inherit; font-size: 12px; font-weight: 650; letter-spacing: 0; text-transform: none; line-height: 1; transition: color .15s, border-color .15s, background .15s, box-shadow .15s; } .zmw-nav-gear .zmw-i { width: 14px; height: 14px; display: block; transition: transform .35s; } .zmw-nav-gear:hover { color: var(--text); border-color: var(--a1); } .zmw-nav-gear:hover .zmw-i { transform: rotate(60deg); } .zmw-nav-gear:focus-visible { box-shadow: 0 0 0 2px var(--a1); } .zmw-nav-gear-done { display: none; } .zmw-nav-gear.zmw-on { padding: 0 11px; color: #fff; background: var(--grad); border-color: transparent; } .zmw-nav-gear.zmw-on .zmw-i { display: none; } .zmw-nav-gear.zmw-on .zmw-nav-gear-done { display: inline; } .zmw-nav-gear > i { display: none; position: absolute; top: 2px; right: 2px; width: 6px; height: 6px; border-radius: 50%; background: var(--a1); box-shadow: 0 0 0 1.5px var(--surface-2); pointer-events: none; } .zmw-nav-gear.zmw-has > i { display: block; } .zmw-nav-gear.zmw-on > i { display: none; } .zmw-nav-editfoot, .zmw-nav-eye, .zmw-nav-lock { display: none; } .zmw-nav-editing .zmw-nav-editfoot { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; padding: 10px 4px 4px; } .zmw-nav-editfoot .zmw-nav-btn { min-width: 0; padding: 8px 10px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .zmw-nav-editfoot .zmw-nav-btn-wide { grid-column: 1 / -1; } .zmw-nav-editfoot .zmw-nav-btn-done { color: #fff; background: var(--grad); border-color: transparent; } .zmw-nav-editfoot .zmw-nav-btn-done:hover { color: #fff; border-color: transparent; filter: brightness(1.08); } .zmw-nav-btn { border: 1px solid var(--border); background: var(--surface-2); color: var(--text-2); border-radius: 9px; padding: 6px 10px; font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; } .zmw-nav-btn:hover { color: var(--text); border-color: var(--a1); } .zmw-nav-editing .zmw-nav-eye { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 8px; flex: none; margin-right: -6px; color: var(--a1); background: var(--grad-soft); } .zmw-nav-editing .zmw-nav-eye .zmw-i { width: 15px; height: 15px; } .zmw-nav-editing .zmw-nav-lock { display: block; font-size: 11.5px; font-weight: 600; color: var(--muted); } .zmw-nav-editing .zmw-nav-item.zmw-active .zmw-nav-lock { color: inherit; opacity: .75; } .zmw-nav-editing .zmw-nav-item { cursor: pointer; user-select: none; } .zmw-nav-editing .zmw-nav-item.zmw-active::before { display: none; } .zmw-nav-editing .zmw-nav-item.zmw-nav-off { opacity: .45; } .zmw-nav-editing .zmw-nav-item.zmw-nav-off .zmw-nav-eye { color: var(--muted); background: var(--surface-2); } .zmw-nav-editing .zmw-nav-item.zmw-nav-off .zmw-nav-label { text-decoration: line-through; text-decoration-color: rgba(127,127,127,.5); } .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, .zmw-nav-dot.zmw-idle { display: block; } .zmw-nav-dot.zmw-idle { background: #9aa3b2; box-shadow: 0 0 0 3px rgba(154,163,178,.18); } .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: 7px; padding: 10px 6px 8px; margin-top: 12px; border-top: 1px solid var(--border); } @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: 0 9px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 12.5px; line-height: 1.35; } .zmw-device-sub:empty { display: none; } .zmw-device-model { font-weight: 650; color: var(--text); } .zmw-device-sub { font-size: 12px; color: var(--muted); } .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-side .zmw-mem { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: 1fr; gap: 5px; padding: 0; } .zmw-side .zmw-mem:empty { display: none; } .zmw-side .zmw-mem-row:last-child:nth-child(odd) { grid-column: 1 / -1; } html[data-theme="matrix"] .zmw-side .zmw-mem-val { font-size: 11px; letter-spacing: -.02em; } html[data-theme="matrix"] .zmw-side .zmw-mem-val.zmw-mem-fit { font-size: 10px; letter-spacing: -.04em; } .zmw-side .zmw-mem-row { min-width: 0; display: flex; flex-direction: column; justify-content: space-between; padding: 5px 8px 6px; border-radius: 9px; background: var(--surface-2); border: 1px solid var(--border); } .zmw-side .zmw-mem-head { flex-direction: column; gap: 0; margin-bottom: 4px; } .zmw-side .zmw-mem-label { font-size: 11px; line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .zmw-side .zmw-mem-val { font-size: 12px; line-height: 1.3; color: var(--text); overflow: hidden; text-overflow: ellipsis; } .zmw-side .zmw-mem-val.zmw-mem-fit { font-size: 10.5px; letter-spacing: -.02em; } .zmw-side .zmw-mem-bar { height: 4px; } html.zm-dust-on .zmw-side .zmw-mem-row { background: transparent; } #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-main { flex: 1; min-width: 0; display: flex; flex-direction: column; } .zmw-top { --zmw-top-bg: var(--bg); position: sticky; top: 0; z-index: 30; display: flex; align-items: center; gap: 14px; padding: 18px 32px 14px; background: var(--zmw-top-bg); } .zmw-top::after { content: ''; position: absolute; left: 0; right: 0; top: 100%; height: 18px; pointer-events: none; background: linear-gradient(to bottom, var(--zmw-top-bg), transparent); } html[data-theme="dark"] .zmw-top { --zmw-top-bg: rgb(7,9,15); } html[data-theme="light"] .zmw-top { --zmw-top-bg: rgb(243,245,250); } .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; grid-template-columns: minmax(0, 1fr); place-items: center; padding: 20px; box-sizing: border-box; background: var(--bg); overflow-x: hidden; overflow-y: 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; box-sizing: border-box; min-width: 0; 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, .zmw-modal .zm-hint, .zmw-modal 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), .zmw-modal 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), html[data-theme="dark"] .zmw-modal a:not(.cbi-button) { color: #8ab4ff; } #zmw-view .zm-badge, .zm-dock .zm-badge, .zmw-modal .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 3; } #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, html[data-theme="dark"] .zmw-modal .cbi-button { background: rgba(255,255,255,.05); } #zmw-view .cbi-button:hover, .zm-dock .cbi-button:hover, .zmw-modal .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, html[data-theme="dark"] .zmw-modal .cbi-button:hover { background: rgba(255,255,255,.08); } #zmw-view .cbi-button:active, .zm-dock .cbi-button:active, .zmw-modal .cbi-button:active { transform: translateY(0) scale(.98); } #zmw-view .cbi-button:focus-visible, .zm-dock .cbi-button:focus-visible, .zmw-modal .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, .zmw-modal .cbi-button[disabled], .zmw-modal .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, .zmw-modal .cbi-button-positive, html[data-theme="dark"] .zmw-modal .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, .zmw-modal .cbi-button-positive:hover, html[data-theme="dark"] .zmw-modal .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, .zmw-modal .cbi-button-remove, html[data-theme="dark"] .zmw-modal .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, .zmw-modal .cbi-button-remove:hover, html[data-theme="dark"] .zmw-modal .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, .zmw-modal .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, .zmw-modal .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, .zmw-modal .zmw-tabs .cbi-button-positive, .zmw-modal .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-src { border-color: var(--border-2, var(--border)); } #zmw-view .zm-cat-src-title { color: var(--text); } #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-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; } html[data-theme="retro"] #zmw-view .zm-tg-link-card { background: #ffffff; color: #000000; border: 2px solid; border-color: #808080 #ffffff #ffffff #808080; border-radius: 0; } #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-edit { background: var(--surface-2); border: 1px solid var(--border, rgba(100,116,139,.22)); border-radius: 14px; } #zmw-view .zm-tg-chip-ok { background: var(--ok-bg); color: var(--ok); } #zmw-view .zm-tg-chip-bad { background: var(--bad-bg); color: var(--bad); } #zmw-view .zm-tg-chip-wait { background: var(--warn-bg); color: var(--warn); } #zmw-view .zm-tg-edit-in, #zmw-view .zm-tg-key { font-family: var(--mono); } #zmw-view .zm-tg-qr-box img { border-radius: 14px; box-shadow: var(--shadow); } #zmw-view .zm-ab-tile { background: var(--surface-2); border-color: var(--border); } #zmw-view .zm-rm-tile { background: var(--surface-2); border-color: var(--border); } #zmw-view .zm-rm-tile.zm-rm-danger { border-color: rgba(239,68,68,.45); } #zmw-view .zm-pg { border-top-color: var(--border); } #zmw-view .zm-pg-name { color: var(--text); } #zmw-view .zm-pg-sub, #zmw-view .zm-pg-group-title { color: var(--text-2); opacity: 1; } #zmw-view .zm-ab-stats-off { border-color: var(--border-2); } #zmw-view .zm-ab-rank-row { border-top-color: var(--border); } #zmw-view .zm-ab-bar { background: var(--surface-2); } #zmw-view .zm-ab-tile-label, #zmw-view .zm-ab-tile-sub { color: var(--text-2); opacity: 1; } #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 { display: none; } #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-swrow.zm-afix-row { background: var(--surface-2); border-color: var(--border); border-radius: 14px; } #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-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-th-hero { background: var(--grad-soft); } #zmw-view .zm-th-text p, #zmw-view .zm-th-author, #zmw-view .zm-th-ghead { color: var(--muted); opacity: 1; } #zmw-view .zm-th-rows { border-color: var(--border); border-radius: var(--radius-sm); background: var(--surface-2); } #zmw-view .zm-th-row + .zm-th-row { border-top-color: var(--border); } #zmw-view .zm-th-row a { color: var(--a1); } #zmw-view .zm-th-row.zm-th-self { background: var(--grad-soft); } #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 .zm-term { background: var(--console); border: 1px solid var(--console-border); border-radius: 16px; box-shadow: inset 0 1px 0 rgba(255,255,255,.04), var(--shadow); } #zmw-view .zm-term-bar { height: 40px; padding: 0 20px; font-family: var(--mono); } #zmw-view .zm-term-body { padding: 8px 15px 12px; } #zmw-view .zm-term iframe.zm-term-frame { border: 0 !important; border-radius: 0 !important; background: var(--console); box-shadow: none; } @media (max-width: 600px) { #zmw-view .zm-term-bar { padding: 0 14px; } #zmw-view .zm-term-body { padding: 6px 9px 10px; } } #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; } .zmw-body .zm-toast-busy { border-left-color: var(--a1, #7c5cff); } .zmw-body .zm-toast-spin { border-color: rgba(255,255,255,.24); border-top-color: #a9b6ff; } html.zm-theme-dark .zmw-body .zm-toast-spin, html.zm-theme-dark.zmw-body .zm-toast-spin { border-color: rgba(124,92,255,.25); border-top-color: var(--a1, #7c5cff); } @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; white-space: normal; text-overflow: clip; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-height: 1.12; overflow-wrap: anywhere; } #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, .zmw-modal .zm-actions .cbi-button { flex: 1 1 auto; } #zmw-view .zmw-tabs .cbi-button, .zm-dock .zmw-tabs .cbi-button, .zmw-modal .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-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; } html .zm-toast-spin, html .zm-reboot-spin, html .zmw-btn-primary.zmw-loading::after { animation-duration: 1.4s !important; animation-iteration-count: infinite !important; } html .zm-tsc-spin { animation-duration: 1.2s !important; animation-iteration-count: infinite !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-st-stat.zm-fk-top, #zmw-view .zm-fk-sec.zm-fk-sec-main { border-color: var(--a1); } #zmw-view .zm-fk-sec.zm-fk-sec-cur { border-color: var(--a1); box-shadow: inset 0 0 0 1px var(--a1); } #zmw-view .zm-fk-tile { background: var(--surface-3); } #zmw-view .zm-fk-tile > b, #zmw-view .zm-fk-info > div > b { color: var(--text); } #zmw-view .zm-fk-tile > span, #zmw-view .zm-fk-info > div > span { color: var(--muted); opacity: 1; } #zmw-view .zm-fk-info { border-top-color: var(--border); } #zmw-view .zm-fk-chip { background: var(--surface-3); color: var(--text); } #zmw-view .zm-fk-chip.zm-fk-chip-x { background: transparent; border-color: var(--border-2); color: var(--muted); opacity: 1; } #zmw-view .zm-fk-chip.zm-fk-chip-more { background: var(--surface-3); 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-lat-blk { color: var(--warn); } #zmw-view .zm-ab-chip { background: var(--ok-bg); color: var(--ok); } #zmw-view .zm-ab-doms .zm-ab-own { color: var(--ok); } #zmw-view .zm-home-banner { font-size: 13.5px; padding: 12px 16px; } .zmw-modal.zm-wn { max-height: calc(100vh - 32px); max-height: calc(100dvh - 32px); } .zmw-modal.zm-wn .zm-wn-list li { background: var(--surface-2); } .zmw-modal.zm-wn .zm-wn-lead, .zmw-modal.zm-wn .zm-wn-list li span { color: var(--muted); opacity: 1; } .zmw-modal.zm-wn .zm-wn-check input { accent-color: var(--a1); } .zmw-modal.zm-wn .zm-wn-tip { background: var(--grad-soft); } .zmw-modal.zm-ts .zm-ts-where span { background: var(--surface-3); color: var(--text-2); } .zmw-modal.zm-ts .zm-ts-steps li::before { background: var(--grad-soft); color: var(--a1); } .zmw-modal.zm-ts .zm-kbd { border-color: var(--border-2); background: var(--surface-2); } #zmw-view .zm-fo-chain > *::before, #zmw-view .zm-fo-chain > *::after { background: var(--border-2); } #zmw-view .zm-fo-num { background: var(--surface-solid); border-color: var(--border-2); color: var(--muted); } #zmw-view .zm-fo-card { background: var(--surface-2); border-color: var(--border); border-radius: var(--radius-sm); } #zmw-view .zm-fo-main .zm-fo-num { border-color: var(--a1); color: var(--a1); } #zmw-view .zm-fo-tag { background: var(--grad-soft); color: var(--a1); } #zmw-view .zm-fo-tag.zm-fo-tag-now { background: var(--ok-bg); color: var(--ok); } #zmw-view .zm-fo-step.zm-fo-now .zm-fo-num { background: var(--ok); border-color: var(--ok); color: #fff; } html.zm-theme-dark #zmw-view .zm-fo-step.zm-fo-now .zm-fo-num { background: var(--ok-dot); border-color: var(--ok-dot); color: #052e1c; } html[data-theme="ink"] #zmw-view .zm-fo-tag { background: #ffffff; color: #0a0a0a; box-shadow: inset 0 0 0 1.5px #0a0a0a; } #zmw-view .zm-fo-step.zm-fo-now .zm-fo-card { border-color: var(--ok-dot); box-shadow: 0 0 0 3px var(--ok-bg); } #zmw-view .zm-fo-sub { color: var(--muted); opacity: 1; } #zmw-view .zm-fo-sub.zm-fo-warn { color: var(--warn); } #zmw-view .zm-sub-meta { background: var(--grad-soft); border: 1px solid rgba(124,92,255,.22); border-radius: 14px; } #zmw-view .zm-sub-meta.zm-sub-expired { background: rgba(207,34,46,.08); border-color: rgba(207,34,46,.4); } #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: stretch; } } #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-dust { position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 0; } .zmw-login .zmw-dust { position: absolute; } html.zm-dust-on .zmw-shell { position: relative; z-index: 1; } html[data-theme="matrix"] .zmw-login .zmw-dust { display: none; } .zmw-anim-sw { flex: none; margin-left: auto; width: 30px; height: 18px; border-radius: 999px; background: var(--border-2); position: relative; transition: background .2s; } .zmw-anim-sw i { position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: #ffffff; box-shadow: 0 1px 2px rgba(0,0,0,.3); transition: transform .2s, background .2s; } .zmw-anim-on .zmw-anim-sw { background: var(--a1); } .zmw-anim-on .zmw-anim-sw i { transform: translateX(12px); background: var(--surface-solid, #ffffff); } html[data-theme="retro"] .zmw-anim-sw, html[data-theme="retro"] .zmw-anim-sw i, html[data-theme="minimal"] .zmw-anim-sw, html[data-theme="minimal"] .zmw-anim-sw i { border-radius: 0; } @media (prefers-reduced-motion: reduce) { .zmw-anim-sw, .zmw-anim-sw i { transition: none; } } .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 { --zmw-top-bg: #ffffff; background: var(--zmw-top-bg); } 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, html[data-theme="ink"] .zmw-modal .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, html[data-theme="ink"] .zmw-modal .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, html[data-theme="ink"] .zmw-modal .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, html[data-theme="ink"] .zmw-modal .cbi-button-positive, html[data-theme="ink"] .zmw-modal .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, html[data-theme="ink"] .zmw-modal .cbi-button-remove, html[data-theme="ink"] .zmw-modal .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, html[data-theme="ink"] .zmw-modal .cbi-button-action, html[data-theme="ink"] .zmw-modal .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], html[data-theme="ink"] .zmw-modal .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, html[data-theme="ink"] .zmw-modal .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, html[data-theme="ink"] .zmw-modal .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, html[data-theme="ink"] .zmw-modal .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, html[data-theme="ink"] .zmw-modal .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 { 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), html[data-theme="ink"] .zmw-modal 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 { --zmw-top-bg: rgb(239,235,228); background: var(--zmw-top-bg); } 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; } @media (max-width: 960px) { html[data-theme="bento"] .zmw-side { background: var(--side-bg); box-shadow: var(--shadow-lg); } } 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, html[data-theme="bento"] .zmw-modal .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, html[data-theme="bento"] .zmw-modal .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, html[data-theme="bento"] .zmw-modal .cbi-button-positive, html[data-theme="bento"] .zmw-modal .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, html[data-theme="bento"] .zmw-modal .cbi-button-remove, html[data-theme="bento"] .zmw-modal .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, html[data-theme="bento"] .zmw-modal .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 { --zmw-top-bg: #ffffff; background: var(--zmw-top-bg); } 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, html[data-theme="minimal"] .zmw-modal .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, html[data-theme="minimal"] .zmw-modal .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, html[data-theme="minimal"] .zmw-modal .cbi-button-positive, html[data-theme="minimal"] .zmw-modal .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, html[data-theme="minimal"] .zmw-modal .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, html[data-theme="minimal"] .zmw-modal .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, html[data-theme="minimal"] .zmw-modal .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, html[data-theme="minimal"] .zmw-modal .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 { --zmw-top-bg: #008080; background: var(--zmw-top-bg); } 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-nav-ico { border-radius: 0; } html[data-theme="retro"] #zmw-view .zm-seg { 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-seg-item, html[data-theme="retro"] #zmw-view .zm-route-chip { border-radius: 0; } html[data-theme="retro"] #zmw-view .zm-ab-chip, html[data-theme="retro"] #zmw-view .zm-chip { border-radius: 0; } html[data-theme="retro"] #zmw-view .zm-ab-hero::after { display: none; } html[data-theme="retro"] #zmw-view .zm-ab-hero .zm-ab-head h3 { flex: 1 1 auto; } 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 .zm-card h3 .zm-badge { color: #ffffff; background: rgba(255,255,255,.14); padding: 0 6px; } /* температура на сером окне: стандартные цвета слишком светлые */ html[data-theme="retro"] #zmw-view .zm-mon-t-ok { color: #006400; } html[data-theme="retro"] #zmw-view .zm-mon-t-warn { color: #7a4100; } html[data-theme="retro"] #zmw-view .zm-mon-t-bad { color: #a00000; } 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, html[data-theme="retro"] .zmw-modal .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, html[data-theme="retro"] .zmw-modal .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, html[data-theme="retro"] .zmw-modal .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, html[data-theme="retro"] .zmw-modal .cbi-button-positive, html[data-theme="retro"] .zmw-modal .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, html[data-theme="retro"] .zmw-modal .cbi-button-remove, html[data-theme="retro"] .zmw-modal .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, html[data-theme="retro"] .zmw-modal .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, html[data-theme="retro"] .zmw-modal .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 { --zmw-top-bg: rgb(10,14,24); background: var(--zmw-top-bg); } 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, html[data-theme="depth"] .zmw-modal .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, html[data-theme="depth"] .zmw-modal .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, html[data-theme="depth"] .zmw-modal .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, html[data-theme="depth"] .zmw-modal .cbi-button-positive, html[data-theme="depth"] .zmw-modal .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, html[data-theme="depth"] .zmw-modal .cbi-button-remove, html[data-theme="depth"] .zmw-modal .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-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 { --zmw-top-bg: #f4f5f8; background: var(--zmw-top-bg); } 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 { 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, html[data-theme="micro"] .zmw-modal .zm-hint, html[data-theme="micro"] .zmw-modal 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), html[data-theme="micro"] .zmw-modal 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, html[data-theme="micro"] .zmw-modal .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, html[data-theme="micro"] .zmw-modal .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, html[data-theme="micro"] .zmw-modal .cbi-button-positive, html[data-theme="micro"] .zmw-modal .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, html[data-theme="micro"] .zmw-modal .cbi-button-remove, html[data-theme="micro"] .zmw-modal .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, html[data-theme="micro"] .zmw-modal .cbi-button-action, html[data-theme="micro"] .zmw-modal .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, html[data-theme="micro"] .zmw-modal .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-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, html[data-theme="micro"] .zmw-modal .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, html[data-theme="micro"] .zmw-modal .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, html[data-theme="micro"] .zmw-modal .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, html[data-theme="micro"] .zmw-modal .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, html[data-theme="micro"] .zmw-modal .zm-badge .zm-dot { animation: zmw-micro-ring 2s 3; } @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, html[data-theme="ink"] .zmw-modal .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, html[data-theme="ink"] .zmw-modal .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, html[data-theme="ink"] .zmw-modal .zmw-tabs .cbi-button-positive, html[data-theme="ink"] .zmw-modal .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, html[data-theme="bento"] .zmw-modal .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, html[data-theme="bento"] .zmw-modal .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, html[data-theme="bento"] .zmw-modal .zmw-tabs .cbi-button-positive, html[data-theme="bento"] .zmw-modal .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, html[data-theme="minimal"] .zmw-modal .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, html[data-theme="minimal"] .zmw-modal .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, html[data-theme="minimal"] .zmw-modal .zmw-tabs .cbi-button-positive, html[data-theme="minimal"] .zmw-modal .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, html[data-theme="retro"] .zmw-modal .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, html[data-theme="retro"] .zmw-modal .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, html[data-theme="retro"] .zmw-modal .zmw-tabs .cbi-button-positive, html[data-theme="retro"] .zmw-modal .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, html[data-theme="depth"] .zmw-modal .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, html[data-theme="depth"] .zmw-modal .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, html[data-theme="depth"] .zmw-modal .zmw-tabs .cbi-button-positive, html[data-theme="depth"] .zmw-modal .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, html[data-theme="micro"] .zmw-modal .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, html[data-theme="micro"] .zmw-modal .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, html[data-theme="micro"] .zmw-modal .zmw-tabs .cbi-button-positive, html[data-theme="micro"] .zmw-modal .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, html[data-theme="micro"] .zmw-modal .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, .zmw-modal .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, .zmw-modal .zm-savebar-dot { background: var(--warn-dot); box-shadow: 0 0 0 4px var(--warn-bg); } #zmw-view .zm-savebar.zm-savebar-done, .zm-dock .zm-savebar.zm-savebar-done, .zmw-modal .zm-savebar.zm-savebar-done { border-color: var(--ok-dot); } #zmw-view .zm-savebar-done .zm-savebar-dot, .zm-dock .zm-savebar-done .zm-savebar-dot, .zmw-modal .zm-savebar-done .zm-savebar-dot { background: var(--ok-dot); box-shadow: 0 0 0 4px var(--ok-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-svc-dom { border-color: var(--border-2, var(--border)); color: var(--text-2, var(--text)); } #zmw-view .zm-svc-dom:hover { border-color: var(--a1); } #zmw-view .zm-svc-dom:focus-visible { outline-color: var(--a1); } #zmw-view .zm-svc-dom.zm-active { background: var(--grad); border-color: transparent; color: #fff; } #zmw-view .zm-dom-panel { background: var(--surface-2); border-color: var(--border-2, var(--border)); border-radius: 14px; } #zmw-view .zm-dom-title b { color: var(--text); } #zmw-view .zm-dom-sub, #zmw-view .zm-dom-more { color: var(--muted); opacity: 1; } #zmw-view .zm-dom-list, html.zm-theme-dark #zmw-view .zm-dom-list { background: var(--surface-solid); border-color: var(--border); } #zmw-view .zm-dom-item { font-family: var(--mono); color: var(--text); border-bottom-color: var(--border); } #zmw-view .zm-find-open { border-color: var(--border-2, var(--border)); color: var(--text); } #zmw-view .zm-find-open:hover { border-color: var(--a1); background: var(--surface-2); } #zmw-view .zm-find-open .zm-find-ico { color: var(--a1, #6d4cff); } .zm-find-wrap { background: rgba(5,7,12,.5); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); } .zm-find { background: var(--surface-solid, #fff); color: var(--text, #0f172a); border: 1px solid rgba(124,92,255,.42); box-shadow: var(--shadow-lg); font-family: var(--font, inherit); } html.zm-theme-dark .zm-find { background: var(--surface-solid, #1c2128); color: var(--text, #e6edf3); } .zm-find-field { border-bottom-color: var(--border, rgba(110,118,129,.18)); } .zm-find-field > .zm-find-ico { color: var(--a1, #6d4cff); } .zm-find-input, .zm-find-input:focus { font-family: var(--mono); color: var(--text); } .zm-find-x { background: var(--surface-2); color: var(--text-2, var(--text)); } .zm-find-x:hover { background: var(--surface-3, var(--surface-2)); } .zm-find-spin { animation: zmFindSpin .9s linear infinite !important; } .zm-find-title, .zm-find-why, .zm-find-empty > span, .zm-find-note { color: var(--muted); opacity: 1; } .zm-find-row:hover, .zm-find-row:focus-visible { background: var(--surface-2); border-color: var(--border-2, var(--border)); } .zm-find-row.zm-on { background: var(--grad-soft, rgba(124,92,255,.1)); border-color: rgba(124,92,255,.45); } .zm-find-best { background: var(--grad); } .zm-find-foot { border-top-color: var(--border); } .zm-find .zm-switch { background: var(--border-2, rgba(110,118,129,.35)); } .zm-find .zm-switch.zm-switch-on { background: var(--grad, #1aa3ff); } .zm-find .cbi-button { border-radius: 12px; } .zm-find .cbi-button-action { background: var(--grad); border-color: transparent; color: #fff; } .zm-find .cbi-button[disabled] { opacity: .55; } .zmw-modal.zm-find { width: 100%; max-width: 640px; max-height: 82vh; padding: 0; overflow: hidden; border-radius: 22px; border: 1px solid rgba(124,92,255,.42); } .zmw-modal.zm-find .zm-find-input, .zmw-modal.zm-find .zm-find-input:focus { width: auto; height: 44px; padding: 0; border: 0; border-radius: 0; background: transparent; box-shadow: none; font-size: 19px; } @media (max-width: 600px) { .zmw-modal.zm-find { max-height: calc(100vh - 24px); border-radius: 18px; } .zmw-modal.zm-find .zm-find-input { font-size: 16px; } } #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, html body.zmw-body .zmw-modal { left: calc(var(--side-w) + (100vw - var(--side-w)) / 2); width: min(860px, calc(100vw - var(--side-w) - 48px)); } } .zm-dock .zm-savebar, .zmw-modal .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); } .zmw-body .zm-dock .zm-refresh-banner, .zmw-body .zmw-modal .zm-refresh-banner { margin: 0 !important; background: var(--surface-solid); border: 1px solid rgba(245,158,11,.5); border-radius: var(--radius-sm); color: var(--text); box-shadow: var(--shadow-lg); display: flex; align-items: center; justify-content: space-between; gap: 14px; } .zm-dock .zm-savebar-dot, .zmw-modal .zm-savebar-dot { background: var(--warn-dot); box-shadow: 0 0 0 4px var(--warn-bg); } .zm-dock .cbi-button, .zmw-modal .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-chip.zm-route-chip-on { border-color: var(--a1); background: var(--grad-soft); 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; } #zmw-view .zm-ab-desc { background: var(--surface-2); border-color: var(--border); border-radius: var(--radius-sm); } #zmw-view .zm-ab-pips > i { background: var(--surface-3); } #zmw-view .zm-ab-pips > i.zm-on { background: var(--grad); } #zmw-view .zm-ab-warn { color: var(--warn); } html #zmw-view select:not([multiple]), html .zmw-modal select:not([multiple]) { 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; padding-right: 38px; } html[data-theme="graphite"] { --font: "Space Grotesk", system-ui, sans-serif; --a1: #ffb020; --a2: #ffb020; --a3: #ffcf6e; --grad: #ffb020; --grad-soft: rgba(255,176,32,.12); --ring: 0 0 0 3px rgba(255,176,32,.35); --radius: 12px; --radius-sm: 8px; --bg: #141416; --bg-glow-1: transparent; --bg-glow-2: transparent; --surface: #1c1c20; --surface-solid: #1c1c20; --surface-2: rgba(255,255,255,.035); --surface-3: rgba(255,255,255,.07); --side-bg: #18181b; --border: #2b2b31; --border-2: #3a3a42; --text: #ececef; --text-2: #c9c9cf; --muted: #9a9aa3; --shadow: 0 1px 0 rgba(255,255,255,.03) inset; --shadow-lg: 0 30px 70px -30px rgba(0,0,0,.9); --input-bg: #141416; --console: #0e0e10; --console-border: #2b2b31; --ok: #7ee2a8; --ok-bg: rgba(126,226,168,.10); --ok-dot: #4ade80; --bad: #ff8a80; --bad-bg: rgba(255,90,78,.12); --bad-dot: #ff5a4e; --warn: #ffcf6e; --warn-bg: rgba(255,176,32,.12); --warn-dot: #ffb020; --off: #9a9aa3; --off-bg: rgba(154,154,163,.10); color-scheme: dark; } html[data-theme="graphite"] body.zmw-body { background-image: none; } html[data-theme="graphite"] .zmw-orbs, html[data-theme="graphite"] .zmw-grid-bg { display: none; } html[data-theme="graphite"] .zmw-top { --zmw-top-bg: rgb(20,20,22); background: var(--zmw-top-bg); } html[data-theme="graphite"] .zmw-title { font-weight: 700; letter-spacing: -.02em; } html[data-theme="graphite"] .zmw-side { background: #18181b; border-right: 1px solid #2b2b31; box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; } html[data-theme="graphite"] .zmw-nav-item { color: #b4b4bc; } html[data-theme="graphite"] .zmw-nav-item:hover { background: rgba(255,255,255,.04); color: #ececef; } html[data-theme="graphite"] .zmw-nav-item.zmw-active { background: rgba(255,176,32,.10); color: #ffcf6e; box-shadow: none; } html[data-theme="graphite"] .zmw-nav-item.zmw-active::before { background: #ffb020; } html[data-theme="graphite"] .zmw-nav-ico { background: #222227; border-color: #2f2f35; color: #9a9aa3; } html[data-theme="graphite"] .zmw-nav-item.zmw-active .zmw-nav-ico { background: #ffb020; color: #1b1300; border-color: #ffb020; box-shadow: none; } html[data-theme="graphite"] .zmw-mem-bar { background: #2b2b31; box-shadow: none; } html[data-theme="graphite"] .zmw-mem-bar i { background: #ffb020; box-shadow: none; } html[data-theme="graphite"] #zmw-view .zm-card { background: #1c1c20; border: 1px solid #2b2b31; box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; } html[data-theme="graphite"] #zmw-view .zm-card:hover { border-color: #3a3a42; box-shadow: none; } html[data-theme="graphite"] #zmw-view .zm-card h3 { font-weight: 700; letter-spacing: -.01em; color: #ececef; } html[data-theme="graphite"] #zmw-view .zm-card h3::before { background: #ffb020; } html[data-theme="graphite"] #zmw-view .cbi-button, html[data-theme="graphite"] .zmw-icon-btn, html[data-theme="graphite"] .zm-dock .cbi-button, html[data-theme="graphite"] .zmw-modal .cbi-button { background: #26262b; border: 1px solid #34343a; color: #ececef; box-shadow: none; } html[data-theme="graphite"] #zmw-view .cbi-button:hover, html[data-theme="graphite"] .zmw-icon-btn:hover, html[data-theme="graphite"] .zm-dock .cbi-button:hover, html[data-theme="graphite"] .zmw-modal .cbi-button:hover { background: #2f2f35; border-color: #46464f; transform: none; box-shadow: none; } html[data-theme="graphite"] #zmw-view .cbi-button-positive, html[data-theme="graphite"] #zmw-view .cbi-button-positive:hover, html[data-theme="graphite"] .zmw-btn-primary, html[data-theme="graphite"] .zm-dock .cbi-button-positive, html[data-theme="graphite"] .zm-dock .cbi-button-positive:hover, html[data-theme="graphite"] .zmw-modal .cbi-button-positive, html[data-theme="graphite"] .zmw-modal .cbi-button-positive:hover { background: #ffb020; color: #1b1300; border-color: #ffb020; box-shadow: none; } html[data-theme="graphite"] #zmw-view .cbi-button-action, html[data-theme="graphite"] #zmw-view .cbi-button-action:hover, html[data-theme="graphite"] .zm-dock .cbi-button-action, html[data-theme="graphite"] .zmw-modal .cbi-button-action { background: rgba(255,176,32,.14); color: #ffcf6e; border-color: rgba(255,176,32,.45); } html[data-theme="graphite"] #zmw-view .cbi-button-remove, html[data-theme="graphite"] #zmw-view .cbi-button-remove:hover, html[data-theme="graphite"] .zm-dock .cbi-button-remove, html[data-theme="graphite"] .zm-dock .cbi-button-remove:hover, html[data-theme="graphite"] .zmw-modal .cbi-button-remove, html[data-theme="graphite"] .zmw-modal .cbi-button-remove:hover { background: rgba(255,90,78,.12); color: #ff8a80; border-color: rgba(255,90,78,.45); box-shadow: none; } html[data-theme="graphite"] #zmw-view .zm-tile { background: #222227; border: 1px solid #2f2f35; box-shadow: none; } html[data-theme="graphite"] #zmw-view .zm-tile:hover { border-color: #46464f; } html[data-theme="graphite"] #zmw-view .zm-tile.zm-active { background: #ffb020; color: #1b1300; border-color: #ffb020; } html[data-theme="graphite"] #zmw-view .zm-tile.zm-active::before { color: #1b1300; } html[data-theme="graphite"] #zmw-view .zm-badge .zm-dot, html[data-theme="graphite"] .zm-dock .zm-badge .zm-dot, html[data-theme="graphite"] .zmw-modal .zm-badge .zm-dot { box-shadow: none; } html[data-theme="graphite"] #zmw-view input:not([type=checkbox]):not([type=radio]):not([type=range]), html[data-theme="graphite"] #zmw-view select, html[data-theme="graphite"] #zmw-view textarea:not(.zm-config-editor), html[data-theme="graphite"] .zmw-input, html[data-theme="graphite"] .zmw-modal input:not([type=checkbox]):not([type=radio]), html[data-theme="graphite"] .zmw-modal select { background: #141416; border-color: #34343a; color: #ececef; } html[data-theme="graphite"] #zmw-view .zm-seg { background: #141416; border-color: #2b2b31; } html[data-theme="graphite"] #zmw-view .zm-seg-item.zm-active { background: #ffb020; color: #1b1300; } html[data-theme="graphite"] .zmw-login-card, html[data-theme="graphite"] .zmw-modal, html[data-theme="graphite"] .zmw-theme-menu { background: #1c1c20; border: 1px solid #34343a; backdrop-filter: none; -webkit-backdrop-filter: none; } html[data-theme="graphite"] .zmw-theme-opt.zmw-on { background: rgba(255,176,32,.12); color: #ffcf6e; } html[data-theme="graphite"] .zmw-theme-mark { color: #ffb020; } html[data-theme="graphite"] #zmw-view .zm-actions.zmw-tabs { background: #1c1c20; border: 1px solid #2b2b31; box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; } html[data-theme="graphite"] #zmw-view .zmw-tabs .cbi-button, html[data-theme="graphite"] .zm-dock .zmw-tabs .cbi-button, html[data-theme="graphite"] .zmw-modal .zmw-tabs .cbi-button { background: transparent; border: 0; box-shadow: none; color: #9a9aa3; } html[data-theme="graphite"] #zmw-view .zmw-tabs .cbi-button:hover, html[data-theme="graphite"] .zm-dock .zmw-tabs .cbi-button:hover, html[data-theme="graphite"] .zmw-modal .zmw-tabs .cbi-button:hover { background: rgba(255,255,255,.04); color: #ececef; transform: none; } html[data-theme="graphite"] #zmw-view .zmw-tabs .cbi-button-positive, html[data-theme="graphite"] #zmw-view .zmw-tabs .cbi-button-positive:hover, html[data-theme="graphite"] .zm-dock .zmw-tabs .cbi-button-positive, html[data-theme="graphite"] .zm-dock .zmw-tabs .cbi-button-positive:hover, html[data-theme="graphite"] .zmw-modal .zmw-tabs .cbi-button-positive, html[data-theme="graphite"] .zmw-modal .zmw-tabs .cbi-button-positive:hover { background: #ffb020; color: #1b1300; box-shadow: none; } html[data-theme="graphite"] .zmw-icon-btn.zmw-link-kvn, html[data-theme="graphite"] .zmw-icon-btn.zmw-link-kvn:hover { background: #ffb020; color: #1b1300; border-color: #ffb020; } html[data-theme="graphite"] .zmw-icon-btn.zmw-link-tg, html[data-theme="graphite"] .zmw-icon-btn.zmw-link-tg:hover { background: rgba(255,176,32,.10); color: #ffcf6e; border-color: rgba(255,176,32,.35); } html[data-theme="graphite"] .zmw-brand-name b { background: none; -webkit-background-clip: border-box; background-clip: border-box; color: #ffb020; } html[data-theme="graphite"] .zm-stopbar { background: #2a1614; color: #ffd0cb; border-color: rgba(255,90,78,.5); } html[data-theme="oled"] { --font: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; --a1: #ffffff; --a2: #ffffff; --a3: #d4d4d4; --grad: #ffffff; --grad-soft: rgba(255,255,255,.08); --ring: 0 0 0 3px rgba(255,255,255,.28); --radius: 18px; --radius-sm: 12px; --bg: #000000; --bg-glow-1: transparent; --bg-glow-2: transparent; --surface: #000000; --surface-solid: #0a0a0a; --surface-2: rgba(255,255,255,.04); --surface-3: rgba(255,255,255,.08); --side-bg: #000000; --border: #1c1c1c; --border-2: #2a2a2a; --text: #ffffff; --text-2: #d4d4d4; --muted: #8a8a8a; --shadow: none; --shadow-lg: 0 30px 80px -30px rgba(0,0,0,1); --input-bg: #000000; --console: #000000; --console-border: #1c1c1c; --ok: #6ee7a0; --ok-bg: rgba(110,231,160,.10); --ok-dot: #4ade80; --bad: #ff8a80; --bad-bg: rgba(255,90,78,.12); --bad-dot: #ff5a4e; --warn: #fcd34d; --warn-bg: rgba(252,211,77,.10); --warn-dot: #f5b70a; --off: #8a8a8a; --off-bg: rgba(138,138,138,.10); color-scheme: dark; } html[data-theme="oled"] body.zmw-body { background-image: none; } html[data-theme="oled"] .zmw-orbs, html[data-theme="oled"] .zmw-grid-bg { display: none; } html[data-theme="oled"] .zmw-top { --zmw-top-bg: rgb(0,0,0); background: var(--zmw-top-bg); } html[data-theme="oled"] .zmw-title { font-weight: 800; letter-spacing: -.025em; } html[data-theme="oled"] .zmw-side { background: #000000; border-right: 1px solid #1c1c1c; box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; } html[data-theme="oled"] .zmw-nav-item { color: #a3a3a3; } html[data-theme="oled"] .zmw-nav-item:hover { background: rgba(255,255,255,.06); color: #ffffff; } html[data-theme="oled"] .zmw-nav-item.zmw-active { background: rgba(255,255,255,.10); color: #ffffff; box-shadow: none; } html[data-theme="oled"] .zmw-nav-item.zmw-active::before { background: #ffffff; } html[data-theme="oled"] .zmw-nav-ico { background: #0d0d0d; border-color: #262626; color: #8a8a8a; } html[data-theme="oled"] .zmw-nav-item.zmw-active .zmw-nav-ico { background: #ffffff; color: #000000; border-color: #ffffff; box-shadow: none; } html[data-theme="oled"] .zmw-mem-bar { background: #1c1c1c; box-shadow: none; } html[data-theme="oled"] .zmw-mem-bar i { background: #ffffff; box-shadow: none; } html[data-theme="oled"] #zmw-view .zm-card { background: #000000; border: 1px solid #1c1c1c; box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; } html[data-theme="oled"] #zmw-view .zm-card:hover { border-color: #333333; box-shadow: none; } html[data-theme="oled"] #zmw-view .zm-card h3 { font-weight: 700; letter-spacing: -.01em; color: #ffffff; } html[data-theme="oled"] #zmw-view .cbi-button, html[data-theme="oled"] .zmw-icon-btn, html[data-theme="oled"] .zm-dock .cbi-button, html[data-theme="oled"] .zmw-modal .cbi-button { background: #0d0d0d; border: 1px solid #2a2a2a; color: #ffffff; box-shadow: none; } html[data-theme="oled"] #zmw-view .cbi-button:hover, html[data-theme="oled"] .zmw-icon-btn:hover, html[data-theme="oled"] .zm-dock .cbi-button:hover, html[data-theme="oled"] .zmw-modal .cbi-button:hover { background: #262626; border-color: #3d3d3d; transform: none; box-shadow: none; } html[data-theme="oled"] #zmw-view .cbi-button-positive, html[data-theme="oled"] #zmw-view .cbi-button-positive:hover, html[data-theme="oled"] .zmw-btn-primary, html[data-theme="oled"] .zm-dock .cbi-button-positive, html[data-theme="oled"] .zm-dock .cbi-button-positive:hover, html[data-theme="oled"] .zmw-modal .cbi-button-positive, html[data-theme="oled"] .zmw-modal .cbi-button-positive:hover { background: #ffffff; color: #000000; border-color: #ffffff; box-shadow: none; } html[data-theme="oled"] #zmw-view .cbi-button-action, html[data-theme="oled"] #zmw-view .cbi-button-action:hover, html[data-theme="oled"] .zm-dock .cbi-button-action, html[data-theme="oled"] .zmw-modal .cbi-button-action { background: rgba(255,255,255,.14); color: #ffffff; border-color: rgba(255,255,255,.45); } html[data-theme="oled"] #zmw-view .cbi-button-remove, html[data-theme="oled"] #zmw-view .cbi-button-remove:hover, html[data-theme="oled"] .zm-dock .cbi-button-remove, html[data-theme="oled"] .zm-dock .cbi-button-remove:hover, html[data-theme="oled"] .zmw-modal .cbi-button-remove, html[data-theme="oled"] .zmw-modal .cbi-button-remove:hover { background: rgba(255,90,78,.12); color: #ff8a80; border-color: rgba(255,90,78,.45); box-shadow: none; } html[data-theme="oled"] #zmw-view .zm-tile { background: #0d0d0d; border: 1px solid #262626; box-shadow: none; } html[data-theme="oled"] #zmw-view .zm-tile:hover { border-color: #3d3d3d; } html[data-theme="oled"] #zmw-view .zm-tile.zm-active::before { color: #000000; } html[data-theme="oled"] #zmw-view .zm-badge .zm-dot, html[data-theme="oled"] .zm-dock .zm-badge .zm-dot, html[data-theme="oled"] .zmw-modal .zm-badge .zm-dot { box-shadow: none; } html[data-theme="oled"] #zmw-view input:not([type=checkbox]):not([type=radio]):not([type=range]), html[data-theme="oled"] #zmw-view select, html[data-theme="oled"] #zmw-view textarea:not(.zm-config-editor), html[data-theme="oled"] .zmw-input, html[data-theme="oled"] .zmw-modal input:not([type=checkbox]):not([type=radio]), html[data-theme="oled"] .zmw-modal select { background: #000000; border-color: #2a2a2a; color: #ffffff; } html[data-theme="oled"] #zmw-view .zm-seg { background: #000000; border-color: #1c1c1c; } html[data-theme="oled"] #zmw-view .zm-seg-item.zm-active { background: #ffffff; color: #000000; } html[data-theme="oled"] .zmw-login-card, html[data-theme="oled"] .zmw-modal, html[data-theme="oled"] .zmw-theme-menu { background: #000000; border: 1px solid #2a2a2a; backdrop-filter: none; -webkit-backdrop-filter: none; } html[data-theme="oled"] .zmw-theme-opt.zmw-on { background: rgba(255,255,255,.12); color: #ffffff; } html[data-theme="oled"] .zmw-theme-mark { color: #ffffff; } html[data-theme="oled"] #zmw-view .zm-actions.zmw-tabs { background: #000000; border: 1px solid #1c1c1c; box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; } html[data-theme="oled"] #zmw-view .zmw-tabs .cbi-button, html[data-theme="oled"] .zm-dock .zmw-tabs .cbi-button, html[data-theme="oled"] .zmw-modal .zmw-tabs .cbi-button { background: transparent; border: 0; box-shadow: none; color: #8a8a8a; } html[data-theme="oled"] #zmw-view .zmw-tabs .cbi-button:hover, html[data-theme="oled"] .zm-dock .zmw-tabs .cbi-button:hover, html[data-theme="oled"] .zmw-modal .zmw-tabs .cbi-button:hover { background: rgba(255,255,255,.06); color: #ffffff; transform: none; } html[data-theme="oled"] #zmw-view .zmw-tabs .cbi-button-positive, html[data-theme="oled"] #zmw-view .zmw-tabs .cbi-button-positive:hover, html[data-theme="oled"] .zm-dock .zmw-tabs .cbi-button-positive, html[data-theme="oled"] .zm-dock .zmw-tabs .cbi-button-positive:hover, html[data-theme="oled"] .zmw-modal .zmw-tabs .cbi-button-positive, html[data-theme="oled"] .zmw-modal .zmw-tabs .cbi-button-positive:hover { background: #ffffff; color: #000000; box-shadow: none; } html[data-theme="oled"] .zmw-icon-btn.zmw-link-kvn, html[data-theme="oled"] .zmw-icon-btn.zmw-link-kvn:hover { background: #ffffff; color: #000000; border-color: #ffffff; } html[data-theme="oled"] .zmw-icon-btn.zmw-link-tg, html[data-theme="oled"] .zmw-icon-btn.zmw-link-tg:hover { background: rgba(255,255,255,.10); color: #ffffff; border-color: rgba(255,255,255,.35); } html[data-theme="oled"] .zmw-brand-name b { background: none; -webkit-background-clip: border-box; background-clip: border-box; color: #ffffff; } html[data-theme="oled"] .zm-stopbar { background: #1a0705; color: #ffd0cb; border-color: rgba(255,90,78,.5); } html[data-theme="oled"] .zmw-nav-item { border-radius: 999px; } html[data-theme="oled"] .zmw-nav-item.zmw-active { background: #ffffff; color: #000000; } html[data-theme="oled"] .zmw-nav-item.zmw-active::before { display: none; } html[data-theme="oled"] .zmw-nav-item.zmw-active .zmw-nav-ico { background: #000000; color: #ffffff; border-color: #000000; } html[data-theme="oled"] .zmw-nav-gear.zmw-on, html[data-theme="oled"] .zmw-nav-editfoot .zmw-nav-btn-done { background: #ffffff; color: #000000; border-color: #ffffff; } html[data-theme="oled"] .zmw-nav-editing .zmw-nav-eye { color: #ffffff; background: rgba(255,255,255,.12); } html[data-theme="oled"] .zmw-nav-gear > i { background: #ffffff; } html[data-theme="oled"] #zmw-view .zm-card { border-radius: 20px; } html[data-theme="oled"] #zmw-view .zm-card h3::before { background: #ffffff; } html[data-theme="oled"] #zmw-view .cbi-button, html[data-theme="oled"] .zmw-icon-btn, html[data-theme="oled"] .zm-dock .cbi-button, html[data-theme="oled"] .zmw-modal .cbi-button, html[data-theme="oled"] .zmw-btn-primary { border-radius: 999px; } html[data-theme="oled"] #zmw-view .zmw-tabs .cbi-button { border-radius: 999px; } html[data-theme="oled"] #zmw-view .zm-actions.zmw-tabs { border-radius: 999px; } html[data-theme="oled"] #zmw-view .zm-seg, html[data-theme="oled"] #zmw-view .zm-seg-item { border-radius: 999px; } html[data-theme="oled"] #zmw-view .zm-tile { border-radius: 16px; } html[data-theme="oled"] #zmw-view .zm-tile.zm-active { background: #ffffff; color: #000000; border-color: #ffffff; } html[data-theme="oled"] .zmw-login-card, html[data-theme="oled"] .zmw-modal { border-radius: 24px; } html[data-theme="oled"] .zmw-theme-opt.zmw-on { background: rgba(255,255,255,.10); color: #ffffff; } html[data-theme="oled"] .zmw-icon-btn.zmw-link-tg, html[data-theme="oled"] .zmw-icon-btn.zmw-link-tg:hover { background: transparent; color: #ffffff; border-color: #2a2a2a; } html[data-theme="oled"] .zmw-mem-bar { background: #1c1c1c; } html[data-theme="oled"] #zmw-view .zm-switch.zm-switch-on > span { background: #000000; } html[data-theme="oled"] #zmw-view .zm-svc-dom.zm-active, html[data-theme="oled"] .zm-find .cbi-button-action, html[data-theme="oled"] #zmw-view .zm-dns-num { color: #000000; } html[data-theme="oled"] .zmw-check input:checked + .zmw-check-box::after { border-color: #000000; } html[data-theme="oled"] #zmw-view .zm-node.zm-active { border-color: #ffffff; background: rgba(255,255,255,.06); box-shadow: none; } html[data-theme="oled"] #zmw-view .zm-node:hover { border-color: #3d3d3d; } html[data-theme="oled"] #zmw-view .zm-route-chip.zm-route-chip-on { border-color: #ffffff; background: rgba(255,255,255,.08); } html[data-theme="oled"] .zmw-nav-item.zmw-active .zmw-nav-ico .zmw-i { color: #ffffff; } .zmw-mx-rain, .zmw-mx-title, .zmw-mx-sub, .zmw-mx-msg { display: none; } .zmw-mx-rain { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; } .zmw-mx-msg { position: absolute; left: 18px; bottom: 14px; z-index: 2; font: 13px/1.4 "Share Tech Mono", ui-monospace, monospace; color: #00ff41; text-shadow: 0 0 8px rgba(0,255,65,.6); pointer-events: none; } @media (max-width: 600px) { html[data-theme="matrix"] .zmw-mx-msg { left: 12px; bottom: 10px; font-size: 12px; } } html[data-theme="matrix"] { --font: "Share Tech Mono", ui-monospace, "JetBrains Mono", Consolas, monospace; --a1: #00ff41; --a2: #00ff41; --a3: #7dffa0; --grad: #00ff41; --grad-soft: rgba(0,255,65,.08); --ring: 0 0 0 2px rgba(0,255,65,.6); --radius: 2px; --radius-sm: 2px; --bg: #000000; --bg-glow-1: transparent; --bg-glow-2: transparent; --surface: #020a04; --surface-solid: #020a04; --surface-2: rgba(0,255,65,.04); --surface-3: rgba(0,255,65,.08); --side-bg: #000000; --border: #0d3d1a; --border-2: #15652b; --text: #b8ffcc; --text-2: #8ef0a8; --muted: #3bb15f; --shadow: 0 0 0 1px rgba(0,255,65,.06); --shadow-lg: 0 0 60px -20px rgba(0,255,65,.35); --input-bg: #000000; --console: #000000; --console-border: #15652b; --ok: #00ff41; --ok-bg: rgba(0,255,65,.10); --ok-dot: #00ff41; --bad: #ff5f56; --bad-bg: rgba(255,95,86,.12); --bad-dot: #ff5f56; --warn: #e5ff00; --warn-bg: rgba(229,255,0,.10); --warn-dot: #e5ff00; --off: #3bb15f; --off-bg: rgba(59,177,95,.10); color-scheme: dark; } html[data-theme="matrix"] body.zmw-body { background-image: none; font-size: 14.5px; } html[data-theme="matrix"] .zmw-orbs, html[data-theme="matrix"] .zmw-grid-bg { display: none; } html[data-theme="matrix"] .zmw-top { --zmw-top-bg: #000000; background: var(--zmw-top-bg); } html[data-theme="matrix"] .zmw-title { font-weight: 400; letter-spacing: .06em; color: #00ff41; text-shadow: 0 0 8px rgba(0,255,65,.55); } html[data-theme="matrix"] .zmw-side { background: #000000; border-right: 1px solid #0d3d1a; box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; } html[data-theme="matrix"] .zmw-logo svg { filter: hue-rotate(-90deg) saturate(1.6) brightness(1.1) drop-shadow(0 0 6px rgba(0,255,65,.6)); } html[data-theme="matrix"] .zmw-brand-name { letter-spacing: .04em; } html[data-theme="matrix"] .zmw-brand-name b { background: none; -webkit-background-clip: border-box; background-clip: border-box; color: #00ff41; text-shadow: 0 0 8px rgba(0,255,65,.55); } html[data-theme="matrix"] .zmw-nav-item { border-radius: 0; color: #4fd07a; } html[data-theme="matrix"] .zmw-nav-item:hover { background: rgba(0,255,65,.06); color: #b8ffcc; } html[data-theme="matrix"] .zmw-nav-item.zmw-active { background: #00ff41; color: #001a07; box-shadow: 0 0 16px -4px rgba(0,255,65,.7); } html[data-theme="matrix"] .zmw-nav-item.zmw-active::before { display: none; } html[data-theme="matrix"] .zmw-nav-ico { background: transparent; border-color: #0d3d1a; border-radius: 0; color: #00ff41; } html[data-theme="matrix"] .zmw-nav-item.zmw-active .zmw-nav-ico { background: #001a07; color: #00ff41; border-color: #001a07; box-shadow: none; } html[data-theme="matrix"] .zmw-mem-bar { background: #0d3d1a; border-radius: 0; } html[data-theme="matrix"] .zmw-mem-bar i { background: #00ff41; border-radius: 0; box-shadow: 0 0 8px rgba(0,255,65,.7); } html[data-theme="matrix"] #zmw-view .zm-card { background: #020a04; border: 1px solid #0d3d1a; border-radius: 0; box-shadow: inset 0 0 40px -20px rgba(0,255,65,.25); backdrop-filter: none; -webkit-backdrop-filter: none; } html[data-theme="matrix"] #zmw-view .zm-card:hover { border-color: #15652b; box-shadow: inset 0 0 40px -16px rgba(0,255,65,.3); } html[data-theme="matrix"] #zmw-view .zm-card h3 { font-weight: 400; letter-spacing: .08em; text-transform: uppercase; color: #00ff41; text-shadow: 0 0 8px rgba(0,255,65,.55); } html[data-theme="matrix"] #zmw-view .zm-card h3::before { background: #00ff41; border-radius: 0; width: 8px; box-shadow: 0 0 8px #00ff41; } html[data-theme="matrix"] #zmw-view .cbi-button, html[data-theme="matrix"] .zmw-icon-btn, html[data-theme="matrix"] .zm-dock .cbi-button, html[data-theme="matrix"] .zmw-modal .cbi-button { border-radius: 0; background: transparent; border: 1px solid #15652b; color: #8ef0a8; box-shadow: none; font-weight: 400; letter-spacing: .04em; } html[data-theme="matrix"] #zmw-view .cbi-button:hover, html[data-theme="matrix"] .zmw-icon-btn:hover, html[data-theme="matrix"] .zm-dock .cbi-button:hover, html[data-theme="matrix"] .zmw-modal .cbi-button:hover { background: rgba(0,255,65,.08); border-color: #00ff41; color: #ffffff; transform: none; box-shadow: none; } html[data-theme="matrix"] #zmw-view .cbi-button-positive, html[data-theme="matrix"] #zmw-view .cbi-button-positive:hover, html[data-theme="matrix"] .zmw-btn-primary, html[data-theme="matrix"] .zm-dock .cbi-button-positive, html[data-theme="matrix"] .zm-dock .cbi-button-positive:hover, html[data-theme="matrix"] .zmw-modal .cbi-button-positive, html[data-theme="matrix"] .zmw-modal .cbi-button-positive:hover { background: #00ff41; color: #001a07; border-color: #00ff41; box-shadow: 0 0 16px -4px rgba(0,255,65,.8); } html[data-theme="matrix"] #zmw-view .cbi-button-action, html[data-theme="matrix"] #zmw-view .cbi-button-action:hover, html[data-theme="matrix"] .zm-dock .cbi-button-action, html[data-theme="matrix"] .zmw-modal .cbi-button-action { background: rgba(0,255,65,.12); color: #00ff41; border-color: #00ff41; } html[data-theme="matrix"] #zmw-view .cbi-button-remove, html[data-theme="matrix"] #zmw-view .cbi-button-remove:hover, html[data-theme="matrix"] .zm-dock .cbi-button-remove, html[data-theme="matrix"] .zm-dock .cbi-button-remove:hover, html[data-theme="matrix"] .zmw-modal .cbi-button-remove, html[data-theme="matrix"] .zmw-modal .cbi-button-remove:hover { background: transparent; color: #ff5f56; border-color: rgba(255,95,86,.6); box-shadow: none; } html[data-theme="matrix"] #zmw-view .zm-tile { border-radius: 0; background: #000000; border: 1px solid #0d3d1a; box-shadow: none; } html[data-theme="matrix"] #zmw-view .zm-tile:hover { border-color: #00ff41; } html[data-theme="matrix"] #zmw-view .zm-tile.zm-active { background: rgba(0,255,65,.12); color: #ffffff; border-color: #00ff41; box-shadow: 0 0 14px -6px rgba(0,255,65,.8); } html[data-theme="matrix"] #zmw-view .zm-tile.zm-active::before { color: #00ff41; } html[data-theme="matrix"] #zmw-view .zm-badge, html[data-theme="matrix"] .zm-dock .zm-badge, html[data-theme="matrix"] .zmw-modal .zm-badge { border-radius: 0; letter-spacing: .04em; } html[data-theme="matrix"] #zmw-view .zm-badge .zm-dot, html[data-theme="matrix"] .zm-dock .zm-badge .zm-dot, html[data-theme="matrix"] .zmw-modal .zm-badge .zm-dot { border-radius: 0; } html[data-theme="matrix"] #zmw-view input:not([type=checkbox]):not([type=radio]):not([type=range]), html[data-theme="matrix"] #zmw-view select, html[data-theme="matrix"] #zmw-view textarea:not(.zm-config-editor), html[data-theme="matrix"] .zmw-input, html[data-theme="matrix"] .zmw-modal input:not([type=checkbox]):not([type=radio]), html[data-theme="matrix"] .zmw-modal select { border-radius: 0; background: #000000; border: 1px solid #15652b; color: #b8ffcc; } html[data-theme="matrix"] #zmw-view .zm-log, html[data-theme="matrix"] #zmw-view .zm-config-editor { border-radius: 0; } html[data-theme="matrix"] #zmw-view .zm-seg { border-radius: 0; background: #000000; border-color: #0d3d1a; } html[data-theme="matrix"] #zmw-view .zm-seg-item { border-radius: 0; } html[data-theme="matrix"] #zmw-view .zm-seg-item.zm-active { background: #00ff41; color: #001a07; } html[data-theme="matrix"] .zmw-login-card, html[data-theme="matrix"] .zmw-modal, html[data-theme="matrix"] .zmw-theme-menu { border-radius: 0; background: #020a04; border: 1px solid #00ff41; box-shadow: 0 0 0 1px rgba(0,255,65,.15), 0 0 50px -10px rgba(0,255,65,.35); backdrop-filter: none; -webkit-backdrop-filter: none; } html[data-theme="matrix"] .zmw-theme-opt { border-radius: 0; } html[data-theme="matrix"] .zmw-theme-opt.zmw-on, html[data-theme="matrix"] .zmw-theme-opt:hover { background: rgba(0,255,65,.10); color: #00ff41; } html[data-theme="matrix"] .zmw-theme-mark { color: #00ff41; } html[data-theme="matrix"] #zmw-view .zm-actions.zmw-tabs { background: #000000; border: 1px solid #0d3d1a; border-radius: 0; box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; } html[data-theme="matrix"] #zmw-view .zmw-tabs .cbi-button, html[data-theme="matrix"] .zm-dock .zmw-tabs .cbi-button, html[data-theme="matrix"] .zmw-modal .zmw-tabs .cbi-button { background: transparent; border: 0; box-shadow: none; color: #3bb15f; } html[data-theme="matrix"] #zmw-view .zmw-tabs .cbi-button:hover, html[data-theme="matrix"] .zm-dock .zmw-tabs .cbi-button:hover, html[data-theme="matrix"] .zmw-modal .zmw-tabs .cbi-button:hover { background: rgba(0,255,65,.06); color: #b8ffcc; transform: none; } html[data-theme="matrix"] #zmw-view .zmw-tabs .cbi-button-positive, html[data-theme="matrix"] #zmw-view .zmw-tabs .cbi-button-positive:hover, html[data-theme="matrix"] .zm-dock .zmw-tabs .cbi-button-positive, html[data-theme="matrix"] .zm-dock .zmw-tabs .cbi-button-positive:hover, html[data-theme="matrix"] .zmw-modal .zmw-tabs .cbi-button-positive, html[data-theme="matrix"] .zmw-modal .zmw-tabs .cbi-button-positive:hover { background: #00ff41; color: #001a07; box-shadow: none; } html[data-theme="matrix"] .zmw-icon-btn.zmw-link-kvn, html[data-theme="matrix"] .zmw-icon-btn.zmw-link-kvn:hover { background: #00ff41; color: #001a07; border-color: #00ff41; } html[data-theme="matrix"] .zmw-icon-btn.zmw-link-tg, html[data-theme="matrix"] .zmw-icon-btn.zmw-link-tg:hover { background: transparent; color: #00ff41; border-color: #15652b; } html[data-theme="matrix"] .zm-stopbar { background: #1a0705; color: #ffb3ad; border-color: rgba(255,95,86,.5); } html[data-theme="matrix"] .zm-wn, html[data-theme="matrix"] .zm-dlg { border-radius: 0 !important; border: 1px solid #00ff41; } html[data-theme="matrix"] .zmw-btn-primary { border-radius: 0; background: #00ff41; color: #001a07; letter-spacing: .3em; font-weight: 400; text-transform: uppercase; box-shadow: 0 0 18px rgba(0,255,65,.55); } html[data-theme="matrix"] .zmw-btn-primary:hover { background: #7dffa0; box-shadow: 0 0 24px rgba(0,255,65,.75); } html[data-theme="matrix"] .zmw-btn-primary .zmw-i { display: none; } html[data-theme="matrix"] .zmw-login { background: #000000; } html[data-theme="matrix"] .zmw-mx-rain { display: block; } html[data-theme="matrix"] .zmw-login::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: radial-gradient(ellipse at center, transparent 35%, rgba(0,0,0,.85) 100%); } html[data-theme="matrix"] .zmw-login-card { z-index: 2; max-width: 360px; padding: 26px 26px 20px; border-radius: 0; background: rgba(0,10,3,.84); border: 1px solid #00ff41; box-shadow: 0 0 0 1px rgba(0,255,65,.15), 0 0 40px rgba(0,255,65,.25), inset 0 0 30px rgba(0,255,65,.08); text-align: left; backdrop-filter: none; -webkit-backdrop-filter: none; } html[data-theme="matrix"] .zmw-login-card .zmw-logo-lg, html[data-theme="matrix"] .zmw-login-card > h1, html[data-theme="matrix"] .zmw-login-card > .zmw-login-sub { display: none; } html[data-theme="matrix"] .zmw-mx-title { display: block; font-size: 24px; letter-spacing: .14em; color: #00ff41; text-shadow: 0 0 8px rgba(0,255,65,.55); margin: 0 0 2px; overflow-wrap: anywhere; } html[data-theme="matrix"] .zmw-mx-sub { display: block; font-size: 13px; color: #3bd16f; margin: 0 0 18px; } html[data-theme="matrix"] .zmw-field-label { text-transform: uppercase; letter-spacing: .1em; color: #3bd16f; font-weight: 400; font-size: 11.5px; } html[data-theme="matrix"] .zmw-input { height: 40px; border-radius: 0; border-color: #0f6b2c; background: rgba(0,0,0,.6); color: #00ff41; } html[data-theme="matrix"] .zmw-input::before { content: ">"; color: #00ff41; text-shadow: 0 0 8px rgba(0,255,65,.55); } html[data-theme="matrix"] .zmw-input > .zmw-i { display: none; } html[data-theme="matrix"] .zmw-input input { color: #b8ffcc; caret-color: #00ff41; font-family: "Share Tech Mono", ui-monospace, "JetBrains Mono", Consolas, monospace; } html[data-theme="matrix"] .zmw-input:focus-within { border-color: #00ff41; box-shadow: 0 0 0 1px rgba(0,255,65,.35), 0 0 16px -4px rgba(0,255,65,.6); } html[data-theme="matrix"] .zmw-eye { color: #3bb15f; border-radius: 0; } html[data-theme="matrix"] .zmw-check { color: #3bd16f; } html[data-theme="matrix"] .zmw-check-box { border-radius: 0; border-color: #15652b; background: #000000; } html[data-theme="matrix"] .zmw-check input:checked + .zmw-check-box { background: #00ff41; } html[data-theme="matrix"] .zmw-check input:checked + .zmw-check-box::after { border-color: #001a07; } html[data-theme="matrix"] .zmw-login-msg { border-radius: 0; } html[data-theme="matrix"] .zmw-login-foot { color: #1f8f43; text-align: center; } html[data-theme="matrix"] .zmw-mx-msg { display: block; } html[data-theme="matrix"] .zmw-login-theme { z-index: 3; } html.zm-dust-on { --zm-glass: 52%; --zm-glass-side: 66%; } html.zm-dust-on[data-theme="bento"] { --zm-glass: 74%; --zm-glass-side: 80%; } html.zm-dust-on[data-theme="retro"] { --zm-glass: 82%; --zm-glass-side: 86%; } html.zm-dust-on[data-theme] #zmw-view .zm-card, html.zm-dust-on[data-theme] #zmw-view .zm-actions.zmw-tabs { background-color: color-mix(in srgb, var(--surface-solid) var(--zm-glass), transparent); background-image: none; backdrop-filter: none; -webkit-backdrop-filter: none; transition: background-color .35s ease; } html.zm-dust-on[data-theme="bento"] #zmw-view .zm-cards > .zm-card:nth-child(4n+2) { background-color: color-mix(in srgb, #dcecff var(--zm-glass), transparent); } html.zm-dust-on[data-theme="bento"] #zmw-view .zm-cards > .zm-card:nth-child(4n+3) { background-color: color-mix(in srgb, #ffe6c4 var(--zm-glass), transparent); } html.zm-dust-on[data-theme="bento"] #zmw-view .zm-cards > .zm-card:nth-child(4n+4) { background-color: color-mix(in srgb, #dff2e2 var(--zm-glass), transparent); } html.zm-dust-on[data-theme="minimal"] #zmw-view .zm-card { background-color: transparent; } html.zm-dust-on[data-theme] .zmw-side { background-color: color-mix(in srgb, var(--surface-solid) var(--zm-glass-side), transparent); background-image: none; backdrop-filter: none; -webkit-backdrop-filter: none; } @media (max-width: 960px) { html.zm-dust-on[data-theme] .zmw-side { background-color: var(--surface-solid); } } html.zm-dust-on[data-theme] .zmw-login-card { background-color: color-mix(in srgb, var(--surface-solid) 78%, transparent); background-image: none; } html.zm-dust-on[data-theme="matrix"] .zmw-login-card { background-color: rgba(0,10,3,.84); } html.zm-dust-on[data-theme="micro"], html.zm-dust-on[data-theme="light"], html.zm-dust-on[data-theme="ink"] { --zm-glass: 36%; } html.zm-dust-on:not(.zm-theme-dark) { --zm-glass-in: 44%; --zm-glass-in3: 52%; --zm-pan: var(--surface-2); --zm-pan3: var(--surface-3); } html.zm-dust-on[data-theme="ink"] { --zm-pan: #ffffff; } html.zm-dust-on[data-theme="bento"] { --zm-glass-in: 72%; --zm-glass-in3: 76%; } html.zm-dust-on[data-theme="retro"] { --zm-glass-in: 80%; --zm-glass-in3: 84%; } html.zm-dust-on:not(.zm-theme-dark)[data-theme] #zmw-view .zm-card :is(.zm-st-stat, .zm-awg-if, .zm-svc:not(.zm-svc-on), .zm-node:not(.zm-active), .zm-ab-tile, .zm-rm-tile, .zm-th-rows, .zm-fo-card, .zm-seg, .zm-tt-tile, .zm-tt-row, .zm-si-box, .zm-dom-panel, .zm-sb-left, .zm-lists-now, .zm-dns-item, .zm-link-row, .zm-ab-desc, .zm-hide-quick, .zm-current-banner.zm-current-empty) { background-color: color-mix(in srgb, var(--zm-pan) var(--zm-glass-in), transparent); backdrop-filter: none; -webkit-backdrop-filter: none; transition: background-color .35s ease; } html.zm-dust-on:not(.zm-theme-dark)[data-theme] #zmw-view .zm-card :is(.zm-fk-tile, .zm-fk-chip:not(.zm-fk-chip-more), .zm-quota) { background-color: color-mix(in srgb, var(--zm-pan3) var(--zm-glass-in3), transparent); } .zmw-theme-menu { overscroll-behavior: contain; box-sizing: border-box; } @media (max-width: 480px) { .zmw-theme-menu { gap: 2px; padding: 6px; border-radius: 16px; } .zmw-theme-opt { padding: 10px; } } @media (max-width: 420px) { .zmw-top { padding: 10px 12px; gap: 6px; } .zmw-top-actions { gap: 4px; } .zmw-icon-btn { height: 34px; min-width: 34px; padding: 0 7px; } .zmw-icon-btn .zmw-i { width: 18px; height: 18px; } .zmw-title { font-size: 17px; } } @media (max-width: 360px) { .zmw-top { flex-wrap: wrap; row-gap: 6px; } .zmw-top-actions { margin-left: auto; } .zmw-titles { order: 3; flex: 1 1 100%; } .zmw-title { -webkit-line-clamp: 2; overflow-wrap: normal; } } 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 if [ -s /opt/zapret-manager-luci/state/cron.saved ]; then while IFS= read -r _zm_l; do [ -n "$_zm_l" ] || continue grep -qxF "$_zm_l" /etc/crontabs/root 2>/dev/null || echo "$_zm_l" >> /etc/crontabs/root done < /opt/zapret-manager-luci/state/cron.saved rm -f /opt/zapret-manager-luci/state/cron.saved _zmi_ok "Расписание панели восстановлено из сохранённых настроек" fi if [ -f /opt/zapret-manager-luci/state/watch.off ]; then _zmi_ok "Фоновые проверки выключены в «Системе» — сторож панели не включаем" else echo '*/2 * * * * /opt/zapret-manager-luci/backend.sh zm_watch >/dev/null 2>&1 # zm-rpcd-watch' >> /etc/crontabs/root 2>/dev/null || true fi ZM_NO_CRON_RESTART=1 /opt/zapret-manager-luci/backend.sh versions_cron_sync >/dev/null 2>&1 || true case "$(uci -q get system.@system[0].cronloglevel)" in ''|5|8) uci -q set system.@system[0].cronloglevel='9' && uci -q commit system ;; esac 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 _zmi_ok "Файлы Web UI записаны" _zmi_say "Включаем Web UI" ZMW_RESTART=0 ZMW_OK=1 if [ ! -f /etc/config/uhttpd ]; then ZMW_OK=0 elif [ ! -e /usr/lib/uhttpd_ubus.so ]; then _zmi_pkg uhttpd-mod-ubus "Web UI без него не откроется, панель в LuCI работает." || true [ -e /usr/lib/uhttpd_ubus.so ] || ZMW_OK=0 ZMW_RESTART=1 fi if [ "$ZMW_OK" = 0 ]; then : elif ! 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; } if [ -x /usr/bin/netshift ] && [ -f /usr/lib/netshift/constants.sh ] && ! _zmi_has netshift && ! _zmi_has luci-app-netshift; then _zmi_say "Сверяем настройки Forkozz с новой версией панели" if /opt/zapret-manager-luci/backend.sh forkop_device_setup apply >/dev/null 2>&1; then _zmi_ok "Настройки Forkozz в порядке" else _zmi_warn "Сверить не удалось — откройте вкладку Forkozz и нажмите «Перезапустить»"; fi fi if [ -x /usr/bin/bytetube ]; then case "$(/opt/zapret-manager-luci/backend.sh bytetube_sync 2>/dev/null || true)" in *'"restarted":true'*) _zmi_ok "Скрипты ByeTube обновлены, ByeTube перезапущен" ;; *'"changed":true'*) _zmi_ok "Скрипты ByeTube обновлены" ;; esac fi if [ -n "$_zm_old_ver" ] && [ "$_zm_old_ver" != "$ZM_NEW_VER" ]; then mkdir -p /opt/zapret-manager-luci/state echo "$_zm_old_ver" > /opt/zapret-manager-luci/state/cl.pending 2>/dev/null || true fi case "$(/opt/zapret-manager-luci/backend.sh zm_changelog_sync 2>/dev/null || true)" in *'"ok":true'*) _zmi_ok "Список изменений версии $ZM_NEW_VER загружен — панель покажет его при входе" ;; esac ( sleep 30; /opt/zapret-manager-luci/backend.sh zm_watch; /opt/zapret-manager-luci/backend.sh adblock_dns_sync ) >/dev/null 2>&1 /dev/null; then _zmi_step "Правилам ядра steer не хватает файлов списков: $_zm_rh — скачиваем их заново" _zm_rh="$(/opt/zapret-manager-luci/backend.sh zm_refs_heal 2>/dev/null || true)" _zm_rh_n="$(printf '%s' "$_zm_rh" | sed -n 's/.*"restored":\([0-9]*\).*/\1/p')" _zm_rh_l="$(printf '%s' "$_zm_rh" | sed -n 's/.*"left":\([0-9]*\).*/\1/p')" [ "${_zm_rh_n:-0}" -gt 0 ] 2>/dev/null && _zmi_ok "Списки возвращены на место: $_zm_rh_n" case "$_zm_rh" in *'"engine":"started"'*) _zmi_ok "Ядро steer запущено" ;; *'"engine":"stopped"'*) _zmi_warn "Ядро steer сейчас не запущено — включите его там, где настраивали правила: в Splify 2 или на вкладке Steer" ;; esac if [ "${_zm_rh_l:-1}" != 0 ]; then _zmi_warn "Не удалось вернуть: $(printf '%s' "$_zm_rh" | sed -n 's/.*"files":"\([^"]*\)".*/\1/p')" _zmi_warn "Без этих файлов ядро steer не запустится. Проверьте интернет и запустите установку ещё раз, либо примените настройки заново в Steer или Splify 2" fi fi if [ -n "$_zm_cl_s" ]; then _zmi_ok "Освобождено: флеш ${_zm_cl_f:-0} КБ, /tmp ${_zm_cl_t:-0} КБ. Панель занимает на флеше ${_zm_cl_s} КБ" else _zmi_ok "Готово"; fi 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 "sh <(wget -q -O - https://raw.githubusercontent.com/StressOzz/Zapret-Manager/main/Zapret-Manager.sh)" > /usr/bin/zms; chmod +x /usr/bin/zms echo "sh <(wget -q -O - https://raw.githubusercontent.com/StressOzz/Zapret-Manager/main/ZapretManager_LuCI.sh)" > /usr/bin/zmw; chmod +x /usr/bin/zmw if [ "$ZMW_OK" = 1 ]; then _zmi_ok "Web UI включён на порту ${ZMW_PORT:-7788}" else _zmi_warn "Web UI не включён: на роутере нет веб-сервера uhttpd или не установился uhttpd-mod-ubus"; fi echo -e "\n${GREEN}Готово: Zapret Manager для LuCI и Web $ZM_NEW_VER установлен${NC}\n" echo -e "${CYAN}LuCI: ${NC}Службы → Zapret Manager" [ "$ZMW_OK" = 1 ] && echo -e "${CYAN}Web UI: ${NC}http://${ZMW_IP}:${ZMW_PORT:-7788}" echo