From 4a3ef8cb78ce0df98038a9b997e706325620dc3f Mon Sep 17 00:00:00 2001 From: StressOzz <131419324+StressOzz@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:42:39 +0500 Subject: [PATCH] v3.24 https://github.com/StressOzz/Zapret-Manager/issues/1062#issuecomment-6199495365 --- ZapretManager_LuCI.sh | 41593 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41593 insertions(+) create mode 100644 ZapretManager_LuCI.sh diff --git a/ZapretManager_LuCI.sh b/ZapretManager_LuCI.sh new file mode 100644 index 0000000..a3ad792 --- /dev/null +++ b/ZapretManager_LuCI.sh @@ -0,0 +1,41593 @@ +#!/bin/sh +read -r _ _ ZM_NEW_VER <<'ZM_VERSION_EOF' +# Version: 3.24 +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 "Проверяем, что панель сейчас ничем не занята" + +if [ -f /tmp/zapret-manager-luci/redbtn_deep.pid ]; then + _zm_kill_tree() { + for _c in $(cat "/proc/$1/task/$1/children" 2>/dev/null); do _zm_kill_tree "$_c"; done + kill -9 "$1" 2>/dev/null || true + } + _zm_kill_tree "$(cat /tmp/zapret-manager-luci/redbtn_deep.pid 2>/dev/null)" + for _p in $(ps w | grep "[q]num=8397" | awk '{print $1}'); do kill "$_p" 2>/dev/null || true; done + nft delete table inet zm_rb_ztest >/dev/null 2>&1 || true +fi +for _zm_pid in /tmp/zapret-manager-luci/*.pid; do + [ -f "$_zm_pid" ] || continue + _zm_job="$(basename "$_zm_pid" .pid)" + case "$_zm_job" in versions|sysinfo|*_download|redbtn_deep|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 + +_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 \ + /usr/lib/zapret-manager* \ + /etc/zapret_manager_expert_mode* \ + /tmp/zapret-manager \ + /tmp/zapret-manager-luci* \ + /tmp/zm_uninstall_panel.sh \ + /tmp/luci-indexcache* \ + /tmp/luci-modulecache/* 2>/dev/null +rm -f \ + /www/luci-static/resources/view/zapret-manager/test.js \ + /www/luci-static/resources/view/zapret-manager/redbtn.js \ + /www/luci-static/resources/view/zapret-manager/youtube.js \ + /www/luci-static/resources/view/zapret-manager/game.js \ + /www/luci-static/resources/view/zapret-manager/discord.js \ + /www/luci-static/resources/view/zapret-manager/exclusions.js \ + /www/luci-static/resources/view/zapret-manager/forkop.js 2>/dev/null + +_zmi_ok "Готово" +_zmi_say "Записываем файлы панели" +mkdir -p /opt/zapret-manager-luci +chmod 0755 /opt/zapret-manager-luci +cat > '/opt/zapret-manager-luci/backend.sh.zm-new' << 'ZM_INSTALLER_EOF' +#!/bin/sh +umask 022 +# Сторож из cron (zm_watch, раз в 2 минуты). Оболочка читает скрипт по одной команде, поэтому выход +# здесь не разбирает остальные ~16 тысяч строк. Выходим, только если ни одной проверке zm_watch нечего +# делать, — те же условия, что у самих проверок. Любое сомнение — полный проход ниже, как раньше. +if [ "$1" = zm_watch ]; then + _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 + # rpcd_watch — исправный путь: rpcd жив, сессии отвечают, панель видна в ubus со всеми методами + 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 + # _test_recover, восстановление DNS после прерванной операции, TG-прокси + [ -s "$s/strategy_test.backup" ] && return 1 + [ -f "$d/resolv.conf.zm-bak" ] && return 1 + [ -f "$s/tg.watch" ] && return 1 + # Forkozz: сторож и правки движка нужны, только если он стоит + { [ -e /usr/bin/netshift ] || [ -e /usr/lib/netshift ]; } && return 1 + # Steer: панель им управляет? + 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 + # _st_vpn_watch: VPN по подписке + [ ! -f "$e/stopped" ] && [ -s "$e/sub.txt" ] && grep -qxF steer-spec "$e/owned" 2>/dev/null && return 1 + # _st_colo_guard: туннели WARP — не чаще раза в 5 минут + 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 + # _st_legacy_check: ядро steer старше 2.0 + if grep -qxF engine "$e/owned" 2>/dev/null; then + v="$(steer --version 2>/dev/null | awk 'NR == 1 { print $2 }')" + printf '%s\n' "$v" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' && [ "${v%%.*}" -ge 2 ] 2>/dev/null || return 1 + fi + fi + # Блокировка рекламы: список включён, а у dnsmasq его нет (после перезагрузки папки dnsmasq пустые) + 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 + # устройства без блокировки: их DNS жив, правила стоят, пересылка не отстала от основного dnsmasq + 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 — её версию не определяем и не проверяем +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 + +# ---- Частые запросы панели: отвечаем сразу, не разбирая остальной скрипт --------------------------- +# Обзор и боковая панель Web UI спрашивают сведения о роутере каждые 10 секунд, окно хода операции — +# её состояние и журнал каждую секунду. Оболочка читает скрипт по одной команде, поэтому эти ответы +# стоят здесь, до основной части: разбор всех ~16 тысяч строк на слабом роутере (MIPS, ARM) стоит +# полсекунды процессора и больше на каждый запрос — с открытым Обзором или во время установки это +# держало процессор почти в сотке и тормозило саму установку. +esc() { + printf '%s' "$1" | tr -d '\000-\010\013\014\016-\037' | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g; s/\r/\\r/g' | tr '\n' ' ' +} + +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" +# Адрес (хост с путём) репозитория OpenWrt в distfeeds — до /releases/ или /snapshots/ +_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 +} + +# Сменено ли зеркало: хост, как (auto/manual), когда +_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 +} + +# Есть ли интернет у самого роутера. Сначала ping; не прошёл — это ещё не «нет интернета»: провайдер или +# туннель (WARP, VPN) может резать ICMP, а сайт может идти через прокси по подменному адресу. Тогда +# проверяем настоящим соединением с теми же сайтами. Не отвечает ничего по именам, а по IP связь есть — +# интернет есть, не работает DNS роутера (тогда и панель ничего не скачает): ответ «nodns». +_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" +} + +# Модель, версия OpenWrt, ядро и зеркало меняются редко: собираем их раз в 10 минут (и сразу после смены +# зеркала) в файл в ОЗУ, а не десятком процессов на каждый запрос Обзора. +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" +} + +case "$1" in + system_info) system_info; 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 WS Proxy — узнаём её тогда, а не при каждом вызове панели. +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"; } + +# Ограничение времени команды. В части прошивок busybox собран без timeout, а ссылка /usr/bin/timeout +# осталась — «command -v» её находит, а запуск даёт «timeout: applet not found». Поэтому проверяем делом +# (один раз за вызов скрипта); старый busybox с синтаксисом «timeout -t» проверку тоже не проходит. +# Нет рабочего timeout — ограничиваем сами: команда в фоне (stdin сохраняем), сторож снимает её по сроку. +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 + # timeout умеет запускать только программы; функцию оболочки ограничиваем своим сторожем + 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 + # фоновой команде оболочка подставляет /dev/null вместо stdin — отдаём ей наш stdin через fd 3 + { "$@" <&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 f w + _zm_zone_of_net lan && return 0 + _zm_zone_sec lan >/dev/null && { echo lan; return 0; } + w="$(_zm_wan_zone)" + for f in $(uci -q -X show firewall | sed -n "s/^firewall\.\([^.=]*\)=forwarding\$/\1/p"); do + [ "$(uci -q get "firewall.$f.dest")" = "$w" ] && { uci -q get "firewall.$f.src"; return 0; } + done + echo lan +} +_zm_wan_zone() { _zm_zone_of_net wan || echo wan; } +_zm_lan_nets() { + local s n="" + s="$(_zm_zone_sec "$(_zm_lan_zone)")" && n="$(uci -q get "firewall.$s.network")" + echo "${n:-lan}" +} +_zm_lan_devs() { + local s n d out="" + for n in $(_zm_lan_nets); do + d="$(ifstatus "$n" 2>/dev/null | jsonfilter -e '@.l3_device' 2>/dev/null)" + [ -n "$d" ] || d="$(ifstatus "$n" 2>/dev/null | jsonfilter -e '@.device' 2>/dev/null)" + [ -n "$d" ] || d="$(uci -q get "network.$n.device")" + [ -n "$d" ] && [ -d "/sys/class/net/$d" ] || continue + case " $out " in *" $d "*) ;; *) out="$out $d" ;; esac + done + if s="$(_zm_zone_sec "$(_zm_lan_zone)")"; then + for d in $(uci -q get "firewall.$s.device"); do + [ -d "/sys/class/net/$d" ] || continue + case " $out " in *" $d "*) ;; *) out="$out $d" ;; esac + done + fi + [ -n "$out" ] || out="br-lan" + echo $out +} +_zm_lan_ip() { + local n ip d + for n in lan $(_zm_lan_nets); do + ip="$(ifstatus "$n" 2>/dev/null | jsonfilter -e "@['ipv4-address'][0].address" 2>/dev/null)" + [ -n "$ip" ] && { echo "$ip"; return 0; } + done + ip="$(uci -q get network.lan.ipaddr 2>/dev/null | awk '{ print $1 }' | cut -d/ -f1)" + [ -n "$ip" ] && { echo "$ip"; return 0; } + for d in $(_zm_lan_devs); do + ip="$(ip -4 -o addr show dev "$d" 2>/dev/null | awk '{ print $4; exit }' | cut -d/ -f1)" + [ -n "$ip" ] && { echo "$ip"; return 0; } + done + return 1 +} + +_pkg_is_installed() { + if [ "$PKG" = "apk" ]; then apk info -e "$1" >/dev/null 2>&1 + else opkg list-installed 2>/dev/null | grep -q "^$1 "; fi +} + +_ensure_deps() { + local need="" + command -v curl >/dev/null 2>&1 || need="$need curl" + command -v unzip >/dev/null 2>&1 || need="$need unzip" + [ -z "$need" ] && return 0 + echo "==> Не хватает программ:$need — устанавливаем" + $UPDATE + $INSTALL $need +} + +_zm_kids() { + local f a b c d r + for f in /proc/[0-9]*/stat; do + { read -r a b c d r < "$f"; } 2>/dev/null || continue + [ "$d" = "$1" ] && echo "$a" + done +} + +_zm_kill_tree() { + local c + [ -n "$1" ] || return 0 + kill -STOP "$1" 2>/dev/null + for c in $(_zm_kids "$1"); do _zm_kill_tree "$c"; done + kill -9 "$1" 2>/dev/null +} + +_zm_pkg_unlock() { + pidof opkg >/dev/null 2>&1 || rm -f /var/lock/opkg.lock 2>/dev/null + return 0 +} + +_zm_dots() { + local p="$1" tmo="${2:-900}" i=0 o="${ZM_JOB_LOG:-/dev/null}" shown=0 + [ -n "$ZM_IN_WAIT" ] && o=/dev/null + while kill -0 "$p" 2>/dev/null; do + sleep 1 + i=$((i + 1)) + if [ "$i" -ge "$tmo" ]; then + _zm_kill_tree "$p" + [ "$shown" = 1 ] && echo >> "$o" + shown=0 + echo "!! Шаг не завершился за $tmo с — прерываем его, чтобы ничего не зависло" >> "${ZM_JOB_LOG:-/dev/null}" + [ -n "${ZM_TMO_FLAG:-}" ] && : > "$ZM_TMO_FLAG" + _zm_pkg_unlock + break + fi + if [ "$i" -ge 20 ]; then + [ "$shown" = 0 ] && { printf 'Подождите ' >> "$o"; shown=1; } + [ $((i % 5)) = 0 ] && printf '.' >> "$o" + fi + done + [ "$shown" = 1 ] && echo >> "$o" + return 0 +} + +_zm_run() { + local tmo="$1" p rc t="/tmp/zm-run.$$.$RANDOM" + shift + ( ZM_IN_WAIT=1; export ZM_IN_WAIT; "$@" ) > "$t" 2>&1 & + p=$! + _zm_dots "$p" "$tmo" + wait "$p" 2>/dev/null + rc=$? + cat "$t" 2>/dev/null + rm -f "$t" + return $rc +} + +_zm_pkg_fmt() { + awk ' + { sub(/\r$/, "") } + /^Downloading / { next } + /^Updated list of available packages in / { n = $0; sub(/.*\//, "", n); print " · обновлён список: " n; next } + /^Installing [^ ]+ \(/ { v = $3; gsub(/[()]/, "", v); print " · ставим " $2 " " v; next } + /^Upgrading [^ ]+ / { print " · обновляем " $2; next } + /^Configuring / { n = $2; sub(/\.$/, "", n); print " · настраиваем " n; next } + /^Removing package / { print " · удаляем " $3; next } + /^Package .* is up to date/ { print " · " $2 " уже стоит последней версии"; next } + /^\([0-9]+\/[0-9]+\) Installing / { v = $4; gsub(/[()]/, "", v); print " · ставим " $3 " " v; next } + /^\([0-9]+\/[0-9]+\) Upgrading / { print " · обновляем " $3; next } + /^\([0-9]+\/[0-9]+\) (Purging|Deleting) / { print " · удаляем " $3; next } + /^OK: [0-9]+ distinct packages available/ { print " · доступно пакетов: " $2; next } + /^fetch / { next } + /ubus call service delete .*Not found/ { next } + # «write error: Broken pipe» — скрипт пакета писал в трубу, которую читатель уже закрыл + # (echo … | grep -q, | head): на установку не влияет, только шумит + /(write error|[Ww]rite error): [Bb]roken pipe|^[^ ]*: [Bb]roken pipe$/ { 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 + # ошибки у самого репозитория OpenWrt (а не у сторонних лент) — пробуем зеркала + 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; "$@" >>"$log" 2>&1; rc=$?; _zm_net_restore >>"$log" 2>&1; [ -n "$(tail -c 1 "$log" 2>/dev/null)" ] && echo >>"$log"; echo "__DONE__ $rc" >>"$log" ) & + echo $! > "$pid" + printf '{"started":true,"job":"%s"}\n' "$name" +} + +_zm_wait() { + local p rc t="/tmp/zm-wait.$$.$RANDOM" tmo=600 + shift + ( ZM_IN_WAIT=1; export ZM_IN_WAIT; "$@" ) > "$t.o" 2> "$t.e" & + p=$! + _zm_dots "$p" "$tmo" + wait "$p" 2>/dev/null + rc=$? + cat "$t.o" + [ "$rc" = 0 ] || cat "$t.e" >&2 + rm -f "$t.o" "$t.e" + return $rc +} + +_zm_quiet() { + local p rc t="/tmp/zm-quiet.$$.$RANDOM" tmo=600 + shift + ( ZM_IN_WAIT=1; export ZM_IN_WAIT; "$@" ) > "$t" 2>&1 & + p=$! + _zm_dots "$p" "$tmo" + wait "$p" 2>/dev/null + rc=$? + [ "$rc" = 0 ] || tail -n 8 "$t" | sed 's/^/ /' + rm -f "$t" + return $rc +} + +_zm_sleep() { + sleep "$1" & + _zm_dots $! "$(( $1 + 5 ))" + wait $! 2>/dev/null + return 0 +} + +_zm_cron_drop() { + local pat="$1" what="$2" + grep -qiE "$pat" "$CRON_FILE" 2>/dev/null || return 0 + echo " → Убираем задания $what из расписания (cron)" + grep -viE "$pat" "$CRON_FILE" > "$CRON_FILE.zm" 2>/dev/null + if [ $? -le 1 ] && [ -f "$CRON_FILE.zm" ]; then cat "$CRON_FILE.zm" > "$CRON_FILE"; fi + rm -f "$CRON_FILE.zm" + /etc/init.d/cron restart >/dev/null 2>&1 + echo " ✓ Расписание очищено" +} + +_zm_dns_scrub() { + local rx="$1" s v ch=0 sc + for s in $(uci -q show dhcp | sed -n "s/^dhcp\.\([^.=]*\)=dnsmasq\$/\1/p"); do + sc=0 + for v in $(uci -q get "dhcp.$s.server"); do + printf '%s' "$v" | grep -qE "$rx" && { uci -q del_list "dhcp.$s.server=$v"; ch=1; sc=1; } + done + if [ "$sc" = 1 ] && [ -z "$(uci -q get "dhcp.$s.server")" ] && [ "$(uci -q get "dhcp.$s.noresolv")" = 1 ]; then + uci -q delete "dhcp.$s.noresolv" + fi + done + [ "$ch" = 1 ] && { uci -q commit dhcp; echo " ✓ Из настроек DNS роутера убраны оставшиеся ссылки"; } + return 0 +} + +_zm_svc_off() { + [ -x "/etc/init.d/$1" ] || return 0 + echo " → Останавливаем службу $1 и убираем её из автозапуска" + _zm_run 60 "/etc/init.d/$1" stop >/dev/null 2>&1 + "/etc/init.d/$1" disable >/dev/null 2>&1 + return 0 +} + +ZM_RESOLV_BAK="$JOBS_DIR/resolv.conf.zm-bak" + +_zm_dns_ok() { nslookup "$1" >/dev/null 2>&1; } + +_zm_host_ok() { + curl -s -o /dev/null --connect-timeout 6 --max-time 12 "https://$1/" >/dev/null 2>&1 + case $? in 0|22|35|47|52|56|60) return 0 ;; esac + return 1 +} + +ZM_REBOOT_HINT="$JOBS_DIR/reboot.hint" + +_zm_reboot_hint() { + mkdir -p "$JOBS_DIR" + printf '%s\n%s\n' "$1" "$(date +%s)" > "$ZM_REBOOT_HINT" +} + +# Текст подсказки; через 2 минуты она пропадает сама +_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 +} + +# Связь по IP-адресу есть (без DNS)? +_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 + # по IP связь есть — значит, не отвечает DNS: перезапуск всей сети его не чинит, а рвёт связь с роутером. + # Сначала перезапускаем то, что отвечает за имена: DNS over HTTPS (если включён) и dnsmasq + 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_rb_note). +_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 роутера возвращён" +} + +# Хост репозитория пакетов, прописанный на роутере сейчас (выбранное зеркало или downloads.openwrt.org) +_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_installed || 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; } + # движок сейчас запускается, перезапускается или применяет подписку — sing-box и правила на это время + # пропадают; перезапуск отсюда наложился бы на ту операцию и стёр бы ей наборы правил + _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 + # «Авто по пингу»: обрыв ловим чаще, чем раз в 2 минуты — отдельный фоновый цикл с проверкой каждые 20 с + /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 +} + +# Авто по пингу: пять проверок с шагом 20 с — до следующего запуска сторожа из cron. Двойной запуск гасится замком. +_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 +} + +# Keep long VPN health checks outside the watcher's main process. +_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 +) + +# «Вернуться на выбранный»: проверка запомненного сервера раз в 10 минут (срок хранит netshift.uc) +_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 +} + +# DNS failover: NetShift uses one DNS server, Forkozz keeps the list and switches to the first one that answers. +_fk_dns_watch() { + local f="$ZM_STATE_DIR/fk.dnsfo" t cur v best="" tmp="$JOBS_DIR/fk-dnsfo.$$" ok cnt=0 want="" cur_ok=0 l + [ "$(uci -q get netshift.settings.dns_via_outbound)" = 1 ] && { rm -f "$f"; return 0; } + set -- $(uci -q get netshift.settings.zm_dns_servers) + [ $# -gt 1 ] || { rm -f "$f"; return 0; } + t="$(uci -q get netshift.settings.dns_type)"; cur="$(uci -q get netshift.settings.dns_server)" + mkdir -p "$JOBS_DIR" + for v in "$@"; do + ok=0 + _fk_dns_probe "$t" "$v" "$tmp" + IFS='|' read -r l ok l l < "$tmp" 2>/dev/null + rm -f "$tmp" + [ "$ok" = 1 ] && { best="$v"; break; } + done + [ -n "$best" ] && [ "$best" != "$cur" ] || { rm -f "$f"; return 0; } + ok=0 + _fk_dns_probe "$t" "$cur" "$tmp" + IFS='|' read -r l ok l l < "$tmp" 2>/dev/null + rm -f "$tmp" + [ "$ok" = 1 ] && cur_ok=1 + { read -r want cnt < "$f"; } 2>/dev/null + [ "$want" = "$best" ] && cnt=$(( ${cnt:-0} + 1 )) || cnt=1 + echo "$best $cnt" > "$f" + [ "$cnt" -ge 2 ] || return 0 + rm -f "$f" + uci -q set netshift.settings.dns_server="$best" && uci -q commit netshift + if [ "$cur_ok" = 1 ]; then logger -t zapret-manager "Forkozz: DNS $best снова отвечает — возвращаемся к нему" + else logger -t zapret-manager "Forkozz: DNS $cur не отвечает — переключились на $best"; fi + ( _zm_run 180 /usr/bin/netshift reload >/dev/null 2>&1 & ) + return 0 +} + +zm_watch() { + rpcd_watch + _test_recover >/dev/null 2>&1 + _zm_net_orphan && _zm_net_restore force >/dev/null 2>&1 + if ! _fk_installed || [ -f "$FK_MARK" ] || ! _fk_foreign; then + # без папки /etc/sing-box движок собирает конфиг, но не может его положить — sing-box падает без конфига + _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_legacy_check + _st_vpn_watch + _tg_watch + _ab_watch + return 0 +} + +# Фоновые сторожа и служебные задачи: это не операции пользователя — они не должны ни блокировать +# обновление, установку и перезагрузку, ни попадать под «Остановить все операции». +_zm_job_skip() { case "$1" in versions|sysinfo|*_download|redbtn_deep|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" "$JOBS_DIR/forkop-import" ;; + steer) rm -f "$ST_PHASE_FILE" 2>/dev/null; rmdir "$ST_RUN/cat.lock" 2>/dev/null; _st_warp_pre_clean ;; + strategy_test) nft delete table inet zm_rb_ztest >/dev/null 2>&1; 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" ;; + 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" +} + +# rpcd: сначала reload — rpcd по SIGHUP сохраняет сессии, перечитывает права и плагины и продолжает +# работу; входы в LuCI и Web UI остаются. restart — только если reload не помог: он выкидывает всех +# («Сессия истекла — войдите снова»). ПРОВЕРКА — что должно заработать после reload (по умолчанию — +# объект панели в ubus). +_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 (раз в 2 минуты). Чтобы не выкинуть всех из панели на ровном месте, «не отвечает» и +# «завис» должны повториться в два прохода подряд: под нагрузкой (ставятся пакеты) rpcd отвечает медленно. +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 want have sig st stamp=/tmp/zm-rpcd-plugin.stamp + [ -x "$pl" ] || return 0 + _rpcd_plugin_state "$pl" && { rm -f "$stamp"; return 0; } + sleep 5 + _rpcd_plugin_state "$pl" + st=$? + [ "$st" = 0 ] && { rm -f "$stamp"; return 0; } + sig="$(date -r "$pl" +%s 2>/dev/null)" + if [ "$st" = 1 ]; then + case "$(cat "$stamp" 2>/dev/null)" in + "$sig.x3") return 0 ;; + "$sig.x2") echo "$sig.x3" > "$stamp" ;; + "$sig.x1") echo "$sig.x2" > "$stamp" ;; + *) echo "$sig.x1" > "$stamp" ;; + esac + logger -t zapret-manager "rpcd не видит панель (Object not found) — перезапускаем rpcd" + else + [ "$(cat "$stamp" 2>/dev/null)" = "$sig.m" ] && return 0 + echo "$sig.m" > "$stamp" + logger -t zapret-manager "rpcd держит старый список методов панели — перечитываем плагины rpcd" + fi + _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_get() { + local t + t="$(cat "$ZM_STATE_DIR/ui.theme" 2>/dev/null)" + printf '{"theme":"%s"}\n' "$(esc "$t")" +} + +ui_theme_set() { + case "$1" in micro|light|dark|ink|bento|minimal|retro|depth|auto) ;; *) echo '{"error":"неизвестная тема"}'; return 1 ;; esac + mkdir -p "$ZM_STATE_DIR" /www/zm + printf '%s\n' "$1" > "$ZM_STATE_DIR/ui.theme" + printf '%s\n' "$1" > /www/zm/theme.txt + chmod 0644 /www/zm/theme.txt 2>/dev/null + printf '{"ok":true}\n' +} + +# Отметки интерфейса («больше не показывать»): по одной в строке +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 + 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 +} + +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" + if [ -f /etc/init.d/zapret2 ]; then + zr2="installed" + /etc/init.d/zapret2 status >/dev/null 2>&1 && zr2_running="true" + fi + + local strat="" fs_marker="" + if [ -f "$CONF" ]; then + strat=$(_nfq_names | sed 's/^#[[:space:]]*//; s/[[:space:]]*$//; /^udp443$/d' | tr '\n' ' ' | sed 's/ $//') + _udp443_on && strat="${strat:+$strat }QUIC" + fs_marker=$(grep -m1 '^# ZMFS:' "$CONF" | sed 's/^# ZMFS://') + { _zc_has || [ -s "$ZC_FILE" ]; } && strat="${strat:+$strat }Custom" + fi + + printf '{"pkg":"%s","zapret":"%s","zapret_running":%s,"zapret_version":"%s","zapret2":"%s","zapret2_running":%s,"strategy":"%s","flowseal":"%s","yv_off":%s,"quic_yt":%s,"expert":%s,"rkn":%s,"rkn_on":%s,"rkn_fits":%s,"rkn_count":%s,"wssize":%s,"ulist":%s,"ulist_on":%s,"ulist_count":%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)" +} + +_zapret_latest_version() { echo "$ZAPRET_VER"; } + +zapret_latest_version() { + printf '{"version":"%s"}\n' "$ZAPRET_VER" +} + +_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 + # свой блок #CustomStart … #CustomEnd (если конфиг остался от прежней установки) — сохраняем + _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" && + 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 убраны из расписания" + /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 + wget -q --timeout=20 -U "Mozilla/5.0" -O /opt/zapret2/init.d/openwrt/custom.d/50-discord_media.sh \ + "${GH_RAW}/StressOzz/Zapret-Manager/refs/heads/main/files/Zapret2/50-discord_media.sh" \ + || echo "!! Не удалось загрузить 50-discord_media.sh" + if _nochange_in /etc/config/zapret2; then echo " · Стратегия Zapret2 помечена #nochange — оставляем как есть" + else + _nochange_fetch /etc/config/zapret2 "${GH_RAW}/StressOzz/Zapret-Manager/refs/heads/main/files/Zapret2/zapret2" || echo "!! Не удалось загрузить zapret2" + fi + _nochange_fetch /opt/zapret2/ipset/zapret_hosts_discord.txt "${GH_RAW}/StressOzz/Zapret-Manager/refs/heads/main/files/Zapret2/zapret_hosts_discord.txt" \ + || echo "!! Не удалось загрузить zapret_hosts_discord.txt" + + echo "==> Запускаем Zapret2" + /etc/init.d/zapret2 enable >/dev/null 2>&1 + /etc/init.d/zapret2 restart >/dev/null 2>&1 + + cd /; rm -rf "$tmp" + echo "==> Готово, Zapret2 установлен" +} + +do_remove_zapret2() { + 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' + /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 +} + +_nochange_fetch() { + local f="$1" url="$2" tmp="$1.zm-new" + if _nochange_in "$f"; then + [ -n "$ZM_JOB_LOG" ] && echo " · $(basename "$f"): помечен #nochange — не обновляем" >&2 + return 0 + fi + mkdir -p "$(dirname "$f")" + if wget -q --timeout=20 -U "Mozilla/5.0" -O "$tmp" "$url" 2>/dev/null && [ -s "$tmp" ]; then mv -f "$tmp" "$f" + else rm -f "$tmp"; return 1; fi +} + +_nochange_save() { + local key="$1" keep="$ZM_STATE_DIR/nochange.$1" p f + shift + [ -d "$keep" ] && _nochange_restore "$key" >/dev/null 2>&1 + for p in "$@"; do + if [ -d "$p" ]; then set -- "$p"/*; else set -- "$p"; fi + for f in "$@"; do + [ -f "$f" ] && _nochange_in "$f" || continue + mkdir -p "$keep" + cp -p "$f" "$keep/$(echo "$f" | sed 's#/#%#g')" || continue + echo " · $(basename "$f"): помечен #nochange — сохраним как есть" + done + done + return 0 +} + +_nochange_restore() { + local keep="$ZM_STATE_DIR/nochange.$1" f dst + [ -d "$keep" ] || return 0 + for f in "$keep"/*; do + [ -f "$f" ] || continue + dst="$(basename "$f" | sed 's#%#/#g')" + mkdir -p "$(dirname "$dst")" + cp -p "$f" "$dst" && echo " ✓ $(basename "$dst") оставлен как был (#nochange)" + done + rm -rf "$keep" +} + +_add_gp_domains() { + _nochange_in /opt/zapret/ipset/zapret-hosts-google.txt && return 0 + local f="/opt/zapret/ipset/zapret-hosts-google.txt" tmp + mkdir -p "$(dirname "$f")" + tmp="$f.tmp" + { + [ -f "$f" ] && cat "$f" + printf '%s\n' "gvt1.com" "googleplay.com" "play.google.com" "beacons.gvt2.com" \ + "play.googleapis.com" "play-fe.googleapis.com" "lh3.googleusercontent.com" \ + "android.clients.google.com" "connectivitycheck.gstatic.com" \ + "play-lh.googleusercontent.com" "play-games.googleusercontent.com" \ + "prod-lt-playstoregatewayadapter-pa.googleapis.com" "youtubei.youtube.com" \ + "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]*$)' +UDP443_MARK_RX='^[ \t]*#[ \t]*udp443[ \t]*$' + +# Разбор профилей переносит строки-комментарии наверх — свой блок #CustomStart … #CustomEnd на это время +# убираем в сторону (если его ещё не убрала операция) и возвращаем наверх NFQWS_OPT. +_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() { sed -n "/^[[:space:]]*option NFQWS_OPT '\$/,/^[[:space:]]*'\$/p" "$CONF" 2>/dev/null; } + +# ---------- Свой блок стратегии #CustomStart … #CustomEnd (как в консольном Zapret Manager) ---------- +# Строки между #CustomStart и #CustomEnd в NFQWS_OPT — ваша стратегия, панель её не меняет. Смена стратегии, +# YouTube, Discord, игр, QUIC, списков РКН и --wssize переписывает NFQWS_OPT: на это время блок убирается +# в сторону (иначе разбор профилей разнёс бы его строки), а перед перезапуском Zapret встаёт в самый верх +# NFQWS_OPT — как в консольном Zapret Manager. Операция ничего не поменяла — конфиг возвращается как был. +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]*$' +# есть ли в NFQWS_OPT законченный блок (и #CustomStart, и #CustomEnd после него) +_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 }'; } +# NFQWS_OPT: убрать лишние --new (подряд, сразу после открытия и перед закрытием блока) +_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" + # внутри блока: --new сразу после #CustomStart и перед #CustomEnd не нужен — разделители ставим сами + 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; } + # конфига или блока NFQWS_OPT нет (Zapret удаляют или переустанавливают) — блок ждёт до следующего раза + [ -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="" +} +# имена частей стратегии (#v7, #Yv08, …) — без своего блока +_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() { _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; } + +# ---------- Метка ts (--dpi-desync-fooling=ts) ---------- +# Для неё на компьютерах с Windows нужно включить метки времени TCP. Перед применением страница спрашивает, +# в каких частях стратегии, которая встанет, будет ts, — и показывает предупреждение. +ZM_TS_RX='fooling=([a-z0-9]+,)*ts(,|$)' +_ts_has() { grep -qE -- "$ZM_TS_RX"; } +# Блок NFQWS_OPT со стандартного входа → части с ts: основная / YouTube / Discord / игры +_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)"; } +# kind:id — v:v3, flowseal:имя, youtube:Yv08, dv:5, gv:2, text:<блок NFQWS_OPT> +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" + # YouTube после смены основной: ваша Yv из списка или встроенная Yv08 + 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"; } + +do_add_fake_flow() { + [ -d /opt/zapret ] || return 0 + [ -f "$CONF" ] || return 0 + local f msg=0 + for f in stun2.bin quic_initial_tencent_com.bin quic_initial_steamcommunity_com.bin \ + tls_clienthello_sochi_park.bin quic_initial_4pda_to.bin quic_initial_5ka_ru.bin \ + tls_clienthello_5ka_ru.bin quic_initial_rutube_ru.bin; do + if [ ! -f "/opt/zapret/files/fake/$f" ]; then + [ "$msg" = 0 ] && { echo "==> Скачиваем дополнительные fake-файлы"; msg=1; } + wget -q --timeout=20 -U "Mozilla/5.0" -O "/opt/zapret/files/fake/$f" "${FLOWSEAL_FAKE_RAW}/$f" \ + || echo "!! Не удалось загрузить файл $f" + fi + done +} + +do_flowseal_download() { + _ensure_deps + local out zip tmp attempt=1 max_attempts=5 + out="$(_flowseal_file)"; zip="$JOBS_DIR/flowseal.zip"; tmp="$JOBS_DIR/flowseal_src" + echo "==> Скачиваем список стратегий Flowseal" + rm -rf "$tmp" "$zip"; : > "$out" + command -v unzip >/dev/null 2>&1 || $INSTALL unzip >&2 + + while [ "$attempt" -le "$max_attempts" ]; do + rm -f "$zip" + wget -q --timeout=20 -U "Mozilla/5.0" -O "$zip" "$FLOWSEAL_ZIP" >&2 + if [ -s "$zip" ] && unzip -tq "$zip" >/dev/null 2>&1; then + break + fi + attempt=$((attempt + 1)) + done + if [ "$attempt" -gt "$max_attempts" ]; then + echo "ОШИБКА: не удалось скачать целый архив за $max_attempts попыток — проверьте соединение с GitHub" + return 1 + fi + + mkdir -p "$tmp"; unzip -oq "$zip" -d "$tmp" || { echo "ОШИБКА распаковки"; return 1; } + local base="$tmp/zapret-discord-youtube-main" + + echo "==> Разбираем .bat-стратегии" + find "$base" -type f -name 'general*.bat' ! -name 'general (ALT5).bat' | while read -r F; do + MATCH=$(grep -E '^--filter-udp=19294-19344,50000-50100|^--filter-tcp=%GameFilterTCP%|^--filter-udp=%GameFilterUDP%|^--filter-tcp=2053,2083,2087,2096,8443|^--filter-tcp=443 --hostlist="%LISTS%list-google.txt"|^--filter-tcp=80,443 --hostlist="%LISTS%list-general.txt"' "$F") + [ -z "$MATCH" ] && continue + NAME=$(basename "$F" .bat) + { echo "#$NAME"; echo "$MATCH" | sed 's/--/\n--/g' | sed '/^$/d' | sed 's/[[:space:]]*$//'; echo; } >> "$out" + done + + echo "==> Приводим пути и плейсхолдеры к реальным (как в оригинальном download_strategies)" + sed -i '/--hostlist="%LISTS%list-general.txt"/d' "$out" + sed -i '/--ipset="%LISTS%ipset-all.txt"/d' "$out" + sed -i '/--hostlist="%LISTS%list-general-user.txt"/d' "$out" + sed -i '/--ipset-exclude="%LISTS%ipset-exclude.txt"/d' "$out" + sed -i '/--ipset-exclude="%LISTS%ipset-exclude-user.txt"/d' "$out" + sed -i '/--hostlist-exclude="%LISTS%list-exclude-user.txt"/d' "$out" + sed -i 's|"%LISTS%list-exclude.txt"|/opt/zapret/ipset/zapret-hosts-user-exclude.txt|g' "$out" + sed -i 's|"%LISTS%list-google.txt"|/opt/zapret/ipset/zapret-hosts-google.txt|g' "$out" + sed -i 's/--new[[:space:]]\^/--new/g' "$out" + + sed -i 's|"%BIN%tls_clienthello_www_google_com.bin"|/opt/zapret/files/fake/tls_clienthello_www_google_com.bin|g' "$out" + sed -i 's|"%BIN%tls_clienthello_sochi_park.bin"|/opt/zapret/files/fake/tls_clienthello_sochi_park.bin|g' "$out" + sed -i 's|"%BIN%stun.bin"|/opt/zapret/files/fake/stun.bin|g' "$out" + sed -i 's|"%BIN%tls_clienthello_4pda_to.bin"|/opt/zapret/files/fake/4pda.bin|g' "$out" + sed -i 's|"%BIN%quic_initial_www_google_com.bin"|/opt/zapret/files/fake/quic_initial_www_google_com.bin|g' "$out" + sed -i 's|"%BIN%stun2.bin"|/opt/zapret/files/fake/stun2.bin|g' "$out" + sed -i 's|"%BIN%quic_initial_tencent_com.bin"|/opt/zapret/files/fake/quic_initial_tencent_com.bin|g' "$out" + sed -i 's|"%BIN%quic_initial_steamcommunity_com.bin"|/opt/zapret/files/fake/quic_initial_steamcommunity_com.bin|g' "$out" + sed -i 's|"%BIN%quic_initial_4pda_to.bin"|/opt/zapret/files/fake/quic_initial_4pda_to.bin|g' "$out" + sed -i 's|"%BIN%ACTIVE_DISCORD_UDP.bin"|/opt/zapret/files/fake/quic_initial_steamcommunity_com.bin|g' "$out" + sed -i 's|"%BIN%ACTIVE_GAME_UDP.bin"|/opt/zapret/files/fake/quic_initial_4pda_to.bin|g' "$out" + sed -i 's|"%BIN%tls_clienthello_max_ru.bin"|/opt/zapret/files/fake/tls_clienthello_www_onetrust_com.bin|g' "$out" + sed -i 's|"%BIN%quic_initial_5ka_ru.bin"|/opt/zapret/files/fake/quic_initial_5ka_ru.bin|g' "$out" + sed -i 's|"%BIN%quic_initial_rutube_ru.bin"|/opt/zapret/files/fake/quic_initial_rutube_ru.bin|g' "$out" + sed -i 's|\^!|/opt/zapret/files/fake/tls_clienthello_www_google_com.bin|g' "$out" + + sed -i "s|%GameFilterTCP%|$PORTS_TCP|g" "$out" + sed -i "s|%GameFilterUDP%|$PORTS_UDP|g" "$out" + + sed -i 's/[[:space:]]\+$//' "$out" + sed -i '/^[[:space:]]*$/d' "$out" + sed -i '/^--new$/ { N; /^--new\n$/d; }' "$out" + + rm -rf "$tmp" "$zip" + + do_add_fake_flow + + echo "==> Готово, стратегий: $(grep -c '^#' "$out")" +} + +strategy_list_flowseal() { + local f; f="$(_flowseal_file)" + if [ ! -s "$f" ] || [ "$1" = refresh ]; then + job_start flowseal_download do_flowseal_download + return + fi + printf '{"items":[%s]}\n' "$( + grep '^#' "$f" | sed 's/^#//' | awk "$ZM_JESC_AWK"'{ + v = jesc($0) + printf "%s{\"id\":\"%s\",\"label\":\"%s\"}", (NR>1?",":""), v, v + }' + )" +} + +strategy_set_flowseal() { + local name="$1" f block q 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. Если файл не кончается переводом строки (так бывает после правки руками +# или в редакторе), сначала ставит его — иначе новая запись склеится с последней и сломает обе. +_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 +} + +# Раньше все AI-сервисы были одним блоком, и в части AI Studio повторялись адреса Gemini и Copilot. +# Теперь у каждого сервиса свой блок: старые строки AI Studio меняем на новые на том же месте — набор адресов не меняется. +_hosts_ai_migrate() { + [ -s "$HOSTS_FILE" ] || return 1 + grep -qF -e 'aisandbox-pa.googleapis.com xsts.auth.xboxlive.com' -e 'stitch.withgoogle.com gemini.google.com' -e 'xboxdesignlab.xbox.com xgpuweb.gssv-play-prod.xboxlive.com' "$HOSTS_FILE" || return 1 + local tmp="$HOSTS_FILE.zmtmp" + awk 'function n(s) { gsub(/\r/, "", s); gsub(/[ \t]+/, " ", s); sub(/^ /, "", s); sub(/ $/, "", s); return s } + BEGIN { + r["45.155.204.190 aistudio.google.com generativelanguage.googleapis.com aitestkitchen.withgoogle.com aisandbox-pa.googleapis.com xsts.auth.xboxlive.com"] = "45.155.204.190 aistudio.google.com generativelanguage.googleapis.com aitestkitchen.withgoogle.com aisandbox-pa.googleapis.com" + r["45.155.204.190 notebooklm.google.com jules.google.com stitch.withgoogle.com gemini.google.com copilot.microsoft.com edgeservices.bing.com"] = "45.155.204.190 notebooklm.google.com jules.google.com stitch.withgoogle.com" + r["45.155.204.190 rewards.bing.com sydney.bing.com xboxdesignlab.xbox.com xgpuweb.gssv-play-prod.xboxlive.com xgpuwebf2p.gssv-play-prod.xboxlive.com"] = "45.155.204.190 xboxdesignlab.xbox.com" + } + function has(ip, h) { return ((ip " " h) in got) } + NR == FNR { k = n($0); if (k ~ /^#/ || (k in r)) next; m = split(k, a, " "); for (i = 2; i <= m; i++) got[a[1] " " a[i]] = 1; next } + { + k = n($0) + if (!(k in r)) { print; next } + if (!(r[k] in done)) { + done[r[k]] = 1; m = split(r[k], a, " "); need = 0 + for (i = 2; i <= m; i++) if (!has(a[1], a[i])) { need = 1; got[a[1] " " a[i]] = 1 } + if (need) print r[k] + } + m = split(k, a, " "); rest = "" + for (i = 2; i <= m; i++) if (!has(a[1], a[i])) { rest = rest " " a[i]; got[a[1] " " a[i]] = 1 } + if (rest != "") print a[1] rest + }' "$HOSTS_FILE" "$HOSTS_FILE" > "$tmp" && cat "$tmp" > "$HOSTS_FILE" + rm -f "$tmp" + return 0 +} + +hosts_ai_migrate() { + _hosts_ai_migrate && /etc/init.d/dnsmasq restart >/dev/null 2>&1 + return 0 +} + +hosts_status() { + local 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" 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")" + # Все блоки сверяем с hosts за один проход awk — то же, что _hosts_block_status по каждому блоку, но без десятков процессов. + 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 + # вместо hosts могла прийти страница ошибки — такой файл ставить нельзя: строк «адрес имя» в нём нет + 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 + mv "$tmp" "$HOSTS_FILE" + /etc/init.d/dnsmasq restart >/dev/null 2>&1 + printf '{"ok":true,"region":"%s"}\n' "$region" +} + +hosts_reset() { + printf '%s\n' "127.0.0.1 localhost" "" "::1 localhost ip6-localhost ip6-loopback" "ff02::1 ip6-allnodes" "ff02::2 ip6-allrouters" > "$HOSTS_FILE" + /etc/init.d/dnsmasq restart >/dev/null 2>&1 + printf '{"ok":true}\n' +} + +HOSTS_EDIT_MAX=262144 + +hosts_file_get() { + local sz n + [ -f "$HOSTS_FILE" ] || { echo '{"error":"файл hosts не найден"}'; return 1; } + sz="$(wc -c < "$HOSTS_FILE" | tr -d ' ')" + if [ "${sz:-0}" -gt "$HOSTS_EDIT_MAX" ]; then + n="$(wc -l < "$HOSTS_FILE" | tr -d ' ')" + printf '{"too_big":true,"size":%s,"lines":%s}\n' "$sz" "${n:-0}" + return 0 + fi + printf '{"content":"%s"}\n' "$(esc_ml "$(cat "$HOSTS_FILE")")" +} + +hosts_file_set() { + local content="$1" + [ -n "$content" ] || { echo '{"error":"пустой файл — сохранение отменено"}'; return 1; } + [ "$(printf '%s' "$content" | wc -c)" -gt "$HOSTS_EDIT_MAX" ] && { echo '{"error":"файл слишком большой для редактора"}'; return 1; } + cp "$HOSTS_FILE" "$HOSTS_FILE.bak" 2>/dev/null + printf '%s' "$content" > "$HOSTS_FILE" + [ -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' +} + +# Большие тексты (hosts, списки, конфиги) приходят частями: uhttpd не принимает в /ubus запрос больше 64 КБ. +# Части копятся в ZM_UP_FILE, а итоговый вызов вместо текста передаёт метку @zmupload. +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 +} + +_ipv6_enabled_in_zapret() { + [ -f "$CONF" ] && grep -q "option DISABLE_IPV6 '0'" "$CONF" && echo true || echo false +} + +_flow_offloading_fix_applied() { + grep -q 'ct original packets ge 30 flow offload @ft;' /usr/share/firewall4/templates/ruleset.uc 2>/dev/null \ + && echo true || echo false +} + +_expert_mode() { [ -f "$EXPERT_MODE_FILE" ] && echo true || echo false; } + +system_status() { + printf '{"quic_blocked":%s,"ipv6_enabled":%s,"flow_offloading_fix":%s,"expert_mode":%s,"pkg":"%s",%s}\n' \ + "$(_quic_blocked)" "$(_ipv6_enabled_in_zapret)" "$(_flow_offloading_fix_applied)" "$(_expert_mode)" "$PKG" "$(_zt_json)" +} + +# IPv6 проверяем в двух местах одинаково. Пинг по имени зависит от DNS: если DNS роутера не отдаёт +# IPv6-адреса сайтов (так настроен, например, Mihomo в Mixomo), он не проходит и при рабочем IPv6. +# Поэтому связь проверяем по адресу, а DNS — отдельно. +ZM_V6_ADDRS="2001:4860:4860::8888 2606:4700:4700::1111" +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 +} +# Отдаёт ли DNS роутера IPv6-адреса сайтов (без них устройства ходят на сайты по IPv4) +_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 }' +} +# live — IPv6 работает и DNS отдаёт IPv6-адреса; nodns — работает, но адресов DNS не отдаёт; dead — не работает +_zm_v6_probe() { + ip -6 route show default 2>/dev/null | grep -q . || { echo dead; return; } + _zm_v6_ms $ZM_V6_ADDRS >/dev/null || { echo dead; return; } + if _zm_v6_dns; then echo live; else echo nodns; fi +} +# Для плашки на каждой странице: ответ из памяти, обновление раз в 10 минут в фоне — страницу не держим +_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 dns=true st=dead + t4=$(ping -4 -c1 -W2 google.com 2>/dev/null | grep 'time=' | sed -E 's/.*time=([0-9.]+).*/\1/') + t6="$(_zm_v6_ms google.com)" + if [ -z "$t6" ]; then + t6="$(_zm_v6_ms $ZM_V6_ADDRS)" + [ -n "$t6" ] && ! _zm_v6_dns && dns=false + fi + if [ -n "$t6" ]; then st=live; [ "$dns" = false ] && st=nodns; fi + mkdir -p "$JOBS_DIR"; echo "$st" > "$ZM_V6_STATE" + 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() { + [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } + if grep -q "option DISABLE_IPV6 '0'" "$CONF"; then + sed -i "s/option DISABLE_IPV6 '0'/option DISABLE_IPV6 '1'/" "$CONF" + zapret_restart + printf '{"ok":true,"ipv6_enabled":false}\n' + return + fi + if ! _zm_v6_ms $ZM_V6_ADDRS >/dev/null; then + echo '{"error":"IPv6 недоступен на роутере — включение не рекомендуется"}' + return 1 + fi + sed -i "s/option DISABLE_IPV6 '1'/option DISABLE_IPV6 '0'/" "$CONF" + zapret_restart + printf '{"ok":true,"ipv6_enabled":true}\n' +} + +system_toggle_flow_offloading_fix() { + local template="/usr/share/firewall4/templates/ruleset.uc" + [ -f "$template" ] || { echo '{"error":"шаблон firewall4 не найден"}'; return 1; } + if grep -q 'ct original packets ge 30 flow offload @ft;' "$template"; then + sed -i 's/meta l4proto { tcp, udp } ct original packets ge 30 flow offload @ft;/meta l4proto { tcp, udp } flow offload @ft;/' "$template" + fw4 restart >/dev/null 2>&1 + printf '{"ok":true,"flow_offloading_fix":false}\n' + return + fi + sed -i 's/meta l4proto { tcp, udp } flow offload @ft;/meta l4proto { tcp, udp } ct original packets ge 30 flow offload @ft;/' "$template" + fw4 restart >/dev/null 2>&1 + printf '{"ok":true,"flow_offloading_fix":true}\n' +} + + +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/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 \ + "$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"/forkop-src "$JOBS_DIR"/forkop-import "$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 \ + /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.* "$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 /opt/zapret-manager-luci/forkop.uc /usr/lib/zapret-manager* /etc/zapret_manager_expert_mode* /usr/share/zm-redbtn/lists/* /usr/share/zm-redbtn/lists \ + /www/luci-static/resources/view/zapret-manager/test.js /www/luci-static/resources/view/zapret-manager/redbtn.js \ + /www/luci-static/resources/view/zapret-manager/youtube.js /www/luci-static/resources/view/zapret-manager/game.js \ + /www/luci-static/resources/view/zapret-manager/discord.js /www/luci-static/resources/view/zapret-manager/exclusions.js \ + /www/luci-static/resources/view/zapret-manager/forkop.js; do + [ -e "$f" ] || [ -L "$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|$RB_SHARE/lists|$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_SHARE"/lists/*.lst) + _rb_list_get "$b" >/dev/null 2>&1 + if [ "$f" != "$ZM_LISTS_DIR/$b" ] && [ -s "$ZM_LISTS_DIR/$b" ]; then mkdir -p "${f%/*}" 2>/dev/null && cp -f "$ZM_LISTS_DIR/$b" "$f" 2>/dev/null; fi ;; + "$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_status() { + printf '{%s}\n' "$(_zm_usage_json)" +} + +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/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) ;; + /usr/lib/zapret-manager*|/etc/zapret_manager_expert_mode*|/usr/share/zm-redbtn/lists|/usr/share/zm-redbtn/lists/?*|/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")" +} + +# Удаление панели. «keep» (так по умолчанию): настройки панели остаются в /opt/zapret-manager-luci/state — +# блокировка рекламы (списки, белый список, статистика, устройства без блокировки), предупреждения, версии, +# а задания расписания панели сохраняются туда же. Поставили панель снова — установщик возвращает +# расписание, а сторож за пару минут снова включает блокировку рекламы. «wipe» — удалить всё, как раньше. +system_uninstall_panel() { + local script="/tmp/zm_uninstall_panel.sh" j + if j="$(_zm_busy_job)"; then + printf '{"error":"%s"}\n' "$(esc "идёт операция «$j» — дождитесь её окончания и удалите панель")" + return 1 + fi + cat > "$script" << 'ZM_UNINSTALL_EOF' +sleep 1 +/opt/zapret-manager-luci/backend.sh redbtn_panel_gone >/dev/null 2>&1 +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 +ZM_OPT="/opt/zapret-manager-luci" +# настройки сохраняем: задания расписания панели — в state, чтобы установщик вернул их при новой установке +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" ]; }; 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/*) [ -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 \ + /usr/lib/zapret-manager* /etc/zapret_manager_expert_mode* /etc/hosts.zmtmp /etc/hosts.zmdrop \ + /www/zm /www/zm-webui.html /etc/zm-warp-own.conf /usr/bin/zmw" +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 +if [ -s /etc/zm-steer/owned ]; then : +elif cat /etc/steer/*.json /etc/steer/*.yaml /etc/steer/*.yml 2>/dev/null | sed 's|\\/|/|g' | grep -q '/usr/share/zm-redbtn/lists/'; then rm -f /usr/share/zm-redbtn/services.conf /usr/share/zm-redbtn/doh.conf +else rm -rf /usr/share/zm-redbtn; fi +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" + chmod 0755 "$script" + ( sh "$script" >/dev/null 2>&1 & ) + printf '{"ok":true,"kept":%s}\n' "$([ "$1" = wipe ] && echo false || echo true)" +} + +# ---- Зеркала OpenWrt ----------------------------------------------------------------------------- +# Список пакетов (opkg/apk update) не обновился или обновился с ошибками у основного репозитория — +# панель сама проверяет зеркала OpenWrt (все разом, по настоящему файлу списка пакетов этого роутера), +# переключается на самое быстрое, на котором обновление проходит без ошибок, и запоминает это. Не +# помогло ни одно — возвращает прежний адрес. Свои (сторонние) репозитории не трогаются. +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" +# Первый файл списка пакетов: apk — сам адрес (.adb), opkg — адрес ленты + /Packages.gz +_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/#"; } +# Переписывает distfeeds на ХОСТ (копия прежнего — в .zm-bak) +_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"; } +# Проверка зеркала: файл списка пакетов отдаётся (код 200). Вывод: «мс хост» или ничего. +_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 }' +} +# Ошибки обновления, относящиеся к репозиторию OpenWrt на ХОСТЕ (вывод opkg/apk в ФАЙЛЕ) +_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" +} +# Обновление списка пакетов на текущем адресе без ошибок у репозитория OpenWrt +_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 try + [ -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' +} + +EXCLUDE_DOMAINS_FILE="/opt/zapret/ipset/zapret-hosts-user-exclude.txt" + + + + +_zl_path() { + case "$1" in + exclude) echo /opt/zapret/ipset/zapret-hosts-user-exclude.txt ;; + user) echo /opt/zapret/ipset/zapret-hosts-user.txt ;; + google) echo /opt/zapret/ipset/zapret-hosts-google.txt ;; + *) return 1 ;; + esac +} +_zl_json_text() { + [ -f "$1" ] || return 0 + local tab; tab="$(printf '\t')" + tr -d '\r\000-\010\013\014\016-\037' < "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e "s/$tab/\\\\t/g" | awk '{ printf "%s%s", (NR > 1 ? "\\n" : ""), $0 }' +} +_zl_count() { local n; [ -f "$1" ] && n=$(grep -cvE '^[[:space:]]*(#.*)?$' "$1"); echo "${n:-0}"; } +_zl_info() { + local f; f="$(_zl_path "$1")" + printf '{"id":"%s","path":"%s","exists":%s,"count":%s,"size":%s,"mtime":%s,"auto":"%s","nochange":%s}' "$1" "$f" \ + "$([ -f "$f" ] && echo true || echo false)" "$(_zl_count "$f")" \ + "$([ -f "$f" ] && wc -c < "$f" | tr -d ' ' || echo 0)" "$([ -f "$f" ] && date -r "$f" +%s 2>/dev/null || echo 0)" \ + "$([ "$1" = exclude ] && _zx_auto_get)" "$(_nochange_in "$f" && echo true || echo false)" +} + +ZX_AUTO_TAG="# zm-excl-auto" + +_zx_auto_get() { + local v + v="$(grep -F "$ZX_AUTO_TAG=" "$CRON_FILE" 2>/dev/null | head -n1 | sed 's/.*=//')" + case "$v" in h2|h4|h6|h12) echo "$v" ;; *) echo off ;; esac +} + +zapret_excl_auto_set() { + local v="$1" spec="" + case "$v" in + off) ;; + h2|h4|h6|h12) spec="17 */${v#h} * * *" ;; + *) echo '{"error":"неизвестный интервал"}'; return 1 ;; + esac + [ -z "$spec" ] || [ -x /etc/init.d/zapret ] || { echo '{"error":"Zapret не установлен"}'; return 1; } + mkdir -p "$(dirname "$CRON_FILE")" + touch "$CRON_FILE" + sed -i "\\|$ZX_AUTO_TAG=|d" "$CRON_FILE" + [ -n "$spec" ] && echo "$spec /opt/zapret-manager-luci/backend.sh zapret_excl_tick >/dev/null 2>&1 $ZX_AUTO_TAG=$v" >> "$CRON_FILE" + /etc/init.d/cron enable >/dev/null 2>&1 + /etc/init.d/cron restart >/dev/null 2>&1 + printf '{"ok":true,"info":%s}\n' "$(_zl_info exclude)" +} + +zapret_excl_tick() { + local f tmp + [ -x /etc/init.d/zapret ] || return 0 + f="$(_zl_path exclude)"; tmp="$f.zmauto" + _nochange_in "$f" && return 0 + _zm_busy_job >/dev/null 2>&1 && return 0 + mkdir -p "$(dirname "$f")" + rm -f "$tmp" + wget -q --timeout=20 -U "Mozilla/5.0" -O "$tmp" "$EXCLUDE_URL" 2>/dev/null + if [ ! -s "$tmp" ] || grep -qi ' "$tmp" || { rm -f "$tmp"; echo '{"error":"не удалось записать файл"}'; return 1; } + mv -f "$tmp" "$f" + chmod 644 "$f" + zapret_restart + printf '{"ok":true,"info":%s}\n' "$(_zl_info "$1")" +} + +_nochange_strip() { + awk '{ l = tolower($0); if (l ~ /^[ \t]*#[ \t]*nochange[ \t]*$/) next; print }' +} + +zapret_nochange_set() { + local t="${1%%:*}" st="${1#*:}" f tmp + case "$st" in on|off) ;; *) echo '{"error":"неизвестное значение"}'; return 1 ;; esac + [ -x /etc/init.d/zapret ] || { echo '{"error":"Zapret не установлен"}'; return 1; } + case "$t" in + strategy) + [ -f "$CONF" ] || { echo '{"error":"конфигурация Zapret не найдена"}'; return 1; } + grep -q "^[[:space:]]*option NFQWS_OPT '\$" "$CONF" || { echo '{"error":"в конфигурации не найден блок NFQWS_OPT"}'; return 1; } + tmp="$CONF.zmnc" + if [ "$st" = on ]; then + _nochange_strategy || { + awk '{ print } !d && /^[ \t]*option NFQWS_OPT \047$/ { print "#nochange"; d = 1 }' "$CONF" > "$tmp" && [ -s "$tmp" ] && cat "$tmp" > "$CONF" + rm -f "$tmp" + } + else + awk '/^[ \t]*option NFQWS_OPT \047$/ { b = 1; print; next } b && /^[ \t]*\047$/ { b = 0 } b { l = tolower($0); if (l ~ /^[ \t]*#[ \t]*nochange[ \t]*$/) next } { print }' "$CONF" > "$tmp" && [ -s "$tmp" ] && cat "$tmp" > "$CONF" + rm -f "$tmp" + _nochange_strategy && { echo '{"error":"пометка #nochange стоит внутри другой строки — уберите её вручную в редакторе стратегии","nochange":true}'; return 1; } + fi + printf '{"ok":true,"nochange":%s}\n' "$(_nochange_strategy && echo true || echo false)" ;; + exclude|user|google) + f="$(_zl_path "$t")"; tmp="$f.zmnc" + mkdir -p "$(dirname "$f")" + if [ "$st" = on ]; then + _nochange_in "$f" || { { echo "#nochange"; [ -f "$f" ] && cat "$f"; :; } > "$tmp" && mv -f "$tmp" "$f"; chmod 644 "$f"; } + elif [ -f "$f" ]; then + _nochange_strip < "$f" > "$tmp" && mv -f "$tmp" "$f"; chmod 644 "$f" + _nochange_in "$f" && { printf '{"error":"пометка #nochange стоит внутри другой строки — уберите её вручную в редакторе списка","info":%s}\n' "$(_zl_info "$t")"; return 1; } + fi + printf '{"ok":true,"info":%s}\n' "$(_zl_info "$t")" ;; + *) echo '{"error":"неизвестный список"}'; return 1 ;; + esac +} + +zapret_list_restore() { + case "$1" in + auto=*) zapret_excl_auto_set "${1#auto=}"; return ;; + nochange=*) zapret_nochange_set "${1#nochange=}"; return ;; + esac + [ "$1" = exclude ] || { echo '{"error":"восстановить можно только список исключений"}'; return 1; } + [ -x /etc/init.d/zapret ] || { echo '{"error":"Zapret не установлен"}'; return 1; } + local f tmp; f="$(_zl_path exclude)"; tmp="$f.zmdl" + _nochange_in "$f" && { echo '{"error":"включено «Не изменять список» (#nochange) — панель его не меняет. Выключите этот переключатель, чтобы восстановить исходный"}'; return 1; } + mkdir -p "$(dirname "$f")" + rm -f "$tmp" + wget -q --timeout=20 -U "Mozilla/5.0" -O "$tmp" "$EXCLUDE_URL" 2>/dev/null + if [ ! -s "$tmp" ]; then rm -f "$tmp"; echo '{"error":"не удалось скачать список исключений — текущий список не тронут"}'; return 1; fi + mv -f "$tmp" "$f"; chmod 644 "$f" + zapret_restart + zapret_list_get exclude +} + +nfqws_opt_get() { + [ -f "$CONF" ] || { printf '{"content":""}\n'; return 0; } + local content + content=$(sed -n "/^[[:space:]]*option NFQWS_OPT '\$/,/^[[:space:]]*'\$/p" "$CONF" | sed '1d;$d') + printf '{"content":"%s"}\n' "$(esc_ml "$content")" +} + +nfqws_opt_set() { + local content="$1" + [ -x /etc/init.d/zapret ] || { echo '{"error":"Zapret не установлен"}'; return 1; } + [ -f "$CONF" ] || { echo '{"error":"конфигурация Zapret не найдена"}'; return 1; } + grep -q "^[[:space:]]*option NFQWS_OPT '\$" "$CONF" || { echo '{"error":"в конфигурации не найден блок NFQWS_OPT"}'; return 1; } + 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" + [ -f /opt/zapret/sync_config.sh ] && chmod +x /opt/zapret/sync_config.sh + zapret_restart + sleep 1 + printf '{"ok":true}\n' +} + +TG_MTPROTO_VER="0.10" +TGWS_VERSION="0.3.1" +TGWS_BASE_URL="https://gitlab.com/xyzmean/brb/-/raw/main/dist" +TGWS_VERSION_URL="https://gitlab.com/xyzmean/brb/-/raw/main/VERSION" +TG_GO_VER="1.4.1" +TG_RS_VER="2.3.3" +TG_BIN_GO="/usr/bin/tg-ws-proxy-go" +TG_INIT_GO="/etc/init.d/tg-ws-proxy-go" +TG_BIN_RS="/usr/bin/tg-ws-proxy-rs" +TG_INIT_RS="/etc/init.d/tg-ws-proxy-rs" +TG_SECRET_RS_FILE="/etc/tg-ws-proxy-rs.secret" +TG_SECRET_MT_FILE="/etc/tg-ws-proxy/secret.conf" +TG_VER_GO_FILE="/etc/tg-ws-proxy-go.ver" +TG_VER_RS_FILE="/etc/tg-ws-proxy-rs.ver" +TG_REPO_MT="spatiumstas/tg-ws-proxy-go" +TG_REPO_GO="d0mhate/-tg-ws-proxy-Manager-go" +TG_REPO_RS="valnesfjord/tg-ws-proxy-rs" + +_gh_latest_tag() { + curl -Ls --connect-timeout 5 --max-time 12 -o /dev/null -w '%{url_effective}' "https://github.com/$1/releases/latest" 2>/dev/null | + sed -n 's#.*/releases/tag/##p' | head -n1 | grep -E '^[vV]?[0-9]+(\.[0-9]+)+$' +} +_tg_url_ok() { curl -sfIL --connect-timeout 6 --max-time 15 -o /dev/null "$1" 2>/dev/null; } + +_tg_mt_url() { + local sfx="$3" raz="ipk" arch + [ "$PKG" = apk ] && { sfx="r$3"; raz="apk"; } + arch="$(awk -F\' '/DISTRIB_ARCH/ {print $2}' /etc/openwrt_release)" + echo "${GH_MAIN}/$TG_REPO_MT/releases/download/$1/tg-ws-proxy_$2-${sfx}_openwrt_${arch}.${raz}" +} + +_tg_tag_mt() { + local t v r + t="$(_gh_latest_tag "$TG_REPO_MT")"; [ -n "$t" ] || return 1 + v="$(_ver_norm "$t")" + for r in 1 2 3; do _tg_url_ok "$(_tg_mt_url "$t" "$v" "$r")" && { echo "$t $r"; return 0; }; done + return 1 +} +_tg_tag_go() { + local t f + f="$(_tg_arch_go)" || return 1 + t="$(_gh_latest_tag "$TG_REPO_GO")"; [ -n "$t" ] || return 1 + _tg_url_ok "${GH_MAIN}/$TG_REPO_GO/releases/download/$t/$f" && echo "$t" +} +_tg_tag_rs() { + local t f + f="$(_tg_arch_rs)" || return 1 + t="$(_gh_latest_tag "$TG_REPO_RS")"; [ -n "$t" ] || return 1 + _tg_url_ok "${GH_MAIN}/$TG_REPO_RS/releases/download/$t/$f.tar.gz" && echo "$t" +} + +_tg_latest() { + local t + t="$(_zm_cached "tg_$1" "_tg_tag_$1")" + [ -n "$t" ] || case "$1" in mt) t="$TG_MTPROTO_VER 1" ;; go) t="v$TG_GO_VER" ;; rs) t="v$TG_RS_VER" ;; esac + echo "$t" +} + +_tg_fresh_tag() { + local t + t="$(ZM_VER_FORCE=1 _zm_cached "tg_$1" "_tg_tag_$1")" + [ -n "$t" ] || t="$(_tg_latest "$1")" + echo "$t" +} + +_tg_ver_migrate() { + [ -s /usr/bin/tg-ws-proxy-go.ver ] && { [ -s "$TG_VER_GO_FILE" ] || mv /usr/bin/tg-ws-proxy-go.ver "$TG_VER_GO_FILE"; rm -f /usr/bin/tg-ws-proxy-go.ver; } + [ -s /usr/bin/tg-ws-proxy-rs.ver ] && { [ -s "$TG_VER_RS_FILE" ] || mv /usr/bin/tg-ws-proxy-rs.ver "$TG_VER_RS_FILE"; rm -f /usr/bin/tg-ws-proxy-rs.ver; } + return 0 +} + +_tg_newer() { [ -n "$1" ] && [ -n "$2" ] && _st_ver_lt "$(_ver_norm "$1")" "$(_ver_norm "$2")"; } + +_zm_pkg_files() { + if [ "$PKG" = apk ]; then apk info -L "$1" 2>/dev/null | grep -v 'contains:$' | grep -v '^$' | sed 's#^/*#/#' + else opkg files "$1" 2>/dev/null | grep '^/'; fi +} + +_zm_pkg_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_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_left() { + local f left="" + for f in "$@"; do [ -e "$f" ] || [ -L "$f" ] && left="$left $f"; done + [ -z "$left" ] && return 0 + echo "!! Не удалось удалить:$left" + return 1 +} + +_zm_stop_svc() { + local s="$1" b="$2" + [ -x "/etc/init.d/$s" ] && { "/etc/init.d/$s" stop >/dev/null 2>&1; "/etc/init.d/$s" disable >/dev/null 2>&1; } + [ -n "$b" ] && killall "$b" >/dev/null 2>&1 + rm -f /etc/rc.d/[SK][0-9][0-9]"$s" +} + +# Переустановка: удаление печатает не «Готово: … удалён», а короткую строку — итог скажет установка. +_zm_reinstall() { + 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-ws-proxy-rs (luci-app-tg-ws-proxy-rs или его install.sh) ставит свой init.d: бинарник +# запускается без аргументов, а порт, ключ и адрес ссылки берутся из /etc/config/tg-ws-proxy-rs. +# Такой установкой управляет менеджер пакетов — панель её только показывает. +_tg_rs_uci() { + [ -f /etc/config/tg-ws-proxy-rs ] && [ -f "$TG_INIT_RS" ] && ! grep -q -- '--secret' "$TG_INIT_RS" +} +# Прокси выключен в настройках пакета (option enabled '0') — init.d тогда ничего не запускает +_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-ws-proxy-rs: dd + 32-символьный ключ, +# готовый dd/ee-ключ как есть, а при FakeTLS-домене — ee + ключ + hex(домен) +_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 + mt_ver=$(apk info -v 2>/dev/null | grep '^tg-ws-proxy-' | grep -v '^tg-ws-proxy-go' | head -n1 | sed -E 's/^tg-ws-proxy-([0-9.]+).*/\1/') + else + mt_ver=$(opkg list-installed 2>/dev/null | awk '$1=="tg-ws-proxy"{print $3}' | sed 's/-[0-9]*$//; s/-r[0-9]*$//') + fi + fi + _tg_ver_migrate + if [ -f "$TG_INIT_GO" ]; then + go="installed"; pidof tg-ws-proxy-go >/dev/null 2>&1 && go_running="true" + [ -f "$TG_VER_GO_FILE" ] && go_ver=$(cat "$TG_VER_GO_FILE") + fi + if [ -f "$TG_INIT_RS" ]; then + rs="installed"; pidof tg-ws-proxy-rs >/dev/null 2>&1 && rs_running="true" + [ -f "$TG_VER_RS_FILE" ] && rs_ver=$(cat "$TG_VER_RS_FILE") + fi + + [ -f "$TG_SECRET_MT_FILE" ] && secret_mt=$(grep '^SECRET=' "$TG_SECRET_MT_FILE" | cut -d= -f2) + if _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=$(sed -n 's/.*--secret[[:space:]]*\([0-9a-fA-F]\{32\}\).*/\1/p' "$TG_INIT_RS" | head -n1) + [ -z "$secret_rs" ] && [ -f "$TG_SECRET_RS_FILE" ] && secret_rs=$(cat "$TG_SECRET_RS_FILE") + 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_l go_l rs_l + mt_l="$(_tg_latest mt)"; mt_l="$(_ver_norm "${mt_l%% *}")"; go_l="$(_ver_norm "$(_tg_latest go)")"; rs_l="$(_ver_norm "$(_tg_latest rs)")" + printf '{"mtproto":"%s","mtproto_running":%s,"mtproto_version":"%s","mtproto_latest":"%s","mtproto_newer":%s,"socks5":"%s","socks5_running":%s,"socks5_version":"%s","socks5_latest":"%s","socks5_newer":%s,"rust":"%s","rust_running":%s,"rust_version":"%s","rust_latest":"%s","rust_newer":%s,"rust_uci":%s,"rust_enabled":%s,"rust_port":"%s","rust_host":"%s","lan_ip":"%s","secret_mtproto":"%s","secret_rust":"%s","auto":%s}\n' \ + "$mt" "$mt_running" "$(esc "$mt_ver")" "$(esc "$mt_l")" "$(_tg_newer "$mt_ver" "$mt_l" && echo true || echo false)" \ + "$go" "$go_running" "$(esc "$go_ver")" "$(esc "$go_l")" "$(_tg_newer "$go_ver" "$go_l" && echo true || echo false)" \ + "$rs" "$rs_running" "$(esc "$rs_ver")" "$(esc "$rs_l")" "$(_tg_newer "$rs_ver" "$rs_l" && echo true || echo false)" \ + "$rs_uci" "$rs_enabled" "$(esc "$rs_port")" "$(esc "$rs_host")" \ + "$(esc "$lan_ip")" "$(esc "$secret_mt")" "$(esc "$secret_rs")" "$(_tg_auto_json)" +} + +TG_AUTO_TAG="# zm-tg-auto" +TG_WATCH_FLAG="$ZM_STATE_DIR/tg.watch" + +_tg_auto_init() { + case "$1" in + mtproto) echo /etc/init.d/tg-ws-proxy ;; + socks5) echo "$TG_INIT_GO" ;; + rust) echo "$TG_INIT_RS" ;; + tgws) echo /etc/init.d/tgws ;; + *) return 1 ;; + esac +} + +_tg_auto_alive() { + case "$1" in + mtproto) pidof tg-ws-proxy >/dev/null 2>&1 ;; + socks5) pidof tg-ws-proxy-go >/dev/null 2>&1 ;; + rust) pidof tg-ws-proxy-rs >/dev/null 2>&1 ;; + tgws) [ -n "$(tgws status 2>/dev/null)" ] ;; + esac +} + +_tg_auto_busy() { + case "$1" in + tgws) _job_running tgws_install || _job_running tgws_remove || _job_running tgws_restart || _job_running tgws_reconfigure ;; + *) _job_running "tg_install_$1" || _job_running "tg_remove_$1" ;; + esac +} + +_tg_auto_get() { + local v + v="$(grep -F "$TG_AUTO_TAG-$1=" "$CRON_FILE" 2>/dev/null | head -n1 | sed 's/.*=//')" + case "$v" in m5|m10|m15|m30|h1|h2|h3|h6|h12|d[0-9]|d1[0-9]|d2[0-3]) echo "$v" ;; *) echo off ;; esac +} + +_tg_auto_json() { + printf '{"mtproto":"%s","socks5":"%s","rust":"%s","tgws":"%s","watch":%s}' \ + "$(_tg_auto_get mtproto)" "$(_tg_auto_get socks5)" "$(_tg_auto_get rust)" "$(_tg_auto_get tgws)" \ + "$([ -f "$TG_WATCH_FLAG" ] && echo true || echo false)" +} + +_tg_auto_drop() { + grep -qF "$TG_AUTO_TAG-$1=" "$CRON_FILE" 2>/dev/null || return 0 + sed -i "\\|$TG_AUTO_TAG-$1=|d" "$CRON_FILE" + /etc/init.d/cron restart >/dev/null 2>&1 +} + +tg_auto_set() { + local id="$1" v="$2" spec init off + init="$(_tg_auto_init "$id")" || { echo '{"error":"неизвестный прокси"}'; return 1; } + off=$(( $(printf '%s' "$id" | wc -c) % 5 )) + case "$v" in + off) spec="" ;; + h1) spec="$((7 + off)) * * * *" ;; + h2|h3|h6|h12) spec="$((7 + off)) */${v#h} * * *" ;; + *) echo '{"error":"неизвестный интервал"}'; return 1 ;; + esac + [ -z "$spec" ] || [ -f "$init" ] || { echo '{"error":"этот прокси не установлен"}'; return 1; } + mkdir -p "$(dirname "$CRON_FILE")" + touch "$CRON_FILE" + sed -i "\\|$TG_AUTO_TAG-$id=|d" "$CRON_FILE" + [ -n "$spec" ] && echo "$spec /opt/zapret-manager-luci/backend.sh tg_auto_tick $id >/dev/null 2>&1 $TG_AUTO_TAG-$id=$v" >> "$CRON_FILE" + /etc/init.d/cron enable >/dev/null 2>&1 + /etc/init.d/cron restart >/dev/null 2>&1 + printf '{"ok":true,"auto":%s}\n' "$(_tg_auto_json)" +} + +tg_watch_set() { + mkdir -p "$ZM_STATE_DIR" + case "$1" in + on) : > "$TG_WATCH_FLAG" ;; + off) rm -f "$TG_WATCH_FLAG" ;; + *) echo '{"error":"неизвестное значение"}'; return 1 ;; + esac + printf '{"ok":true,"auto":%s}\n' "$(_tg_auto_json)" +} + +tg_auto_tick() { + local init + init="$(_tg_auto_init "$1")" || return 0 + [ -x "$init" ] || return 0 + "$init" enabled >/dev/null 2>&1 || return 0 + [ "$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 + raz="ipk"; [ "$PKG" = "apk" ] && raz="apk" + echo "==> Узнаём последнюю версию на GitHub ($TG_REPO_MT)" + tag="$(_tg_fresh_tag mt)"; rev="${tag#* }"; tag="${tag%% *}"; [ "$rev" = "$tag" ] && rev=1 + ver="$(_ver_norm "$tag")" + cur="$(tg_status | sed -n 's/.*"mtproto_version":"\([^"]*\)".*/\1/p')" + echo " ✓ Последняя версия: $ver${cur:+, сейчас стоит $cur}" + tmp="/tmp/tg-ws-proxy.$raz" + rm -f /etc/tg-ws-proxy.conf /etc/tg-ws-proxy.conf-opkg + $UPDATE >&2 + url="$(_tg_mt_url "$tag" "$ver" "$rev")" + echo "==> Скачиваем $(basename "$url")" + if ! curl -fsL --connect-timeout 10 --max-time 120 -o "$tmp" "$url" || [ ! -s "$tmp" ]; then + if [ "$ver" != "$TG_MTPROTO_VER" ]; then + echo "!! Для версии $ver нет пакета под эту архитектуру — ставим проверенную $TG_MTPROTO_VER" + ver="$TG_MTPROTO_VER" + url="$(_tg_mt_url "$ver" "$ver" 1)" + curl -fsL --connect-timeout 10 --max-time 120 -o "$tmp" "$url" && [ -s "$tmp" ] || { rm -f "$tmp"; echo "ОШИБКА скачивания $url"; return 1; } + else + rm -f "$tmp"; echo "ОШИБКА скачивания $url"; return 1 + fi + fi + if _pkg_is_installed tg-ws-proxy && [ ! -f /etc/init.d/tg-ws-proxy ]; then + echo "==> Пакет числится установленным, но файлов нет — ставим поверх" + if [ "$PKG" = apk ]; then _zm_run 180 apk del --force-broken-world tg-ws-proxy >/dev/null 2>&1; $INSTALL "$tmp" >&2 + else $INSTALL --force-reinstall "$tmp" >&2; fi + elif [ "$PKG" = opkg ] && _pkg_is_installed tg-ws-proxy; then + $INSTALL --force-reinstall "$tmp" >&2 + else + $INSTALL "$tmp" >&2 + fi + [ -f /etc/init.d/tg-ws-proxy ] || { echo "ОШИБКА установки: менеджер пакетов не поставил tg-ws-proxy"; rm -f "$tmp"; return 1; } + rm -f "$tmp" + mkdir -p "$(dirname "$TG_SECRET_MT_FILE")" + if ! grep -q '^SECRET=.' "$TG_SECRET_MT_FILE" 2>/dev/null; then + echo "SECRET=$(head -c16 /dev/urandom | hexdump -e '16/1 "%02x"')" > "$TG_SECRET_MT_FILE" + fi + rm -f /etc/tg-ws-proxy.conf /etc/tg-ws-proxy.conf-opkg + /etc/init.d/tg-ws-proxy enable >/dev/null 2>&1 + /etc/init.d/tg-ws-proxy restart >/dev/null 2>&1 + cur="$(tg_status | sed -n 's/.*"mtproto_version":"\([^"]*\)".*/\1/p')" + echo "==> Готово: TG WS Proxy MTProto ${cur:-$ver}" +} + + +do_tg_remove_mtproto() { + local rc=0 + echo "==> Останавливаем TG WS Proxy MTProto" + _zm_stop_svc tg-ws-proxy tg-ws-proxy + _zm_pkg_purge tg-ws-proxy || rc=1 + echo "==> Чистим файлы и настройки" + _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" + rm -f /usr/bin/tg-ws-proxy-go.ver + echo "==> Готово: TG WS Proxy SOCKS5 $(cat "$TG_VER_GO_FILE")" +} + + +do_tg_remove_socks5() { + echo "==> Удаляем TG WS Proxy SOCKS5" + _zm_stop_svc tg-ws-proxy-go tg-ws-proxy-go + echo "==> Удаляем программу, службу и настройки" + local rc=0 + _zm_wipe "$TG_BIN_GO" "$TG_BIN_GO.new" "$TG_INIT_GO" "$TG_VER_GO_FILE" /usr/bin/tg-ws-proxy-go.ver /etc/rc.d/*tg-ws-proxy-go || 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" + rm -f /usr/bin/tg-ws-proxy-rs.ver + echo "==> Готово: TG WS Proxy Rust $(cat "$TG_VER_RS_FILE")" +} + + +do_tg_remove_rust() { + echo "==> Удаляем TG WS Proxy Rust" + _zm_stop_svc tg-ws-proxy-rs tg-ws-proxy-rs + echo "==> Удаляем программу, службу и настройки" + local rc=0 + _zm_wipe "$TG_BIN_RS" "$TG_INIT_RS" "$TG_SECRET_RS_FILE" "$TG_VER_RS_FILE" /usr/bin/tg-ws-proxy-rs.ver /etc/rc.d/*tg-ws-proxy-rs || 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 ;; + 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 list-installed 2>/dev/null | awk '$1=="tgws"{print $3}' | sed 's/-r[0-9]\+$//') + fi + if [ -x /etc/init.d/tgws ]; then + installed="installed" + [ -n "$(tgws status 2>/dev/null)" ] && running="true" + domain=$(_tgws_domain) + fi + latest="$(_zm_cached tgws _tgws_latest)" + [ -n "$latest" ] || latest="$TGWS_VERSION" + printf '{"installed":"%s","running":%s,"version":"%s","latest":"%s","newer":%s,"domain":"%s"}\n' "$installed" "$running" "$(esc "$ver")" "$(esc "$latest")" "$(_tg_newer "$ver" "$latest" && echo true || echo false)" "$(esc "$domain")" +} + +do_tgws_install() { + _ensure_deps + echo "==> Устанавливаем sTGWS" + local ver arch file tmp + echo "==> Узнаём последнюю версию sTGWS" + ver="$(ZM_VER_FORCE=1 _zm_cached tgws _tgws_latest)" + [ -n "$ver" ] || ver="$TGWS_VERSION" + echo " ✓ Последняя версия: $ver" + arch="$(awk -F\' '/DISTRIB_ARCH/ {print $2}' /etc/openwrt_release)" + [ -n "$arch" ] || arch="$(opkg print-architecture 2>/dev/null | awk '$2!="all"&&$2!="noarch"{print $2}' | tail -1)" + if [ -z "$arch" ]; then + echo "ОШИБКА: не удалось определить архитектуру роутера" + return 1 + fi + file="tgws-${ver}-1_${arch}.${RAZ}" + tmp="$JOBS_DIR/tgws_install.$RAZ" + echo "==> Роутер: $arch, формат пакетов: $PKG" + echo "==> Скачиваем $file" + local attempt=1 max_attempts=5 + while [ "$attempt" -le "$max_attempts" ]; do + rm -f "$tmp" + wget -q --timeout=20 -O "$tmp" "$TGWS_BASE_URL/$file" >&2 + if [ -s "$tmp" ]; then + if [ "$RAZ" = "ipk" ]; then + tar -tzf "$tmp" 2>/dev/null | grep -q 'debian-binary' && break + else + head -c 512 "$tmp" | grep -qiE ' Обновляем список пакетов" + $UPDATE >&2 + if [ "$PKG" = "apk" ]; then + if apk info -e kmod-nft-queue >/dev/null 2>&1; then + $INSTALL kmod-nft-queue >&2 + fi + elif opkg list-installed kmod-nft-queue 2>/dev/null | grep -q .; then + opkg flag user kmod-nft-queue >/dev/null 2>&1 + fi + echo "==> Устанавливаем пакет" + $INSTALL "$tmp" || { echo "ОШИБКА: менеджер пакетов отказался ставить sTGWS"; rm -f "$tmp"; return 1; } + rm -f "$tmp" + if [ ! -x /etc/init.d/tgws ]; then + echo "ОШИБКА: установка sTGWS не удалась" + return 1 + fi + echo "==> Подбираем домен" + /etc/init.d/tgws enable >/dev/null 2>&1 + /etc/init.d/tgws restart >/dev/null 2>&1 + tgws pick >/dev/null 2>&1 + local i started=0 + for i in $(seq 1 20); do + if [ -n "$(tgws status 2>/dev/null)" ]; then + started=1 + break + fi + sleep 5 + done + if [ "$started" != "1" ]; then + echo "ОШИБКА: не удалось подобрать домен" + return 1 + fi + local domain + domain=$(_tgws_domain) + echo "==> Используем домен: ${domain:-не определён}" + _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_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"; 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)")" +} + +# ---------- Дополнения к стратегии Zapret: списки РКН, --wssize, скрипты custom.d ---------- +# РКН: большой список заблокированных доменов (zapret4rocket) лежит отдельным файлом. В режиме РКН +# основной профиль стратегии работает только по спискам — своему и РКН — вместо «всё, кроме исключений». +# Переключатели переживают смену стратегии: панель возвращает их после strategy_set_v / _flowseal. +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_SCRIPT="/opt/zapret/init.d/openwrt/custom.d/50-script.sh" + +_zo_rkn_on() { _nfq_opt_body | grep -qxF -- "--hostlist=$ZO_RKN_FILE"; } +_zo_rkn_fits() { _nfq_opt_body | grep -qxF -- "$ZO_EXCL_HL"; } + +# в блоке NFQWS_OPT: строка исключений → свой список + список РКН +_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" +} + +# Обход по своему списку: в основном профиле строка исключений → только свой список хостов +# (zapret-hosts-user.txt), как «Обход по пользовательским спискам» в консольном Zapret Manager. С РКН не +# совмещается: в режиме РКН свой список уже стоит рядом со списком РКН. Какую по счёту строку «--hostlist=свой +# список» вставила панель, запоминаем: в стратегии своя такая строка может быть и в другом профиле. +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" + # сколько своих строк «--hostlist=свой список» стоит до строки исключений — новая будет следующей + 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" +} +# обратно: та самая (k-я) строка своего списка снова становится строкой исключений +_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"; } + +# отдельный профиль в конце стратегии, как в консольном Zapret Manager +_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 +} + +# вызывается из strategy_set_v / strategy_set_flowseal: вернуть то, что было включено +_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 +} + +# какой скрипт стоит: по комментарию в начале файла (как в консольном Zapret Manager), без учёта регистра. +# Пакет zapret-openwrt сам кладёт 50-script.sh с тем же Stun4ALL — он тоже узнаётся. +_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 ;; + 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" +} + +# ---------- Автоподбор стратегии по расписанию ---------- +# Ночью (или в выбранный час) тест v / Flowseal по списку заблокированных сайтов; затем текущая +# стратегия проверяется на тех же адресах, и лучшая ставится, только если она открыла больше сайтов. +# Применяет теми же функциями, что и кнопка «Применить»: YouTube, Discord, игры, QUIC, РКН, wssize +# сохраняются. При «Не изменять стратегию» (#nochange) автоподбор ничего не трогает. +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_mode() { case "$(cat "$ZA_MODE_FILE" 2>/dev/null)" in v) echo v ;; flowseal) echo flowseal ;; *) echo v_flowseal ;; esac; } +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; } + +# Дополнения к основной стратегии, которые человек выбрал сам: YouTube (Yv / выключен), Discord (Dv / выключен), +# игры (Gv, Xtreme / выключены). Стратегия Flowseal приносит свои части для YouTube, Discord и игр и затирает +# выбор человека — после неё автоподбор возвращает их как были; меняется только основная стратегия. +_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" +} + +do_autobest() { + local mode res best bname bok btot cur cok ctot cname urls applied=0 r + mode="$(_za_mode)" + mkdir -p "$TEST_DIR"; : > "$ZA_RUN" + echo "==> Автоподбор стратегии: $(case "$mode" in v) echo v1–v10 ;; flowseal) echo Flowseal ;; *) echo 'v + Flowseal' ;; esac)" + [ -f "$CONF" ] || { echo "ОШИБКА: Zapret не установлен"; rm -f "$ZA_RUN"; return 1; } + _test_unstale + if _nochange_strategy; then + echo "==> Включено «Не изменять стратегию» (#nochange) — автоподбор ничего не меняет" + _za_last_write "$mode" "" 0 0 0 "" nochange; rm -f "$ZA_RUN"; return 0 + fi + cname="$(_nfq_names | sed 's/^#[[:space:]]*//' | grep -vE '^(udp443|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; rm -f "$ZA_RUN"; 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; rm -f "$ZA_RUN"; 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; rm -f "$ZA_RUN"; return 0 + fi + echo "==> Применяем $bname" + _zc_has && echo " · свой блок #CustomStart … #CustomEnd сохраняем — он останется в самом верху стратегии" + _zc_lift + case "$bname" in + v[1-9]|v10) r="$(strategy_set_v "$bname")" ;; + *) + # Flowseal меняет и YouTube, Discord, игры — запоминаем выбор человека и возвращаем его + _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)" + rm -f "$ZA_RUN" + return 0 +} + +# when: ЧЧ:ММ или off; mode: v | flowseal | v_flowseal. Последнее время запоминается — при повторном +# включении панель предложит его же. +test_auto_set() { + local when="${1%%|*}" mode="${1#*|}" h m + case "$mode" in v|flowseal|v_flowseal) ;; *) 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" + 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 +} + +# запуск по расписанию: только если Zapret стоит и работает, время на роутере верное и ничего не идёт +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_json() { + local h last at m b bo bt co cn res + h="$(_za_time)" + last="$(cat "$ZA_LAST" 2>/dev/null)" + if [ -n "$last" ]; then + IFS='|' read -r at m b bo bt co cn res <<-ZA_EOF + $last + ZA_EOF + last="{\"at\":${at:-0},\"mode\":\"$(esc "$m")\",\"best\":\"$(esc "$b")\",\"best_ok\":${bo:-0},\"total\":${bt:-0},\"cur_ok\":${co:-0},\"cur\":\"$(esc "$cn")\",\"result\":\"$(esc "$res")\"}" + else last=null; fi + 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","running":%s,"last":%s,"ts_at":%s,"ts_parts":[%s]}' "$h" "$(cat "$ZA_TIME_FILE" 2>/dev/null || echo 04:00)" "$(_za_mode)" "$(_za_running && echo true || echo false)" "$last" "${tsat:-0}" "$tsp" +} + +# ---------- Время на роутере: часовой пояс и синхронизация по NTP ---------- +# Нужно для всех расписаний панели: автоподбор стратегии, перезапуски, обновления списков. +_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_json() { + printf '"time":%s,"time_text":"%s","zone":"%s","utc_offset":"%s","ntp":%s' \ + "$(date +%s)" "$(date '+%d.%m.%Y %H:%M:%S')" "$(esc "$(uci -q get system.@system[0].zonename)")" "$(date +%z)" \ + "$([ "$(uci -q get system.ntp.enabled)" = 0 ] && echo false || echo true)" +} + +# Часовой пояс по месту роутера: спрашиваем несколько независимых сервисов геолокации параллельно +# и берём пояс, который назвало большинство. Затем точное время: NTP, а если провайдер режет NTP — +# по заголовку Date нескольких крупных сайтов (берём совпадающие). +_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 + # пояс без перехода на летнее время по смещению: +07:00 → <+07>-7 + 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 + # из каждого ответа: пояс IANA и город + 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" ] +} + +# точное время: NTP, иначе — заголовок Date с нескольких сайтов (две совпадающие отметки) +_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 >/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 + case "$a" in + 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)" +} + +# $4 — предел времени на попытку (по умолчанию 120 с: так качается весь установщик) +_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_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 "Терминал" ;; + 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|redbtn_deep|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 +} + +# Установщик качается фоновой задачей: с медленного GitHub это дольше, чем служба rpcd ждёт ответа, +# и кнопка «Обновить панель» получала от роутера пустой ответ («нет данных»). +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, но пока значения нет совсем, спрашивает GitHub сразу — не чаще раза в 10 минут. +# Если GitHub с роутера недоступен, страница не должна ждать его при каждом открытии: между попытками — в фоне. +_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) + [ -f "$MIHOMO_CONF" ] && grep -q '^[[:space:]]*[^#].*url: "' "$MIHOMO_CONF" && subscription="true" + + if [ -f "$MAGITRICKLE_CONF" ]; then + if grep -Fq 'name: Google_ai' "$MAGITRICKLE_CONF"; then mt_list="itdog" + elif grep -Fq 'name: Meta (WA+FB+Instagram)' "$MAGITRICKLE_CONF"; then mt_list="ih2" + elif grep -Fq 'url: https://sw.ext.io/ipset/ipset_cf.list' "$MAGITRICKLE_CONF"; then mt_list="ih1" + fi + fi + + local cronline hourm + cronline=$(grep -F "$MIXOMO_CRON_CMD" "$CRON_FILE" 2>/dev/null | head -n1) + if [ -n "$cronline" ]; then + hourm=$(echo "$cronline" | awk '{print $2}') + case "$hourm" in + */*) autorestart="every:$(echo "$hourm" | cut -d'/' -f2)" ;; + *) autorestart="daily:$hourm" ;; + esac + fi + + printf '{"mihomo":"%s","mihomo_running":%s,"mihomo_version":"%s","mihomo_latest":"%s","magitrickle":"%s","magitrickle_running":%s,"magitrickle_version":"%s","magitrickle_latest":"%s","hev":"%s","hev_running":%s,"hev_version":"%s","lan_ip":"%s","subscription":%s,"magitrickle_list":"%s","autorestart":"%s","ui_panel":"%s","forkozz":%s,"bytetube":%s,"exclude":"%s"}\n' \ + "$mihomo" "$mihomo_running" "$(esc "$mihomo_ver")" "$(esc "$mihomo_latest")" \ + "$magitrickle" "$magitrickle_running" "$(esc "$mt_ver")" "$(esc "$mt_latest")" \ + "$hev" "$hev_running" "$(esc "$hev_ver")" \ + "$(esc "$lan_ip")" "$subscription" "$mt_list" "$(esc "$autorestart")" "$(esc "$ui_panel")" \ + "$(_fk_present && echo true || echo false)" "$(_bt_installed && echo true || echo false)" "$(esc "$(cat "$MIXOMO_EXCL" 2>/dev/null)")" +} + +_bt_installed() { [ -x /usr/bin/bytetube ] || [ -x /etc/init.d/bytetube ]; } +_mx_installed() { [ -x "$MIHOMO_BIN" ] || [ -x /etc/init.d/mihomo ]; } + +_mt_apply_list() { + local new="$MAGITRICKLE_CONF.new" out="$MAGITRICKLE_CONF.zmtmp" + mkdir -p "$(dirname "$MAGITRICKLE_CONF")" + rm -f "$new" "$out" + wget -q --timeout=20 -O "$new" "$1" 2>/dev/null || curl -fsSL --connect-timeout 8 --max-time 30 -o "$new" "$1" 2>/dev/null + if [ ! -s "$new" ] || ! grep -q '^groups:' "$new"; then + rm -f "$new" + return 1 + fi + if [ -s "$MAGITRICKLE_CONF" ] && grep -q '^app:' "$MAGITRICKLE_CONF"; then + { + awk '/^(groups|subscriptions):/ { exit } { print }' "$MAGITRICKLE_CONF" + awk '/^groups:/ { on = 1 } on' "$new" + } > "$out" + else + cp -f "$new" "$out" + fi + rm -f "$new" + [ -s "$out" ] && grep -q '^groups:' "$out" || { rm -f "$out"; return 1; } + mv -f "$out" "$MAGITRICKLE_CONF" +} + +_mt_wait_running() { + local i=0 + while [ "$i" -lt 10 ]; do + pidof magitrickled >/dev/null 2>&1 && return 0 + sleep 1; i=$((i + 1)) + done + return 1 +} + +_mt_list_known() { + [ -f "$MAGITRICKLE_CONF" ] || return 1 + grep -Fq 'name: Google_ai' "$MAGITRICKLE_CONF" || + grep -Fq 'name: Meta (WA+FB+Instagram)' "$MAGITRICKLE_CONF" || + grep -Fq 'url: https://sw.ext.io/ipset/ipset_cf.list' "$MAGITRICKLE_CONF" +} + +do_mixomo_install() { + if [ ! -x "$MIHOMO_BIN" ] && _fk_present; then + echo "ОШИБКА: стоит Forkozz — он сам направляет сервисы в туннель. Чтобы поставить Mixomo, удалите Forkozz" + return 1 + fi + _ensure_deps + echo "==> Устанавливаем Mixomo (Mihomo + hev-socks5-tunnel + MagiTrickle)" + + echo "==> Устанавливаем зависимости (unzip, ca-certificates, модули ядра TUN/nftables)" + $UPDATE >&2 + if [ "$PKG" = "apk" ]; then + $INSTALL unzip ca-certificates kmod-tun kmod-nft-tproxy kmod-nft-nat curl >&2 + else + $INSTALL unzip ca-certificates kmod-tun kmod-nft-tproxy kmod-nft-nat curl libcurl4 ca-bundle >&2 + fi + + command -v curl >/dev/null 2>&1 || { echo "ОШИБКА: не найден curl"; return 1; } + if [ ! -f /etc/ssl/certs/ca-certificates.crt ] && [ ! -f /etc/ssl/certs/ca-bundle.crt ]; then + echo "ОШИБКА: не найден пакет ca-certificates" + return 1 + fi + if [ ! -c /dev/net/tun ]; then + modprobe tun >/dev/null 2>&1 + [ -c /dev/net/tun ] || { echo "ОШИБКА: в ядре нет поддержки TUN"; return 1; } + fi + + local avail_tmp avail_root + avail_tmp=$(df -k /tmp | awk 'NR==2{print $4}') + if [ "$avail_tmp" -lt 16000 ]; then + echo "ОШИБКА: недостаточно места в /tmp (нужно около 16 МБ свободных)" + return 1 + fi + avail_root=$(df -k /usr/bin | awk 'NR==2{print $4}') + if [ "$avail_root" -lt 18000 ]; then + echo "ОШИБКА: недостаточно места на диске (нужно около 18 МБ свободных)" + return 1 + fi + + 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 || 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" + /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_present; then + echo '{"error":"Стоит Forkozz — он сам направляет сервисы в туннель. Чтобы поставить Mixomo, удалите Forkozz."}' + return 1 + fi + if ! _mx_installed && _bt_installed; then + echo '{"error":"Стоит ByeTube — он тоже использует hev-socks5-tunnel. Чтобы поставить Mixomo, удалите ByeTube."}' + return 1 + fi + job_start mixomo_install do_mixomo_install ;; + remove) job_start mixomo_remove do_mixomo_remove ;; + start) + [ -x /etc/init.d/mihomo ] || { echo '{"error":"Mixomo не установлен"}'; return 1; } + /etc/init.d/mihomo start >/dev/null 2>&1 + [ -x /etc/init.d/hev-socks5-tunnel ] && /etc/init.d/hev-socks5-tunnel start >/dev/null 2>&1 + [ -x /etc/init.d/magitrickle ] && /etc/init.d/magitrickle start >/dev/null 2>&1 + mixomo_status ;; + stop) + [ -x /etc/init.d/mihomo ] || { echo '{"error":"Mixomo не установлен"}'; return 1; } + [ -x /etc/init.d/magitrickle ] && /etc/init.d/magitrickle stop >/dev/null 2>&1 + [ -x /etc/init.d/hev-socks5-tunnel ] && /etc/init.d/hev-socks5-tunnel stop >/dev/null 2>&1 + /etc/init.d/mihomo stop >/dev/null 2>&1 + mixomo_status ;; + restart) + [ -x /etc/init.d/mihomo ] || { echo '{"error":"Mixomo не установлен"}'; return 1; } + /etc/init.d/mihomo restart >/dev/null 2>&1 + [ -x /etc/init.d/hev-socks5-tunnel ] && /etc/init.d/hev-socks5-tunnel restart >/dev/null 2>&1 + [ -x /etc/init.d/magitrickle ] && /etc/init.d/magitrickle restart >/dev/null 2>&1 + mixomo_status ;; + *) echo '{"error":"неизвестное действие"}' ;; + esac +} + +mixomo_config_get() { + [ -f "$MIHOMO_CONF" ] || { echo '{"error":"конфигурация не найдена"}'; return 1; } + printf '{"content":"%s"}\n' "$(esc_ml "$(cat "$MIHOMO_CONF")")" +} + +mixomo_config_set() { + local content="$1" + [ -x /etc/init.d/mihomo ] || { echo '{"error":"Mixomo не установлен"}'; return 1; } + cp "$MIHOMO_CONF" "$MIHOMO_CONF.bak" 2>/dev/null + printf '%s' "$content" > "$MIHOMO_CONF" + { [ -s "$MIXOMO_EXCL" ] || [ -s "$MIXOMO_HIDE" ]; } && _mx_filter_apply + /etc/init.d/mihomo restart >/dev/null 2>&1 + sleep 1 + if pidof mihomo >/dev/null 2>&1; then + printf '{"ok":true}\n' + else + cp "$MIHOMO_CONF.bak" "$MIHOMO_CONF" 2>/dev/null + /etc/init.d/mihomo restart >/dev/null 2>&1 + echo '{"error":"mihomo не запустился с новой конфигурацией — изменения отменены, проверьте синтаксис"}' + return 1 + fi +} + +_mx_filter_rx() { + ZM_EXCL="$(head -n1 "$MIXOMO_EXCL" 2>/dev/null)" ZM_HIDE="$MIXOMO_HIDE" awk ' + function plain(x) { gsub(/[][\\^$.|?*+(){}`"]/, ".", x); return x } + BEGIN { + o = "" + c = split(ENVIRON["ZM_EXCL"], a, /[|,]/) + for (i = 1; i <= c; i++) { t = a[i]; gsub(/^[ \t]+|[ \t]+$/, "", t); if (t != "") o = o (o == "" ? "" : "|") plain(t) } + f = ENVIRON["ZM_HIDE"] + while ((getline l < f) > 0) if (l != "" && !(l in hid)) { hid[l] = 1; o = o (o == "" ? "" : "|") "^" plain(l) "$" } + printf "%s", o + }' +} + +_mx_filter_cur() { + awk ' + { t = $0; sub(/\r$/, "", t) } + t ~ /^[^ \t#]/ { inpp = (t ~ /^proxy-providers:[ \t]*(#.*)?$/); next } + inpp && t ~ /^[ \t]+exclude-filter:/ { sub(/^[ \t]+/, "", t); sub(/[ \t]+$/, "", t); print t; exit } + ' "$MIHOMO_CONF" 2>/dev/null +} + +_mx_filter_val() { + printf '%s\n' "$1" | awk '{ + s = $0; sub(/^exclude-filter:[ \t]*/, "", s); q = substr(s, 1, 1); o = ""; n = length(s) + if (q == "\"") { + for (i = 2; i <= n; i++) { c = substr(s, i, 1); if (c == "\\") { o = o c substr(s, i + 1, 1); i++; continue } if (c == "\"") break; o = o c } + } else { + if (q == "\047") { st = 2 } else { st = 1; sub(/[ \t]+#.*$/, "", s); sub(/[ \t]+$/, "", s); n = length(s) } + for (i = st; i <= n; i++) { + c = substr(s, i, 1) + if (q == "\047" && c == "\047") { if (substr(s, i + 1, 1) == "\047") { o = o c; i++; continue } break } + if (c == "\\") o = o "\\\\"; else if (c == "\"") o = o "\\\""; else o = o c + } + } + print o + }' +} + +_mx_api() { + local port sec + local ec host port sec + ec="$(sed -n 's/^external-controller:[[:space:]]*//p' "$MIHOMO_CONF" 2>/dev/null | head -n1 | sed 's/[[:space:]]*#.*$//; s/^["'\'']//; s/["'\''][[:space:]]*$//; s/[[:space:]]*$//')" + port="${ec##*:}" + case "$port" in ''|*[!0-9]*) port=9090 ;; esac + host="${ec%:*}"; host="${host#[}"; host="${host%]}" + case "$host" in ''|0.0.0.0|::|'*'|localhost) host=127.0.0.1 ;; esac + case "$host" in *:*) host="[$host]" ;; esac + sec="$(sed -n 's/^secret:[[:space:]]*//p' "$MIHOMO_CONF" 2>/dev/null | head -n1)" + case "$sec" in + \"*) sec="${sec#\"}"; sec="${sec%%\"*}" ;; + \'*) sec="${sec#\'}"; sec="${sec%%\'*}" ;; + *) sec="$(printf '%s' "$sec" | sed 's/[[:space:]]*#.*$//; s/[[:space:]]*$//')" ;; + esac + curl -s --connect-timeout 3 --max-time 8 ${sec:+-H "Authorization: Bearer $sec"} "http://$host:$port$1" 2>/dev/null +} + +_mx_names_tail() { + printf ',"hide_names":[%s],"exclude":"%s","all_names":[%s],"own_filter":"%s"}\n' \ + "$(_zm_json_lines "$MIXOMO_HIDE")" "$(esc "$(cat "$MIXOMO_EXCL" 2>/dev/null)")" \ + "$(_zm_json_lines "$MIHOMO_DIR/.zm_names_all")" "$(esc "$(head -n1 "$MIHOMO_DIR/.zm_filter_user" 2>/dev/null)")" +} + +mixomo_proxies() { + local j nf="$MIHOMO_DIR/.zm_names" af="$MIHOMO_DIR/.zm_names_all" + [ -x /etc/init.d/mihomo ] || { echo '{"error":"Mixomo не установлен"}'; return 1; } + pidof mihomo >/dev/null 2>&1 || { printf '{"error":"Mihomo не запущен"'; _mx_names_tail; return 0; } + j="$(_mx_api /providers/proxies | tr '\n' ' ')" + case "$j" in + *'"providers"'*) + if printf '%s' "$j" | jsonfilter -e '@.providers' >/dev/null 2>&1; then + printf '%s' "$j" | jsonfilter -e '@.providers[@.vehicleType!="Compatible"].proxies[*].name' 2>/dev/null | grep -v '^$' > "$nf.new" + if [ -s "$nf.new" ]; then + mv -f "$nf.new" "$nf" + cat "$af" "$nf" 2>/dev/null | awk 'NF && !s[$0]++' | tail -n 1000 > "$af.new" && mv -f "$af.new" "$af" + fi + rm -f "$nf.new" + else + printf '{"error":"Mihomo отдал неполный список серверов — нажмите «Обновить список»"'; _mx_names_tail; return 0 + fi + ;; + '{'*'"message"'*) printf '{"error":"Mihomo не пустил к списку серверов — проверьте secret в конфигурации"'; _mx_names_tail; return 0 ;; + *) j=null ;; + esac + printf '{"data":%s' "$j"; _mx_names_tail +} + +_mx_filter_apply() { + local rx line tmp prev="" cur user="" uf="$MIHOMO_DIR/.zm_filter_user" + [ -f "$MIHOMO_CONF" ] || return 0 + grep -q '^proxy-providers:' "$MIHOMO_CONF" || return 0 + [ -s "$MIHOMO_DIR/.zm_filter_line" ] && prev="$(cat "$MIHOMO_DIR/.zm_filter_line")" + cur="$(_mx_filter_cur)" + case "$cur" in *'# zm-filter') cur="" ;; esac + if [ -n "$cur" ] && [ "$cur" != "$prev" ]; then + user="$(_mx_filter_val "$cur")" + if [ -n "$user" ]; then printf '%s\n' "$user" > "$uf"; else rm -f "$uf"; fi + fi + [ -s "$uf" ] && user="$(head -n1 "$uf")" + rx="$(_mx_filter_rx)" + [ -n "$user" ] && rx="$user${rx:+|$rx}" + line="" + [ -n "$rx" ] && line="exclude-filter: \"$rx\"" + tmp="$MIHOMO_CONF.zm" + ZM_LINE="$line" ZM_PREV="$prev" awk ' + function ind(x) { match(x, /^ */); return RLENGTH } + BEGIN { line = ENVIRON["ZM_LINE"]; prev = ENVIRON["ZM_PREV"] } + { + raw = $0; t = raw; cr = sub(/\r$/, "", t) + blank = (t ~ /^[ \t]*(#.*)?$/) + if (!blank && t ~ /^[^ \t]/) { inpp = (t ~ /^proxy-providers:[ \t]*(#.*)?$/); pind = -1; pend = 0; print raw; next } + if (!inpp || blank) { print raw; next } + i = ind(t) + if (pind < 0) pind = i + if (i == pind) { pend = (line != "" && t ~ /:[ \t]*(#.*)?$/); print raw; next } + if (t ~ /^[ \t]*exclude-filter:/) next + if (pend) { sp = ""; for (k = 0; k < i; k++) sp = sp " "; print sp line (cr ? "\r" : ""); pend = 0 } + print raw + }' "$MIHOMO_CONF" > "$tmp" && [ -s "$tmp" ] && mv -f "$tmp" "$MIHOMO_CONF" || { rm -f "$tmp"; return 0; } + if [ -n "$line" ]; then printf '%s\n' "$line" > "$MIHOMO_DIR/.zm_filter_line"; else rm -f "$MIHOMO_DIR/.zm_filter_line"; fi +} + +mixomo_filter_set() { + local m="$1" was=0 nf="$JOBS_DIR/mixomo.hide.in" mk="" hasmk=0 + [ -x /etc/init.d/mihomo ] || { echo '{"error":"Mixomo не установлен"}'; return 1; } + case "$m" in + '{'*) + mkdir -p "$MIHOMO_DIR" + if [ "$(printf '%s' "$m" | jsonfilter -e '@.reset' 2>/dev/null)" = true ]; then + rm -f "$MIXOMO_HIDE" "$MIXOMO_EXCL" + else + case "$m" in *'"markers":'*|*'"markers" :'*) mk="$(printf '%s' "$m" | jsonfilter -e '@.markers' 2>/dev/null)"; hasmk=1 ;; esac + if [ "$hasmk" = 1 ]; then + case "$mk" in *" +"*|*"$(printf '\r')"*) echo '{"error":"фразы — одной строкой через |"}'; return 1 ;; esac + [ "${#mk}" -le 1600 ] || { echo '{"error":"слишком длинный список фраз"}'; return 1; } + fi + printf '%s' "$m" | jsonfilter -e '@.names[*]' > "$nf" 2>/dev/null + rm -f "$nf.bak" + [ -f "$MIXOMO_HIDE" ] && cp -f "$MIXOMO_HIDE" "$nf.bak" + _zm_names_save "$nf" "$MIXOMO_HIDE" + rm -f "$nf" + fi + if [ "$hasmk" = 1 ]; then m="$mk"; else m="$(cat "$MIXOMO_EXCL" 2>/dev/null)"; fi + ;; + esac + case "$m" in *" +"*) echo '{"error":"маркеры — одной строкой через |"}'; return 1 ;; esac + [ "${#m}" -le 1600 ] || { echo '{"error":"слишком длинный список маркеров"}'; return 1; } + mkdir -p "$MIHOMO_DIR" + if [ -s "$MIHOMO_DIR/.zm_names_all" ] && ZM_EXCL="$m" ZM_HIDE="$MIXOMO_HIDE" awk ' + BEGIN { c = split(ENVIRON["ZM_EXCL"], a, /[|,]/); n = 0 + for (i = 1; i <= c; i++) { t = a[i]; gsub(/^[ \t]+|[ \t]+$/, "", t); if (t != "") m[++n] = t } + f = ENVIRON["ZM_HIDE"]; while ((getline l < f) > 0) if (l != "") h[l] = 1 } + NF { tot++; if ($0 in h) next; for (i = 1; i <= n; i++) if (index($0, m[i])) next; left++ } + END { exit !(tot > 0 && left == 0) }' "$MIHOMO_DIR/.zm_names_all"; then + if [ -f "$nf.bak" ]; then mv -f "$nf.bak" "$MIXOMO_HIDE"; else rm -f "$MIXOMO_HIDE"; fi + echo '{"error":"так исключаются все серверы подписки — тогда Mihomo останется без серверов. Оставьте хотя бы один"}' + return 1 + fi + rm -f "$nf.bak" + if [ -n "$(printf '%s' "$m" | tr -d ' |,')" ]; then printf '%s\n' "$m" > "$MIXOMO_EXCL"; else rm -f "$MIXOMO_EXCL"; fi + [ -s "$MIXOMO_HIDE" ] || [ -s "$MIXOMO_EXCL" ] || m="" + grep -q '^proxy-providers:' "$MIHOMO_CONF" 2>/dev/null || { printf '{"ok":true,"providers":false}\n'; return 0; } + _mx_filter_apply + if { [ -s "$MIXOMO_EXCL" ] || [ -s "$MIXOMO_HIDE" ]; } && ! grep -q '^[[:space:]]*exclude-filter:' "$MIHOMO_CONF" 2>/dev/null; then + printf '{"ok":true,"providers":false}\n' + return 0 + fi + pidof mihomo >/dev/null 2>&1 && was=1 + [ "$was" = 1 ] && _zm_run 60 /etc/init.d/mihomo restart >/dev/null 2>&1 + printf '{"ok":true,"providers":true,"restarted":%s}\n' "$([ "$was" = 1 ] && echo true || echo false)" +} + +mixomo_subscription_set() { + local sub_url="$1" + case "$sub_url" in + http://*|https://*) ;; + *) echo '{"error":"ссылка должна начинаться с http:// или https://"}'; return 1 ;; + esac + case "$sub_url" in *[[:space:]\"\\]*) echo '{"error":"в ссылке не должно быть пробелов, кавычек и обратных косых черт"}'; return 1 ;; esac + [ -x /etc/init.d/mihomo ] || { echo '{"error":"Mixomo не установлен"}'; return 1; } + /etc/init.d/mihomo stop >/dev/null 2>&1 + rm -rf "$MIHOMO_DIR/proxy-providers" "$MIHOMO_DIR/proxies" + rm -f "$MIHOMO_DIR/.zm_names" "$MIHOMO_DIR/.zm_names_all" + + if grep -q '^[[:space:]]*proxy-providers:' "$MIHOMO_CONF" 2>/dev/null; then + local tmp + tmp=$(mktemp) + if ZM_URL="$sub_url" awk 'BEGIN { url = ENVIRON["ZM_URL"]; updated = 0; in_provider = 0; section = "" } { if ($0 ~ /^[a-zA-Z_-]+:/) { section = $0; sub(/:.*/, "", section) } if (!updated && section == "proxy-providers" && $0 ~ /^[[:space:]]*type:[[:space:]]*http[[:space:]]*$/) { in_provider = 1; print; next } if (!updated && in_provider && $0 ~ /^[[:space:]]*url:[[:space:]]*["\047]?https?:/) { match($0, /^[[:space:]]*/); print substr($0, 1, RLENGTH) "url: \"" url "\""; updated = 1; in_provider = 0; next } print } END { exit (updated ? 0 : 1) }' "$MIHOMO_CONF" > "$tmp" + then + mv "$tmp" "$MIHOMO_CONF" + { [ -s "$MIXOMO_EXCL" ] || [ -s "$MIXOMO_HIDE" ]; } && _mx_filter_apply + /etc/init.d/mihomo restart >/dev/null 2>&1 + printf '{"ok":true,"mode":"updated"}\n' + return 0 + fi + rm -f "$tmp" + fi + + [ -s "$MIHOMO_CONF" ] && cp -f "$MIHOMO_CONF" "$MIHOMO_CONF.bak" 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" ;; + *) 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="" + +mixomo_warp_status() { + local exists="false" content="" + if [ -s "$MIXOMO_WARP_CONF" ]; then + exists="true" + content=$(cat "$MIXOMO_WARP_CONF") + fi + printf '{"exists":%s,"content":"%s"}\n' "$exists" "$(esc_ml "$content")" +} + +mixomo_warp_config_set() { + local content="$1" + [ -n "$content" ] || { echo '{"error":"пустая конфигурация — сохранение отменено"}'; return 1; } + echo "$content" | grep -q '^\[Interface\]' || { echo '{"error":"файл должен начинаться с секции [Interface]"}'; return 1; } + echo "$content" | grep -q '^\[Peer\]' || { echo '{"error":"в файле нет секции [Peer]"}'; return 1; } + echo "$content" | grep -q '^PrivateKey' || { echo '{"error":"в секции [Interface] нет PrivateKey"}'; return 1; } + echo "$content" | grep -q '^PublicKey' || { echo '{"error":"в секции [Peer] нет PublicKey"}'; return 1; } + mkdir -p "$(dirname "$MIXOMO_WARP_CONF")" + printf '%s' "$content" > "$MIXOMO_WARP_CONF" + chmod 600 "$MIXOMO_WARP_CONF" + printf '{"ok":true}\n' +} + +_mixomo_warp_best_endpoint() { + local warp_tmp="$JOBS_DIR/mixomo_warp" pings count=0 ip + pings="$warp_tmp/pings" + mkdir -p "$warp_tmp" + rm -f "$pings" "$warp_tmp/pick.info" + for ip in $(_awg_rand_hosts 4 | head -n 60); do + ( + trace_data=$(curl -s --connect-timeout 2 --max-time 4 -w "\n%{time_total}" -H "Host: trace.cloudflare.com" "http://${ip}/cdn-cgi/trace" 2>/dev/null) + [ -n "$trace_data" ] || exit 0 + colo=$(echo "$trace_data" | awk -F'=' '$1=="colo"{print $2}') + [ -n "$colo" ] || exit 0 + _st_warp_is_ru "$colo" && exit 0 + ping_ms=$(echo "$trace_data" | tail -n1 | awk '{printf "%d", $1 * 1000}') + [ -n "$ping_ms" ] && echo "$ping_ms $ip $colo" >> "$pings" + ) & + count=$((count + 1)) + [ $((count % 20)) -eq 0 ] && wait + done + wait + if [ -s "$pings" ]; then + sort -n "$pings" | head -n1 | awk '{ print $2 ":4500" }' + sort -n "$pings" | head -n1 | awk '{ print " ближайший зарубежный узел: " $3 ", " $1 " мс (оценка по HTTP, без туннеля)" }' > "$warp_tmp/pick.info" + else + echo "engage.cloudflareclient.com:4500" + fi + rm -f "$pings" +} + +do_mixomo_warp_register() { + local endpoint_mode="$1" + ZM_KEYS_INSTALL=1 + ZM_GEN_NOPHASE=1 + if [ "$endpoint_mode" = auto ] && _awg_installed; then + echo "==> AmneziaWG установлен — проверяем точки входа настоящим туннелем" + do_awg_gen check + return $? + fi + if [ "$endpoint_mode" = auto ]; then + echo "==> AmneziaWG не установлен — точку входа подбираем приблизительно, без проверки туннелем" + do_awg_gen std auto + else + do_awg_gen std "engage.cloudflareclient.com:4500" + fi +} + +mixomo_warp_action() { + local endpoint_mode="$1" + _job_running awg && { echo '{"error":"на вкладке AmneziaWG идёт операция — дождитесь её окончания"}'; return 1; } + job_start mixomo_warp do_mixomo_warp_register "$endpoint_mode" +} + +do_mixomo_warp_integrate() { + [ -s "$MIXOMO_WARP_CONF" ] || { echo "ОШИБКА: сначала сгенерируйте WARP.conf"; return 1; } + [ -x /etc/init.d/mihomo ] || { echo "ОШИБКА: Mixomo не установлен"; return 1; } + echo "==> Интегрируем WARP.conf в Mihomo" + local tmp; tmp=$(mktemp) + awk -v OUT="$tmp" ' + function trim(s){ gsub(/^[ \t\r\n]+|[ \t\r\n]+$/, "", s); return s } + function lc(s){ return tolower(s) } + function yaml_quote(s, o, i, c){ o=""; for(i=1;i<=length(s);i++){ c=substr(s,i,1); if(c=="\\") o=o "\\\\"; else if(c=="\"") o=o "\\\""; else if(c!="\r") o=o c } return "\"" o "\"" } + function split_endpoint(s, a,n){ s=trim(s); n=split(s,a,":"); if(n<2){ host=s; port="" } else { port=a[n]; host=a[1]; for(i=2;i "/dev/stderr"; exit 2 } + split_endpoint(endpoint) + ip=addr4; sub(/\/32$/, "", ip) + ipv6=addr6; sub(/\/128$/, "", ipv6) + if(allowed=="") allowed="0.0.0.0/0,::/0" + n=split(allowed, aip, ",") + allowed_block="" + for(i=1;i<=n;i++){ if(aip[i]=="") continue; allowed_block = allowed_block " - " yaml_quote(aip[i]) "\n" } + + print "mixed-port: 7890" > OUT + print "allow-lan: false" >> OUT + print "tcp-concurrent: true" >> OUT + print "mode: rule" >> OUT + print "log-level: error" >> OUT + print "ipv6: false" >> OUT + print "external-controller: 0.0.0.0:9090" >> OUT + print "external-ui: ./ui" >> OUT + print "unified-delay: true" >> OUT + print "profile:" >> OUT + print " store-selected: true" >> OUT + print " store-fake-ip: true" >> OUT + print "" >> OUT + print "proxy-groups:" >> OUT + print " - name: GLOBAL" >> OUT + print " type: select" >> OUT + print " proxies:" >> OUT + print " - WARP" >> OUT + print " - DIRECT" >> OUT + print "" >> OUT + print "rules:" >> OUT + print " - \"MATCH,GLOBAL\"" >> OUT + print "" >> OUT + print "proxies:" >> OUT + print " - name: WARP" >> OUT + print " type: wireguard" >> OUT + print " server: " host >> OUT + if(port!="") print " port: " port >> OUT + print " private-key: " yaml_quote(priv) >> OUT + print " udp: true" >> OUT + if(ip!="") print " ip: " ip >> OUT + if(ipv6!="") print " ipv6: " ipv6 >> OUT + print " public-key: " yaml_quote(pub) >> OUT + if(psk!="") print " pre-shared-key: " yaml_quote(psk) >> OUT + print " allowed-ips:" >> OUT + printf "%s", allowed_block >> OUT + if(mtu!="") print " mtu: " mtu >> OUT + if(keep!="") print " persistent-keepalive: " keep >> OUT + if(s1!="" || s2!="" || jc!="" || jmin!="" || jmax!="" || h1!="" || h2!="" || h3!="" || h4!="" || i1!=""){ + print " amnezia-wg-option:" >> OUT + if(s1!="") print " s1: " s1 >> OUT + if(s2!="") print " s2: " s2 >> OUT + if(jc!="") print " jc: " jc >> OUT + if(jmin!="") print " jmin: " jmin >> OUT + if(jmax!="") print " jmax: " jmax >> OUT + if(h1!="") print " h1: " h1 >> OUT + if(h2!="") print " h2: " h2 >> OUT + if(h3!="") print " h3: " h3 >> OUT + if(h4!="") print " h4: " h4 >> OUT + if(i1!="") print " i1: " yaml_quote(i1) >> OUT + } + }' "$MIXOMO_WARP_CONF" 2>"$tmp.err" + if [ -s "$tmp.err" ]; then + echo "ОШИБКА: в WARP.conf отсутствуют обязательные поля" + rm -f "$tmp" "$tmp.err" + return 1 + fi + rm -f "$tmp.err" + cp "$MIHOMO_CONF" "$MIHOMO_CONF.bak" 2>/dev/null + chmod 600 "$tmp" + mv -f "$tmp" "$MIHOMO_CONF" + /etc/init.d/mihomo restart >/dev/null 2>&1 + echo "==> Готово, WARP интегрирован в Mihomo" +} + +mixomo_warp_integrate_action() { + job_start mixomo_warp_integrate do_mixomo_warp_integrate +} + +BYEDPI_REPO="DPITrickster/ByeDPI-OpenWrt" + +_bytetube_fetch() { + if command -v curl >/dev/null 2>&1; then + curl -fsSL --connect-timeout 15 --max-time 90 -o "$2" "$1" + else + wget -q -T 20 -O "$2" "$1" + fi +} + +health() { + local zr=0 zr2=0 bt=0 tg=0 mx=0 doh=0 hs=0 inst=0 bad=0 out="" rb=0 + if [ -f /etc/init.d/zapret ]; then zr=2; pgrep -f "/opt/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 + # стоит ли пакет: для opkg — по его файлам в базе, а не разбором списка всех пакетов на каждый опрос + 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 opkg list-installed 2>/dev/null | grep -q '^https-dns-proxy ' && doh=2; fi + if [ "$doh" = "2" ]; then pidof https-dns-proxy >/dev/null 2>&1 && doh=1; fi + out=$(hosts_status 2>/dev/null) + case "$out" in *'"enabled":true'*|*'"geohide":"'[a-z]*) hs=1 ;; esac + local sr=0 w + _st_legacy_check + if _st_installed; then + if [ -f /etc/zm-steer/stopped ] || ! grep -qx 'steer-spec' /etc/zm-steer/owned 2>/dev/null || [ "$(_st_exit)" = none ]; then + sr=5 + else + sr=2 + if stl_running; then + if [ "$(_st_exit)" = vpn ]; then + _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="" + if _st_installed; then sx="$(_st_exit)"; [ "$sx" = warp ] && _st_warp_own && sx=own; fi + local fw=false v6=false + [ "$(uci -q get firewall.@defaults[0].flow_offloading)" = 1 ] && [ -f /usr/share/firewall4/templates/ruleset.uc ] && [ "$(_flow_offloading_fix_applied)" = false ] && fw=true + # плашка нужна, только если по IPv6 действительно ходят: он работает и DNS роутера отдаёт IPv6-адреса сайтов + [ -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 { [ -f "$DOH_OFF_FLAG" ] || ! 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 + if _ab_on; then if _ab_applied; then d_ab=1; else d_ab=2; 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},' \ + "$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" + printf '"zapret":%s,"zapret2":%s,"bytetube":%s,"tg":%s,"mixomo":%s,"doh":%s,"hosts":%s,"steer":%s,"steer_off":%s,"steer_exit":"%s","awg":%s,"forkop":%s,"flow_warn":%s,"ipv6_warn":%s,"reboot_hint":"%s"}\n' \ + "$zr" "$zr2" "$bt" "$tg" "$mx" "$doh" "$hs" "$sr" \ + "$([ -f /etc/zm-steer/stopped ] && echo true || echo false)" "$sx" "$awgj" "$fkh" "$fw" "$v6" "$(esc "$(_zm_reboot_hint_get)")" +} + +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 +} + +# Последняя версия: что вернула проверка, а если ничего (сбор без проверки или GitHub не ответил) — +# прошлое известное значение из файла версий +_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 "$cur" "$latest" && latest="$cur" + printf '{"name":"%s","installed":"%s","latest":"%s"}' "$(esc "$1")" "$(esc "$cur")" "$(esc "$latest")" +} +_jf() { jsonfilter -s "$1" -e "$2" 2>/dev/null; } + +# Версии. Проверка (кнопка «Проверить»): установленные версии и последние — с GitHub и из репозитория. +# Без проверки (ZM_VER_LOCAL=1 — после установки или удаления чего-либо): только установленные версии, +# без интернета и без разбора списков пакетов; последние версии берутся из прошлой проверки. +# Итог — файл $VERSIONS_CACHE на роутере, Обзор только читает его. +_vl() { [ -n "$ZM_VER_LOCAL" ] && return 0; "$@"; } +do_versions_refresh() { + local out="" sep="" j it 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')")" + 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 + _st_legacy_check + stl_present && add "$(_ver_item 'Ядро steer' "$(stl_version)" "$(_vl stl_latest)")" + _fk_installed && add "$(_ver_item Forkozz "$(_fk_version)" "$(_vl _fk_latest)")" + local feeds=0 v + [ -n "$ZM_VER_LOCAL" ] || [ -n "$(_ver_feed_latest busybox)" ] || [ -n "$(_zm_busy_job)" ] || { $UPDATE >/dev/null 2>&1; } + if _fk_installed && v="$(_fk_sb_var)" && [ -n "$v" ]; then + case "$v" in + extended) add "$(_ver_item 'sing-box расширенный' "$(_fk_sb_ver)" "$(_vl _fk_sbx_latest)")" ;; + lite) add "$(_ver_item 'sing-box расширенный облегчённый' "$(_fk_sb_ver)" "")" ;; + manual) add "$(_ver_item sing-box "$(_fk_sb_ver)" "")" ;; + *) add "$(_ver_item sing-box "$(_fk_sb_ver)" "$(_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)" ] +} + +# ---------- Поиск новых версий: как часто (Система → «Поиск новых версий») ---------- +# day (по умолчанию) — роутер сам проверяет раз в сутки, около 04:00 (минута своя у каждого роутера, чтобы +# все не шли на GitHub разом); open — при каждом открытии Дашборда, не чаще раза в 10 минут; button — только +# кнопкой. Результат — в $VERSIONS_CACHE, Дашборд показывает его. +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" +} +# задание cron — только в режиме «раз в день» +_ver_cron_sync() { + local line + mkdir -p "$(dirname "$CRON_FILE")"; [ -f "$CRON_FILE" ] || : > "$CRON_FILE" + if [ "$(_ver_mode)" = day ]; 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 +} +# прошло ли с последней проверки больше $1 минут (или проверки не было) +_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 )) ] +} +# ночная проверка из cron +versions_cron() { + [ "$(_ver_mode)" = day ] || { _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 '' +} + +# Дашборд («auto»): «при открытии» — проверка с интернетом, если с прошлой прошло 10 минут; «раз в день» — +# только если ночная проверка не случилась (роутер был выключен) больше суток. Иначе — только файл версий; если +# с прошлого раза что-то ставили или удаляли (база пакетов, операции панели или сама панель новее файла) — в +# фоне записываем установленные версии, без интернета. Кнопка («refresh») — проверка с интернетом всегда. +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 1560; 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)}/" "$VERSIONS_CACHE" + else + printf '{"ts":"","items":[],"pending":%s,"mode":"%s","minute":%s}\n' "$busy" "$mode" "$(_ver_minute)" + 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 + [ -f /proc/net/if_inet6 ] || ipv6=0 + + case "$byedpi_port" in + ''|*[!0-9]*) log "некорректный порт: $byedpi_port"; return 1 ;; + esac + + byedpi=$(find_byedpi) + hev=$(command -v hev-socks5-tunnel) + [ -n "$byedpi" ] || { log "ciadpi не найден: установите пакет byedpi"; return 1; } + [ -n "$hev" ] || { log "hev-socks5-tunnel не найден: установите пакет"; return 1; } + dnsmasq_has_nftset || { log "dnsmasq без поддержки nftset: установите dnsmasq-full"; return 1; } + [ -c /dev/net/tun ] || modprobe tun 2>/dev/null + + mkdir -p "$RUNDIR" + write_hev_conf "$byedpi_port" "$ipv6" + + "$LIBEXEC/net.sh" up || { log "не удалось настроить nftables/маршрутизацию"; return 1; } + ensure_fw4_include + dns_apply "$ipv6" + + procd_open_instance byedpi + procd_set_param command "$byedpi" -i 127.0.0.1 -p "$byedpi_port" + set -f + [ -n "$byedpi_opts" ] && procd_append_param command $byedpi_opts + set +f + procd_set_param respawn 3600 5 0 + procd_set_param stderr 1 + procd_close_instance + + procd_open_instance hev + procd_set_param command "$hev" "$RUNDIR/hev.yml" + procd_set_param respawn 3600 5 0 + procd_set_param stderr 1 + procd_close_instance + + touch "$STARTED_FLAG" +} + +stop_service() { + rm -f "$STARTED_FLAG" + "$LIBEXEC/net.sh" down +} + +service_triggers() { + procd_add_reload_trigger "$NAME" +} +YTB_FILE_END_7f3a9c + chmod 755 /etc/init.d/bytetube + mkdir -p /lib/upgrade/keep.d + cat > /lib/upgrade/keep.d/bytetube <<'YTB_FILE_END_7f3a9c' +/etc/bytetube/ +YTB_FILE_END_7f3a9c + chmod 644 /lib/upgrade/keep.d/bytetube + mkdir -p /usr/bin + cat > /usr/bin/bytetube <<'YTB_FILE_END_7f3a9c' +#!/bin/sh + +. /lib/functions.sh +. /usr/libexec/bytetube/common.sh + +svc_running() { + ubus call service list '{"name":"bytetube"}' 2>/dev/null \ + | jsonfilter -e "@.bytetube.instances.$1.running" 2>/dev/null | grep -q true +} + +count_set() { + nft list set inet "$NFT_TABLE" "$1" 2>/dev/null | grep -o 'expires' | wc -l +} + +b() { if [ "$1" = "1" ]; then echo true; else echo false; fi; } + +case "$1" in +status) + config_load bytetube + config_get enabled main enabled 0 + config_get ipv6 main ipv6 1 + config_get quic main quic block + + v_byedpi=0; svc_running byedpi && v_byedpi=1 + v_hev=0; svc_running hev && v_hev=1 + v_tun=0; ip link show "$TUN" >/dev/null 2>&1 && v_tun=1 + v_nft=0; nft list table inet "$NFT_TABLE" >/dev/null 2>&1 && v_nft=1 + v_rule=0; ip rule show 2>/dev/null | grep -q "lookup $TABLE" && v_rule=1 + v_route=0; ip route show table "$TABLE" 2>/dev/null | grep -q "$TUN" && v_route=1 + v_dns=0; [ -s "$(dnsmasq_confdir)/bytetube.conf" ] && v_dns=1 + v_nftset=0; dnsmasq_has_nftset && v_nftset=1 + v_fw=0; nft list chain inet fw4 forward 2>/dev/null | grep -q bytetube && v_fw=1 + v_bin=0; { [ -x /usr/bin/ciadpi ] || [ -x /usr/bin/byedpi ]; } && [ -x /usr/bin/hev-socks5-tunnel ] && v_bin=1 + + printf '{"enabled":%s,"byedpi":%s,"hev":%s,"tun":%s,"nft":%s,"rule":%s,"route":%s,"dns":%s,"dnsmasq_nftset":%s,"fw":%s,"binaries":%s,"ipv6":%s,"quic":"%s","ips4":%s,"ips6":%s}\n' \ + "$(b "$enabled")" "$(b $v_byedpi)" "$(b $v_hev)" "$(b $v_tun)" "$(b $v_nft)" \ + "$(b $v_rule)" "$(b $v_route)" "$(b $v_dns)" "$(b $v_nftset)" "$(b $v_fw)" "$(b $v_bin)" \ + "$(b "$ipv6")" "$quic" "$(count_set yt4)" "$(count_set yt6)" + ;; +flush) + nft flush set inet "$NFT_TABLE" yt4 2>/dev/null + nft flush set inet "$NFT_TABLE" yt6 2>/dev/null + echo "наборы очищены" + ;; +list) + nft list set inet "$NFT_TABLE" yt4 2>/dev/null + nft list set inet "$NFT_TABLE" yt6 2>/dev/null + ;; +diag) + arg="$2"; secs="${3:-20}" + LEASES="${YTB_LEASES:-/tmp/dhcp.leases}" + CT="${YTB_CT:-/proc/net/nf_conntrack}" + case "$secs" in ''|*[!0-9]*) secs=20 ;; esac + + echo "=== YouTube Bypass: диагностика ===" + "$0" status + echo + echo "--- счётчики nft (растут, когда трафик попадает под правила) ---" + { nft list chain inet "$NFT_TABLE" prerouting; nft list chain inet "$NFT_TABLE" forward; } 2>/dev/null \ + | grep counter | sed 's/^[[:space:]]*//' + echo + echo "--- какие DNS раздаются клиентам по DHCP ---" + uci -q show dhcp | grep -E 'dhcp_option|\.dns=' || echo "своих dhcp_option/dns нет (клиенты получают DNS роутера)" + echo "--- IPv6: маршрут по умолчанию: $(ip -6 route show default 2>/dev/null | head -n 1)" + + router_addrs=$(ip -o addr show 2>/dev/null | awk '{print $4}' | sed 's#/.*##' | tr '\n' ' ') + lan_ip=$(uci -q get network.lan.ipaddr 2>/dev/null | awk '{ print $1 }' | sed 's#/.*##') + [ -n "$lan_ip" ] && router_addrs="$router_addrs $lan_ip" + + list_leases() { + echo "--- клиенты в DHCP ---" + if [ -s "$LEASES" ]; then + awk '{printf " %-16s %-18s %s\n", $3, $2, $4}' "$LEASES" + else + echo " (файл аренд пуст — смотрите IP в LuCI: Состояние → Обзор)" + fi + } + + if [ -z "$arg" ]; then + echo + list_leases + echo + echo "Запустите: bytetube diag [секунд, по умолчанию 20]" + exit 0 + fi + + client="$arg" + if ! printf '%s' "$arg" | grep -Eq '^[0-9]+(\.[0-9]+){3}$'; then + found=$(grep -i -- "$arg" "$LEASES" 2>/dev/null) + n=$(printf '%s\n' "$found" | grep -c .) + if [ "$n" -eq 0 ]; then + echo; echo "«$arg» не найден в DHCP-арендах."; list_leases; exit 0 + elif [ "$n" -gt 1 ]; then + echo; echo "«$arg» подходит нескольким клиентам — уточните:"; list_leases; exit 0 + fi + client=$(printf '%s\n' "$found" | awk '{print $3}') + fi + case " $router_addrs " in + *" $client "*) + echo + echo "$client — это адрес самого РОУТЕРА. Нужен IP телефона:" + list_leases + exit 0 ;; + esac + + mac=$(ip neigh show 2>/dev/null | awk -v ip="$client" '$1==ip {for(i=1;i<=NF;i++) if($i=="lladdr") print $(i+1)}' | head -n 1) + ips="$client" + [ -n "$mac" ] && ips="$ips $(ip -6 neigh show 2>/dev/null | awk -v m="$mac" 'tolower($0) ~ tolower(m) {print $1}' | grep -v '^fe80' | tr '\n' ' ')" + echo + echo "=== клиент: $ips (MAC: ${mac:-не найден}) ===" + echo ">>> Собираю данные ${secs} с. ОТКРОЙТЕ YouTube НА ТЕЛЕФОНЕ (перезапустите приложение / обновите страницу) <<<" + + raw="/tmp/ytb-ct.$$"; out="/tmp/ytb-diag.$$"; ins="/tmp/ytb-ins.$$" + : > "$raw"; : > "$ins" + i=0 + while [ "$i" -lt "$secs" ]; do + cat "$CT" >> "$raw" 2>/dev/null + i=$((i + 1)) + [ "$i" -lt "$secs" ] && sleep 1 + done + + awk -v ips=" $ips " ' + { + proto=$3; src=""; dst=""; sport=""; dport=""; mark=0 + for (i=1;i<=NF;i++) { + if (src=="" && $i ~ /^src=/) src=substr($i,5) + else if (dst=="" && $i ~ /^dst=/) dst=substr($i,5) + else if (sport=="" && $i ~ /^sport=/) sport=substr($i,7) + else if (dport=="" && $i ~ /^dport=/) dport=substr($i,7) + else if ($i ~ /^mark=/) mark=substr($i,6)+0 + } + if (index(ips, " " src " ") == 0) next + key=proto " " src " " sport " " dst " " dport + if (!(key in P)) { P[key]=proto; D[key]=dst; Q[key]=dport; M[key]=0 } + if (int(mark/65536)%2==1) M[key]=1 + } + END { + for (k in P) { + if (Q[k]=="53" || Q[k]=="853") c["DNS " P[k] " " D[k] ":" Q[k]]++ + else if (Q[k]=="443") c["443 " P[k] " " (M[k] ? "tunnel" : "direct") " " D[k]]++ + } + for (k in c) print k, c[k] + }' "$raw" 2>/dev/null | sort > "$out" + + echo + echo "--- DNS-запросы клиента (куда он реально их шлёт) ---" + dns_ok=0; dns_bad=0 + if grep -q '^DNS' "$out"; then + while read -r _ proto target n; do + host="${target%:*}"; port="${target##*:}" + if [ "$port" = "853" ]; then + who="DNS-over-TLS (Частный DNS) — МИМО роутера"; dns_bad=1 + elif case " $router_addrs " in *" $host "*) true ;; *) false ;; esac; then + who="роутер (ок)"; dns_ok=1 + else + who="НЕ роутер — набор для этого клиента не заполнится"; dns_bad=1 + fi + echo " $proto $target x$n — $who" + done </dev/null 2>&1; then + echo " $dst x$n — в наборе, но трафик не помечен" | tee -a "$ins" + else + echo " $dst x$n — не в наборе" + fi + done + in_set=$(grep -c . "$ins") + + echo + found_any=0 + if [ "$dns_bad" -eq 1 ]; then + found_any=1 + if [ "$dns_ok" -eq 0 ]; then + echo "ВЫВОД: телефон шлёт DNS мимо роутера (Частный DNS / DoT / внешний DNS) — набор не заполняется, YouTube идёт напрямую." + else + echo "ВЫВОД: часть DNS-запросов телефона идёт мимо роутера (Частный DNS / DoT / внешний DNS) — для таких имён набор не заполнится." + fi + echo " Отключите «Частный DNS» и «Безопасный DNS» в Chrome, уберите статический DNS/VPN/AdGuard и переподключите Wi-Fi." + fi + if [ "$in_set" -gt 0 ]; then + found_any=1 + echo "ВЫВОД: есть соединения на IP из набора, но без метки — пришлите этот вывод целиком." + fi + if [ "$tcp_t" -gt 0 ]; then + found_any=1 + echo "ИНФО: часть трафика YouTube от телефона идёт через туннель — маршрутизация работает." + [ "$dns_bad" -eq 0 ] && [ "$in_set" -eq 0 ] && \ + echo " Если видео всё равно не грузится — стратегия ByeDPI (вкладка «Тест стратегий») или домены, которых нет в списке." + fi + if [ "$tcp_t" -eq 0 ] && [ "$tcp_d" -eq 0 ] && [ "$dns_ok" -eq 0 ] && [ "$dns_bad" -eq 0 ]; then + found_any=1 + echo "ВЫВОД: от этого клиента не было ни DNS, ни :443-трафика. Проверьте IP/MAC, что телефон в этой сети, и откройте YouTube во время сбора." + fi + [ "$found_any" -eq 1 ] || echo "ВЫВОД: однозначно определить не удалось — пришлите этот вывод целиком." + rm -f "$raw" "$out" "$ins" + ;; +test) + shift + exec /usr/libexec/bytetube/test.sh "$@" + ;; +set-strategy) + opts="$2" + if [ -z "$opts" ] || [ "$(printf '%s' "$opts" | wc -l)" -gt 0 ]; then + echo '{"error":"пустая или многострочная стратегия"}' + exit 0 + fi + uci set bytetube.main.byedpi_opts="$opts" && uci commit bytetube + /etc/init.d/bytetube restart >/dev/null 2>&1 + echo '{"ok":true}' + ;; +*) + echo "usage: bytetube status|flush|list|diag|test|set-strategy" >&2 + exit 1 + ;; +esac +YTB_FILE_END_7f3a9c + chmod 755 /usr/bin/bytetube + mkdir -p /usr/libexec/bytetube + cat > /usr/libexec/bytetube/common.sh <<'YTB_FILE_END_7f3a9c' +#!/bin/sh + +TUN=ytb0 +MARK=0x10000 +MASK=0x10000 +TABLE=89 +PREF=8900 +NFT_TABLE=bytetube + +dnsmasq_confdirs() { + local d + d=$(sed -n 's/^conf-dir=\([^,]*\).*/\1/p' /var/etc/dnsmasq.conf.* 2>/dev/null | sort -u) + [ -n "$d" ] || d=/tmp/dnsmasq.d + echo "$d" +} +dnsmasq_confdir() { dnsmasq_confdirs | head -n 1; } + +dnsmasq_has_nftset() { + dnsmasq --version 2>/dev/null | grep -Eq '(^| )nftset( |$)' +} + +find_byedpi() { + local b + for b in /usr/bin/ciadpi /usr/bin/byedpi; do + [ -x "$b" ] && { echo "$b"; return 0; } + done + command -v ciadpi +} + +byedpi_opts_clean() { + printf '%s' "$1" | tr -d "\"'" | tr '\n\r\t' ' ' | sed 's/^ *//; s/ *$//; s/ */ /g' +} +YTB_FILE_END_7f3a9c + chmod 755 /usr/libexec/bytetube/common.sh + cat > /usr/libexec/bytetube/net.sh <<'YTB_FILE_END_7f3a9c' +#!/bin/sh + +. /lib/functions.sh +. /usr/libexec/bytetube/common.sh + +config_load bytetube +config_get IPV6 main ipv6 1 +config_get QUIC main quic block +[ -f /proc/net/if_inet6 ] || IPV6=0 + +rules_del() { + while ip rule del pref "$PREF" 2>/dev/null; do :; done + while ip -6 rule del pref "$PREF" 2>/dev/null; do :; done + ip route del default dev "$TUN" table "$TABLE" 2>/dev/null + ip -6 route del default dev "$TUN" table "$TABLE" 2>/dev/null +} + +set_mark_stmt="counter ct mark set ct mark | $MARK meta mark set meta mark | $MARK" + +gen_ruleset() { + cat </dev/null + gen_ruleset | nft -f - || exit 1 + ip rule add pref "$PREF" fwmark "$MARK/$MASK" lookup "$TABLE" || exit 1 + [ "$IPV6" = "1" ] && ip -6 rule add pref "$PREF" fwmark "$MARK/$MASK" lookup "$TABLE" + ip link show "$TUN" >/dev/null 2>&1 && /usr/libexec/bytetube/route-up.sh "$TUN" + exit 0 + ;; +down) + rules_del + nft flush chain inet "$NFT_TABLE" prerouting 2>/dev/null + nft flush chain inet "$NFT_TABLE" forward 2>/dev/null + ;; +purge) + rules_del + nft delete table inet "$NFT_TABLE" 2>/dev/null + ;; +*) + echo "usage: $0 up|down|purge" >&2 + exit 1 + ;; +esac +exit 0 +YTB_FILE_END_7f3a9c + chmod 755 /usr/libexec/bytetube/net.sh + cat > /usr/libexec/bytetube/route-up.sh <<'YTB_FILE_END_7f3a9c' +#!/bin/sh + +. /lib/functions.sh +. /usr/libexec/bytetube/common.sh + +config_load bytetube +config_get IPV6 main ipv6 1 +[ -f /proc/net/if_inet6 ] || IPV6=0 + +ip link set "$TUN" up 2>/dev/null +ip route replace default dev "$TUN" table "$TABLE" +[ "$IPV6" = "1" ] && ip -6 route replace default dev "$TUN" table "$TABLE" + +echo 2 > "/proc/sys/net/ipv4/conf/$TUN/rp_filter" 2>/dev/null +exit 0 +YTB_FILE_END_7f3a9c + chmod 755 /usr/libexec/bytetube/route-up.sh + cat > /usr/libexec/bytetube/test.sh <<'YTB_FILE_END_7f3a9c' +#!/bin/sh + +. /lib/functions.sh +. /usr/libexec/bytetube/common.sh + +TEST_DIR=/tmp/bytetube-test +PIDF="$TEST_DIR/job.pid" +LOGF="$TEST_DIR/job.log" +RES="$TEST_DIR/results.txt" +RAW="$TEST_DIR/results.raw" +STOP="$TEST_DIR/stop" +CPID="$TEST_DIR/ciadpi.pid" +USER_DIR=/etc/bytetube +STRATS_DEFAULT=/usr/share/bytetube/strategies.txt +DOMS_DEFAULT=/usr/share/bytetube/test-domains.txt +PORT_BASE=22000 +UA='Mozilla/5.0 (Windows NT 10.0; Win64; x64) curl/8.0' +TAB=$(printf '\t') + +is_running() { + [ -f "$PIDF" ] && kill -0 "$(cat "$PIDF" 2>/dev/null)" 2>/dev/null +} + +count_lines() { + sed 's/#.*//' "$1" 2>/dev/null | tr -d '\r' | grep -c '[^[:space:]]' +} + +list_name() { + case "$1" in + strategies) echo strategies.txt ;; + domains) echo test-domains.txt ;; + esac +} + +list_file() { + local nm + nm=$(list_name "$1") + if [ -n "$nm" ] && [ -s "$USER_DIR/$nm" ]; then + echo "$USER_DIR/$nm" + else + case "$1" in + strategies) echo "$STRATS_DEFAULT" ;; + domains) echo "$DOMS_DEFAULT" ;; + esac + fi +} + +list_is_custom() { + local nm + nm=$(list_name "$1") + [ -n "$nm" ] && [ -s "$USER_DIR/$nm" ] +} + +json_esc() { + printf '%s' "$1" | tr -d '\000-\037' | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +norm_domains() { + awk -v badf="$1" ' + { + gsub(/\r/, "") + line = $0 + sub(/^[ \t]+/, "", line); sub(/[ \t]+$/, "", line) + if (line == "") next + if (substr(line, 1, 1) == "#") { print line; next } + sub(/#.*/, "", line) + n = split(line, tok, /[ \t,;]+/) + for (i = 1; i <= n; i++) { + d = tolower(tok[i]) + if (d == "") continue + sub(/^[a-z][a-z0-9+.-]*:\/\//, "", d) + sub(/[\/?#].*$/, "", d) + sub(/^\*?\./, "", d) + if (d ~ /^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/ && index(d, ".") > 0) { + if (!(d in seen)) { seen[d] = 1; print d } + } else if (!bad) { bad = 1; print tok[i] > badf } + } + }' +} + +norm_strategies() { + awk ' + { + gsub(/\r/, ""); gsub(/[ \t]+/, " ") + line = $0; sub(/^ /, "", line); sub(/ $/, "", line) + if (line == "") next + if (substr(line, 1, 1) == "#") { print line; next } + if (!(line in seen)) { seen[line] = 1; print line } + }' +} + +cmd_list() { + local action="$1" kind="$2" text="$3" nm tmp bad n + nm=$(list_name "$kind") + if [ -z "$nm" ]; then + echo '{"error":"неизвестный список"}' + return 0 + fi + case "$action" in + get) + local f + f=$(list_file "$kind") + [ -f "$f" ] && cat "$f" + ;; + set) + if is_running; then + echo '{"error":"тест выполняется — остановите его перед сохранением списка"}' + return 0 + fi + mkdir -p "$TEST_DIR" + tmp="$TEST_DIR/list.$$"; bad="$TEST_DIR/bad.$$"; : > "$bad" + if [ "$kind" = "domains" ]; then + printf '%s\n' "$text" | norm_domains "$bad" > "$tmp" + else + printf '%s\n' "$text" | norm_strategies > "$tmp" + fi + if [ -s "$bad" ]; then + printf '{"error":"Некорректный домен: %s"}\n' "$(json_esc "$(head -n 1 "$bad")")" + rm -f "$tmp" "$bad" + return 0 + fi + rm -f "$bad" + n=$(count_lines "$tmp") + if [ "${n:-0}" -le 0 ]; then + echo '{"error":"список пуст — чтобы вернуть встроенный, нажмите «Сбросить к встроенному»"}' + rm -f "$tmp" + return 0 + fi + mkdir -p "$USER_DIR" + mv "$tmp" "$USER_DIR/$nm" + printf '{"ok":true,"count":%s,"custom":true}\n' "$n" + ;; + reset) + if is_running; then + echo '{"error":"тест выполняется — остановите его перед сбросом списка"}' + return 0 + fi + rm -f "$USER_DIR/$nm" + printf '{"ok":true,"count":%s,"custom":false}\n' "$(count_lines "$(list_file "$kind")")" + ;; + *) + echo '{"error":"неизвестное действие"}' + ;; + esac + return 0 +} + +cmd_start() { + if is_running; then + echo '{"started":true,"already_running":true}' + return 0 + fi + command -v curl >/dev/null 2>&1 || { echo '{"error":"не установлен curl (apk add curl / opkg install curl)"}'; return 0; } + [ -n "$(find_byedpi)" ] || { echo '{"error":"не найден ciadpi (пакет byedpi)"}'; return 0; } + mkdir -p "$TEST_DIR" + rm -f "$STOP" + : > "$LOGF" + ( "$0" run >>"$LOGF" 2>&1; echo "__DONE__ $?" >>"$LOGF" ) >/dev/null 2>&1 "$PIDF" + echo '{"started":true}' +} + +cmd_stop() { + if ! is_running; then + echo '{"error":"тест не запущен"}' + return 0 + fi + touch "$STOP" + [ -f "$CPID" ] && kill "$(cat "$CPID" 2>/dev/null)" 2>/dev/null + echo '{"ok":true}' +} + +cmd_status() { + local running=false has=false cu=false rc="" + is_running && running=true + [ -s "$RES" ] && has=true + command -v curl >/dev/null 2>&1 && cu=true + [ -f "$LOGF" ] && rc=$(grep '^__DONE__' "$LOGF" | tail -n 1 | awk '{print $2}') + local sc=false dc=false + list_is_custom strategies && sc=true + list_is_custom domains && dc=true + printf '{"running":%s,"has_results":%s,"curl":%s,"strategies":%s,"domains":%s,"strategies_custom":%s,"domains_custom":%s,"rc":"%s"}\n' \ + "$running" "$has" "$cu" "$(count_lines "$(list_file strategies)")" "$(count_lines "$(list_file domains)")" "$sc" "$dc" "$rc" +} + +cmd_log() { + [ -f "$LOGF" ] && tail -n 400 "$LOGF" | grep -v '^__DONE__' + return 0 +} + +cmd_results() { + [ -s "$RES" ] && cat "$RES" + return 0 +} + +cmd_clear() { + if is_running; then + echo '{"error":"тест выполняется"}' + return 0 + fi + rm -f "$RES" "$RAW" + echo '{"ok":true}' +} + +check_url() { + local entry="$1" okfile="$2" logfile="$3" proxy="$4" host url rc + host="${entry%%|*}" + url="${entry#*|}" + if [ -n "$proxy" ]; then + curl -4 -sL --socks5 "$proxy" --connect-timeout 4 --max-time 6 --speed-time 3 --speed-limit 1 \ + --range 0-65535 -A "$UA" -o /dev/null "$url" /dev/null 2>&1 + else + curl -4 -sL --connect-timeout 4 --max-time 6 --speed-time 3 --speed-limit 1 \ + --range 0-65535 -A "$UA" -o /dev/null "$url" /dev/null 2>&1 + fi + rc=$? + if [ "$rc" -eq 0 ]; then + echo 1 >> "$okfile" + echo "[ OK ] $host" >> "$logfile" + else + echo "[FAIL] $host" >> "$logfile" + fi +} + +check_all() { + local urls="$1" logfile="$2" proxy="$3" okf run=0 total=0 ok entry + okf="$TEST_DIR/ok.$$" + : > "$okf" + : > "$logfile" + while IFS= read -r entry; do + [ -n "$entry" ] || continue + [ -f "$STOP" ] && break + total=$((total + 1)) + check_url "$entry" "$okf" "$logfile" "$proxy" & + run=$((run + 1)) + if [ "$run" -ge "$PARALLEL" ]; then + wait + run=0 + fi + done < "$urls" + wait + ok=$(wc -l < "$okf" | tr -d ' ') + rm -f "$okf" + echo "$ok $total" +} + +cleanup() { + [ -f "$CPID" ] && kill "$(cat "$CPID" 2>/dev/null)" 2>/dev/null + rm -f "$CPID" +} + +cmd_run() { + local BIN cand keys urls line k opts port idx total ntot res ok tot cok ctot cpid skipped=0 best + + set -f + trap cleanup EXIT + trap 'exit 130' INT TERM + + config_load bytetube + config_get PARALLEL main test_parallel 8 + config_get CUR main byedpi_opts "" + case "$PARALLEL" in ''|*[!0-9]*) PARALLEL=8 ;; esac + [ "$PARALLEL" -ge 1 ] || PARALLEL=8 + + BIN=$(find_byedpi) + [ -n "$BIN" ] || { echo "ОШИБКА: ciadpi не найден"; exit 1; } + command -v curl >/dev/null 2>&1 || { echo "ОШИБКА: не установлен curl"; exit 1; } + + mkdir -p "$TEST_DIR" + rm -f "$STOP" + cand="$TEST_DIR/candidates.txt" + keys="$TEST_DIR/keys.txt" + urls="$TEST_DIR/urls.txt" + : > "$cand"; : > "$keys"; : > "$RAW" + + echo "==> Собираем стратегии для теста" + { printf '%s\n' "$CUR"; cat "$(list_file strategies)"; } | tr -d '\r' | while IFS= read -r line; do + line=$(printf '%s' "$line" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') + case "$line" in ''|'#'*) continue ;; esac + k=$(byedpi_opts_clean "$line") + [ -n "$k" ] || continue + grep -qxF -- "$k" "$keys" && continue + echo "$k" >> "$keys" + echo "$line" >> "$cand" + done + total=$(wc -l < "$cand" | tr -d ' ') + [ "$total" -gt 0 ] || { echo "ОШИБКА: нет стратегий для теста"; exit 1; } + + echo "==> Собираем список доменов" + tr -d '\r' < "$(list_file domains)" | sed 's/#.*//; s/^[[:space:]]*//; s/[[:space:]]*$//' \ + | grep -E '^[A-Za-z0-9._-]+$' | awk '!s[$0]++' | sed 's#.*#&|https://&/#' > "$urls" + ntot=$(wc -l < "$urls" | tr -d ' ') + [ "$ntot" -gt 0 ] || { echo "ОШИБКА: нет доменов для теста"; exit 1; } + echo "==> Стратегий: $total, доменов: $ntot, параллельно: $PARALLEL" + + echo "==> Контрольный тест: без обхода" + res=$(check_all "$urls" "$TEST_DIR/log_control.txt" "") + if [ -f "$STOP" ]; then + echo "==> Тест остановлен на контрольном замере, результатов нет" + rm -f "$STOP" + return 0 + fi + cok=${res% *}; ctot=${res#* } + echo "==> Результат: $cok/$ctot" + + idx=0 + while IFS= read -r line; do + [ -f "$STOP" ] && break + idx=$((idx + 1)) + opts=$(byedpi_opts_clean "$line") + port=$((PORT_BASE + idx)) + echo "==> [$idx/$total] $line" + "$BIN" -i 127.0.0.1 -p "$port" $opts >/dev/null 2>&1 "$CPID" + sleep 1 + if ! kill -0 "$cpid" 2>/dev/null; then + echo " пропуск: ciadpi не запустился с этими параметрами" + skipped=$((skipped + 1)) + continue + fi + res=$(check_all "$urls" "$TEST_DIR/log_$idx.txt" "127.0.0.1:$port") + kill "$cpid" 2>/dev/null + wait "$cpid" 2>/dev/null + rm -f "$CPID" + if [ -f "$STOP" ]; then + echo " прервано — эта стратегия в результаты не входит" + break + fi + ok=${res% *}; tot=${res#* } + ok=${ok:-0} + echo " результат: $ok/$tot" + printf '%06d\t%06d\t%s\t%s\t%s\n' "$((999999 - ok))" "$idx" "$ok" "$tot" "$line" >> "$RAW" + done < "$cand" + + if [ -f "$STOP" ]; then + echo "==> Тест остановлен пользователем, показываем то, что успели проверить" + rm -f "$STOP" + else + echo "==> Тест завершён" + fi + [ "$skipped" -gt 0 ] && echo "==> Пропущено стратегий (не запустились): $skipped" + + { + echo "Контрольный тест (без обхода) → $cok/$ctot" + sort "$RAW" | while IFS="$TAB" read -r _ _ ok tot line; do + echo "$line → $ok/$tot" + done + } > "$RES" + + echo "==> Основной сервис и его настройки не менялись." + return 0 +} + +case "$1" in + start) cmd_start ;; + stop) cmd_stop ;; + status) cmd_status ;; + log) cmd_log ;; + results) cmd_results ;; + clear) cmd_clear ;; + list) shift; cmd_list "$@" ;; + run) cmd_run ;; + *) echo "usage: bytetube test start|stop|status|log|results|clear|list" >&2; exit 1 ;; +esac +exit 0 +YTB_FILE_END_7f3a9c + chmod 755 /usr/libexec/bytetube/test.sh + mkdir -p /usr/share/nftables.d/chain-pre/forward + cat > /usr/share/nftables.d/chain-pre/forward/50-bytetube.nft <<'YTB_FILE_END_7f3a9c' +oifname "ytb0" accept comment "bytetube" +YTB_FILE_END_7f3a9c + chmod 644 /usr/share/nftables.d/chain-pre/forward/50-bytetube.nft + mkdir -p /usr/share/bytetube + cat > /usr/share/bytetube/domains.list <<'YTB_FILE_END_7f3a9c' +android.clients.google.com +beacons.gvt2.com +cdn.youtube.com +connectivitycheck.gstatic.com +fonts.googleapis.com +fonts.gstatic.com +ggpht.com +googleapis.com +googleplay.com +googleusercontent.com +googlevideo.com +gvt1.com +i.ytimg.com +i9.ytimg.com +jnn-pa.googleapis.com +kids.youtube.com +lh3.googleusercontent.com +m.youtube.com +manifest.googlevideo.com +music.youtube.com +nhacmp3youtube.com +play-fe.googleapis.com +play-games.googleusercontent.com +play-lh.googleusercontent.com +play.google.com +play.googleapis.com +prod-lt-playstoregatewayadapter-pa.googleapis.com +returnyoutubedislikeapi.com +s.ytimg.com +signaler-pa.youtube.com +studio.youtube.com +tv.youtube.com +wide-youtube.l.google.com +withyoutube.com +youtu.be +youtube-nocookie.com +youtube-ui.l.google.com +youtube.ae +youtube.al +youtube.am +youtube.at +youtube.az +youtube.ba +youtube.be +youtube.bg +youtube.bh +youtube.bo +youtube.by +youtube.ca +youtube.cat +youtube.ch +youtube.cl +youtube.co +youtube.co.ae +youtube.co.at +youtube.co.cr +youtube.co.hu +youtube.co.id +youtube.co.il +youtube.co.in +youtube.co.jp +youtube.co.ke +youtube.co.kr +youtube.co.ma +youtube.co.nz +youtube.co.th +youtube.co.tz +youtube.co.ug +youtube.co.uk +youtube.co.ve +youtube.co.za +youtube.co.zw +youtube.com +youtube.com.ar +youtube.com.au +youtube.com.az +youtube.com.bd +youtube.com.bh +youtube.com.bo +youtube.com.br +youtube.com.by +youtube.com.co +youtube.com.do +youtube.com.ec +youtube.com.ee +youtube.com.eg +youtube.com.es +youtube.com.gh +youtube.com.gr +youtube.com.gt +youtube.com.hk +youtube.com.hn +youtube.com.hr +youtube.com.jm +youtube.com.jo +youtube.com.kw +youtube.com.lb +youtube.com.lv +youtube.com.ly +youtube.com.mk +youtube.com.mt +youtube.com.mx +youtube.com.my +youtube.com.ng +youtube.com.ni +youtube.com.om +youtube.com.pa +youtube.com.pe +youtube.com.ph +youtube.com.pk +youtube.com.pt +youtube.com.py +youtube.com.qa +youtube.com.ro +youtube.com.sa +youtube.com.sg +youtube.com.sv +youtube.com.tn +youtube.com.tr +youtube.com.tw +youtube.com.ua +youtube.com.uy +youtube.com.ve +youtube.cr +youtube.cz +youtube.de +youtube.dk +youtube.ee +youtube.es +youtube.fi +youtube.fr +youtube.ge +youtube.googleapis.com +youtube.gr +youtube.gt +youtube.hk +youtube.hr +youtube.hu +youtube.ie +youtube.in +youtube.iq +youtube.is +youtube.it +youtube.jo +youtube.jp +youtube.kr +youtube.kz +youtube.la +youtube.lk +youtube.lt +youtube.lu +youtube.lv +youtube.ly +youtube.ma +youtube.md +youtube.me +youtube.mk +youtube.mn +youtube.mx +youtube.my +youtube.ng +youtube.ni +youtube.nl +youtube.no +youtube.pa +youtube.pe +youtube.ph +youtube.pk +youtube.pl +youtube.pr +youtube.pt +youtube.qa +youtube.ro +youtube.rs +youtube.ru +youtube.sa +youtube.se +youtube.sg +youtube.si +youtube.sk +youtube.sn +youtube.soy +youtube.sv +youtube.tn +youtube.tv +youtube.ua +youtube.ug +youtube.uy +youtube.vn +youtubeeducation.com +youtubeembeddedplayer.googleapis.com +youtubefanfest.com +youtubegaming.com +youtubego.co.id +youtubego.co.in +youtubego.com +youtubego.com.br +youtubego.id +youtubego.in +youtubei.googleapis.com +youtubei.youtube.com +youtubekids.com +youtubemobilesupport.com +yt-video-upload.l.google.com +yt.be +yt3.ggpht.com +yt3.googleusercontent.com +yt4.ggpht.com +ytimg.com +ytimg.l.google.com +yting.com +YTB_FILE_END_7f3a9c + chmod 644 /usr/share/bytetube/domains.list + cat > /usr/share/bytetube/strategies.txt <<'YTB_FILE_END_7f3a9c' +-f-200 -Qr -s3:5+sm -a1 -As -d1 -s4+sm -s8+sh -f-300 -d6+sh -a1 -At,r,s -o2 -f-30 -As -r5 -Mh -r6+sh -f-250 -s2:7+s -s3:6+sm -a1 -At,r,s -s3:5+sm -s6+s -s7:9+s -q30+sm -a1 +-d1 -d3+s -s6+s -d9+s -s12+s -d15+s -s20+s -d25+s -s30+s -d35+s -r1+s -S -a1 -As -d1 -d3+s -s6+s -d9+s -s12+s -d15+s -s20+s -d25+s -s30+s -d35+s -S -a1 +-q2 -s2 -s3+s -r3 -s4 -r4 -s5+s -r5+s -s6 -s7+s -r8 -s9+s -Qr -Mh,d,r -a1 -At,r -s2+s -r2 -d2 -s3 -r3 -r4 -s4 -d5+s -r5 -d6 -s7+s -d7 -a1 +-o1 -d1 -a1 -At,r,s -s1 -d1 -s5+s -s10+s -s15+s -s20+s -r1+s -S -a1 -As -s1 -d1 -s5+s -s10+s -s15+s -s20+s -S -a1 +-n "google.com" -Qr -f-204 -s1:5+sm -a1 -As -d1 -s3+s -s5+s -q7 -a1 -As -o2 -f-43 -a1 -As -r5 -Mh -s1:5+s -s3:7+sm -a1 +-n "google.com" -Qr -f-205 -a1 -As -s1:3+sm -a1 -As -s5:8+sm -a1 -As -d3 -q7 -o2 -f-43 -f-85 -f-165 -r5 -Mh -a1 +-d1+s -s50+s -a1 -As -f20 -r2+s -a1 -At -d2 -s1+s -s5+s -s10+s -s15+s -s25+s -s35+s -s50+s -s60+s -a1 +-o1 -a1 -At,r,s -f-1 -a1 -At,r,s -d1:11+sm -S -a1 -At,r,s -n "google.com" -Qr -f1 -d1:11+sm -s1:11+sm -S -a1 +-d1 -s1 -q1 -a1 -Ar -s5 -o1+s -d3+s -s6+s -d9+s -s12+s -d15+s -s20+s -d25+s -s30+s -d35+s -a1 +-f1+nme -t6 -a1 -As -n "google.com" -Qr -s1:6+sm -a1 -As -s5:12+sm -a1 -As -d3 -q7 -r6 -Mh -a1 +-d1 -s1+s -d3+s -s6+s -d9+s -s12+s -d15+s -s20+s -d25+s -s30+s -d35+s -a1 +-d1 -s1+s -d1+s -s3+s -d6+s -s12+s -d14+s -s20+s -d24+s -s30+s -a1 +-o1 -a1 -At,r,s -f-1 -a1 -Ar,s -o1 -a1 -At -r1+s -f-1 -t6 -a1 +-d1 -s1+s -s3+s -s6+s -s9+s -s12+s -s15+s -s20+s -s30+s -a1 +-d1 -d3+s -s6+s -d6+s -s7+s -d8+s -s10+s -a1 -t12 -At,s -r3 +-f1 -t5 -n "google.com" -q3+h -Qr -f2 -q1 -r1+s -t15 -q1 -o2 -a1 +-n "google.com" -d2:5:2+h -f-3 -r2+sm -o2 -o50+s -r2+s -f-4 -a1 +-f-1 -Qr -s1+sm -d3+s -s5+sm -o2 -a1 -As -r1+s -d8+s -a1 +-r-1+s -o20+sm -s3:7+sm -d5:3+sm -f300+s -Qr -f-1 -a1 +-o2 -O4 -s1 -q1 -a1 -Ar -s5 -o1+s -f1+s -r20+s -a1 +-o1 -r-5+se -a1 -At,r,s -d1 -n "google.com" -Qr -f-1 -a1 +--fake -1 --ttl 8 --split 1+s --disorder 3+s -a1 +-n "google.com" -Qr -f6+nr -d2 -d11 -f9+hm -o3 -t7 -a1 +-r5+s -s25+s -a1 -At,r,s -s50 -r5+s -s50+s -a1 +-d1 -d3+s -s6+s -d9+s -s20+s -d25+s -s30+s -a1 +-d9+s -q20+s -s25+s -t5 -a1 -At,r,s -r1+h -a1 +-q1+s -s29+s -s30+s -s14+s -o5+s -f-1 -S -a1 +-d1 -s1+s -r1+s -e1 -m1 -o1+s -f-1 -t2 -a1 +-d1 -o1 -a1 -Ar -o1 -a1 -At -f-1 -r1+s -a1 +-d1 -s4 -d8 -s1+s -d5+s -s10+s -d20+s -a1 +-f-1 -n "google.com" -Qr -s2+s -r3 -o20 -t4 -a1 +-n "google.com" -Qr -d5+sm -f3+sm -o2 -t4 -a1 +-o1 -a1 -Ar -q1 -a1 -At -f-1 -r1+s -a1 +-q1 -a1 -Ar -o1 -a1 -At -f-1 -r1+s -a1 +-s4+sn -r9+s -Qr -n "google.com" -S -a1 +-o1 -d1 -r1+s -S -s1+s -d3+s -a1 +-q1+s -s29+s -o5+s -f-1 -S -a1 +-n "google.com" -Qr -m2 -f-1 -d7 -a1 +-d1 -s1+s -r1+s -f-1 -t8 -a1 +-o1 -a1 -An -f1+nme -t6 -a1 +-n "google.com" -Qr -f-1 -r1+s -a1 +-n "google.com" -Qr -d1:3 -f-1 -a1 +-s1 -d3+s -a1 -At -r1+s -a1 +-f-1 -t8 -n "google.com" -s1+s -a1 +-n "google.com" -Qr -d1 -f-1 -a1 +-f64+se -n "google.com" -t5 -a1 +-o1 -a1 -At,r,s -d1 -a1 +-d1+s -o2 -s5 -r5 -a1 +-r8 -o2 -s7 -q4+s -a1 +-o1 -f-1 -r-5+se -a1 +-d6+s -q4+hm -o2 -a1 +-s5+s -s35+s -m4 -a1 +-f-1+sm -t7 -m2 -a1 +-o1 -r-5+se -a1 +-o1+s -d3+s -a1 +-o1 -s4 -s6 -a1 +-q1 -r25+s -a1 +-d1 -s3+s -a1 +-o3 -d7 -a1 +-d7 -s2 -a1 +-o1 -a1 -r-5+se +YTB_FILE_END_7f3a9c + chmod 644 /usr/share/bytetube/strategies.txt + cat > /usr/share/bytetube/test-domains.txt <<'YTB_FILE_END_7f3a9c' +youtu.be +youtube.com +i.ytimg.com +i9.ytimg.com +yt3.ggpht.com +yt4.ggpht.com +googleapis.com +jnn-pa.googleapis.com +googleusercontent.com +signaler-pa.youtube.com +youtubei.googleapis.com +manifest.googlevideo.com +yt3.googleusercontent.com + +rr1---sn-4axm-n8vs.googlevideo.com +rr1---sn-gvnuxaxjvh-o8ge.googlevideo.com +rr1---sn-ug5onuxaxjvh-p3ul.googlevideo.com +rr1---sn-ug5onuxaxjvh-n8v6.googlevideo.com +rr4---sn-q4flrnsl.googlevideo.com +rr10---sn-gvnuxaxjvh-304z.googlevideo.com +rr14---sn-n8v7kn7r.googlevideo.com +rr16---sn-axq7sn76.googlevideo.com +rr1---sn-8ph2xajvh-5xge.googlevideo.com +rr1---sn-gvnuxaxjvh-5gie.googlevideo.com +rr12---sn-gvnuxaxjvh-bvwz.googlevideo.com +rr5---sn-n8v7knez.googlevideo.com +rr1---sn-u5uuxaxjvhg0-ocje.googlevideo.com +rr2---sn-q4fl6ndl.googlevideo.com +rr5---sn-gvnuxaxjvh-n8vk.googlevideo.com +rr4---sn-jvhnu5g-c35d.googlevideo.com +rr1---sn-q4fl6n6y.googlevideo.com +rr2---sn-hgn7ynek.googlevideo.com +rr1---sn-xguxaxjvh-gufl.googlevideo.com +YTB_FILE_END_7f3a9c + chmod 644 /usr/share/bytetube/test-domains.txt +} + +do_bytetube_install() { + NEW_BYEDPI=0 + NEW_HEV=0 + command -v fw4 >/dev/null 2>&1 || { echo "ОШИБКА: нужен firewall4 (OpenWrt 22.03+); hev-socks5-tunnel в пакетах — с 24.10"; return 1; } + command -v nft >/dev/null 2>&1 || { echo "ОШИБКА: не найден nft"; return 1; } + if ip rule add pref 8999 fwmark 0x10000/0x10000 lookup 89 2>/dev/null; then + ip rule del pref 8999 2>/dev/null + else + echo "ОШИБКА: ваш ip не поддерживает fwmark с маской. Установите ip-full (замените ip-tiny на ip-full) и попробуйте снова" + return 1 + fi + + _ensure_deps + echo "==> Обновляем список пакетов" + $UPDATE >&2 + + if ! _pkg_is_installed kmod-tun; then + echo "==> Ставим kmod-tun" + $INSTALL kmod-tun >&2 || { echo "ОШИБКА: kmod-tun не установлен"; return 1; } + fi + _bytetube_ensure_dnsmasq_full || return 1 + + if ! _pkg_is_installed hev-socks5-tunnel; then + echo "==> Ставим hev-socks5-tunnel" + $INSTALL hev-socks5-tunnel >&2 || { echo "ОШИБКА: hev-socks5-tunnel не найден в репозитории (есть в feeds OpenWrt 24.10+)"; return 1; } + NEW_HEV=1 + fi + _bytetube_install_byedpi || return 1 + + for p in ca-bundle curl; do + _pkg_is_installed "$p" || $INSTALL "$p" >&2 || echo "!! не удалось поставить $p — тест стратегий работать не будет (остальное — да)" + done + + if [ "$NEW_BYEDPI" = 1 ] && [ -x /etc/init.d/byedpi ]; then + /etc/init.d/byedpi stop >/dev/null 2>&1; /etc/init.d/byedpi disable >/dev/null 2>&1 + fi + if [ "$NEW_HEV" = 1 ] && [ -x /etc/init.d/hev-socks5-tunnel ]; then + /etc/init.d/hev-socks5-tunnel stop >/dev/null 2>&1; /etc/init.d/hev-socks5-tunnel disable >/dev/null 2>&1 + fi + + echo "==> Устанавливаем ByeTube (сервис)" + _bytetube_install_payload + rm -rf /tmp/luci-indexcache* /tmp/luci-modulecache + /etc/init.d/rpcd reload >/dev/null 2>&1 + + /etc/init.d/bytetube enable >/dev/null 2>&1 + echo "==> Запускаем" + /etc/init.d/bytetube restart >/dev/null 2>&1 + sleep 4 + nslookup youtube.com 127.0.0.1 >/dev/null 2>&1 + sleep 1 + + local ST + ST=$(/usr/bin/bytetube status 2>/dev/null) + _bt_show() { + if [ "$(jsonfilter -s "$ST" -e "@.$1" 2>/dev/null)" = "true" ]; then + echo " [ok] $2" + else + echo " [--] $2" + fi + } + echo + echo "==> Состояние:" + _bt_show byedpi "ByeDPI (ciadpi)" + _bt_show hev "hev-socks5-tunnel" + _bt_show tun "интерфейс ytb0" + _bt_show nft "правила nftables" + _bt_show route "policy routing" + _bt_show dns "dnsmasq -> nftset" + _bt_show fw "firewall forward" + echo "==> Готово, ByeTube установлен" +} + +do_bytetube_uninstall() { + echo "==> Останавливаем ByeTube" + [ -x /usr/bin/bytetube ] && /usr/bin/bytetube test stop >/dev/null 2>&1 + _zm_svc_off bytetube + echo "==> Снимаем правила файрвола и DNS ByeTube" + if [ -x /usr/libexec/bytetube/net.sh ]; then + /usr/libexec/bytetube/net.sh purge >/dev/null 2>&1 + . /usr/libexec/bytetube/common.sh + for d in $(dnsmasq_confdirs 2>/dev/null || dnsmasq_confdir); do rm -f "$d/bytetube.conf"; done + fi + echo "==> Удаляем файлы и настройки ByeTube" + 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 + $DELETE byedpi + _pkg_is_installed byedpi && rc=1 + if _mx_installed; then + echo "==> hev-socks5-tunnel используется Mixomo — пакет оставляем" + else + echo "==> Удаляем пакет hev-socks5-tunnel" + _zm_svc_off hev-socks5-tunnel + $DELETE hev-socks5-tunnel + _pkg_is_installed hev-socks5-tunnel && rc=1 + rm -rf /etc/hev-socks5-tunnel + fi + _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_OFF_FLAG="/opt/zapret-manager-luci/doh_off" +_doh_pkg() { + if [ "$PKG" = "apk" ]; then apk info -e https-dns-proxy >/dev/null 2>&1 + else opkg list-installed 2>/dev/null | grep -q '^https-dns-proxy '; fi +} +_doh_steer_active() { grep -qx 'steer-spec' /etc/zm-steer/owned 2>/dev/null && [ ! -f /etc/zm-steer/stopped ]; } + +DOH_FORCE_MODE_FILE="/opt/zapret-manager-luci/doh_force" +_doh_force_mode() { + local m + m="$(cat "$DOH_FORCE_MODE_FILE" 2>/dev/null)" + case "$m" in off) echo off ;; *) echo auto ;; esac +} +_doh_force_want() { + if [ "$(_doh_force_mode)" = off ]; then echo 0; else echo 1; fi +} +_doh_main_text() { + local n + echo "config main 'config'" + echo " option dnsmasq_config_update '*'" + echo " option force_dns '$1'" + echo " option notrack_dns '1'" + echo " list force_dns_port '53'" + echo " list force_dns_port '853'" + for n in $(_zm_lan_nets); do echo " list force_dns_src_interface '$n'"; done + echo " option procd_trigger_wan6 '0'" + echo " option heartbeat_domain 'heartbeat.mossdef.org'" + echo " option heartbeat_sleep_timeout '10'" + echo " option heartbeat_wait_timeout '10'" + echo " option user 'nobody'" + echo " option group 'nogroup'" + echo " option listen_addr '127.0.0.1'" + echo " option force_ip_family 'auto'" + if [ "$1" = 1 ]; then + echo " option canary_domains_icloud '1'" + echo " option canary_domains_mozilla '1'" + fi +} +_doh_other_sections() { + awk '/^config[ \t]/ { skip = ($2 == "main") } !skip' "$_doh_file" 2>/dev/null | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}' | sed '/./,$!d' +} +_doh_force_apply() { + local want new cur rest + [ -f "$_doh_file" ] || return 0 + [ -f "$DOH_OFF_FLAG" ] && return 0 + want="$(_doh_force_want)" + new="$(_doh_main_text "$want")" + cur="$(awk '/^config[ \t]/ { on = ($2 == "main") } on' "$_doh_file" | sed '/^[[:space:]]*$/d')" + [ "$cur" = "$new" ] && return 0 + rest="$(_doh_other_sections)" + { printf '%s\n' "$new"; [ -n "$rest" ] && printf '\n%s\n' "$rest"; } > "$_doh_file.zmtmp" && mv -f "$_doh_file.zmtmp" "$_doh_file" + /etc/init.d/https-dns-proxy running >/dev/null 2>&1 && /etc/init.d/https-dns-proxy restart >/dev/null 2>&1 + return 0 +} + +doh_force_set() { + case "$1" in auto|off) ;; on) set -- auto ;; *) echo '{"error":"неизвестный режим"}'; return 1 ;; esac + if [ ! -f "$_doh_file" ] || [ -f "$DOH_OFF_FLAG" ] || [ ! -x /etc/init.d/https-dns-proxy ]; then + echo '{"error":"DNS over HTTPS не установлен — сначала нажмите «Установить DNS over HTTPS»"}' + return 1 + fi + mkdir -p "$(dirname "$DOH_FORCE_MODE_FILE")" + if [ "$1" = auto ]; then rm -f "$DOH_FORCE_MODE_FILE"; else echo "$1" > "$DOH_FORCE_MODE_FILE"; fi + _doh_force_apply + printf '{"ok":true,"mode":"%s","force_dns":%s}\n' "$1" "$([ "$(_doh_force_want)" = 1 ] && echo true || echo false)" +} + +do_doh_install() { + local installed + _fk_present && { echo "ОШИБКА: стоит Forkozz — он сам шифрует DNS. Удалите Forkozz, чтобы поставить DoH"; return 1; } + installed=$(doh_status | grep -o '"installed":[a-z]*' | cut -d: -f2) + if [ "$installed" = "true" ]; then + echo "==> DNS over HTTPS уже установлен" + return 0 + fi + if _doh_pkg; then + rm -f "$DOH_OFF_FLAG" + echo "==> Пакет https-dns-proxy уже стоит — включаем службу" + /etc/init.d/https-dns-proxy enable >/dev/null 2>&1 + _doh_force_apply + echo "==> Готово — выберите сервер ниже" + return 0 + fi + _ensure_deps + echo "==> Обновляем список пакетов" + $UPDATE >&2 + echo "==> Устанавливаем https-dns-proxy и luci-app-https-dns-proxy" + $INSTALL https-dns-proxy luci-app-https-dns-proxy >&2 || { echo "ОШИБКА установки"; return 1; } + _doh_force_apply + echo "==> Готово, DNS over HTTPS установлен — выберите сервер ниже" +} + +do_doh_remove() { + local r 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_OFF_FLAG" "$DOH_FORCE_MODE_FILE" "$DOH_BOOT_FILE" "$DOH_FO_FILE" "$DOH_FO_STATE" "$DOH_FO_EVENTS" || 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" + echo "==> Перезапускаем DNS роутера" + /etc/init.d/dnsmasq restart >/dev/null 2>&1 + _zm_rm_done "$rc" "DNS over HTTPS" +} +doh_remove() { + job_start doh_remove do_doh_remove +} + +doh_install() { + job_start doh_install do_doh_install +} + +_doh_provider_of() { + case "$1" in + https://cloudflare-dns.com/*|https://1.1.1.1/*|https://1.0.0.1/*) echo cloudflare ;; + https://dns.google/*|https://8.8.8.8/*|https://8.8.4.4/*) echo google ;; + https://dns.quad9.net/*) echo quad9 ;; + https://xbox-dns.ru/*) echo xbox ;; + https://dns.comss.one/*) echo comss ;; + https://dns.dns-ai.ru/*) echo dnsai ;; + https://family.dot.dns.yandex.net/*) echo yandex_family ;; + https://safe.dot.dns.yandex.net/*) echo yandex_safe ;; + https://eu.geohide.ru/*) echo geohide_eu ;; + https://us.geohide.ru/*) echo geohide_us ;; + https://geohide.ru/*|https://dns.geohide.ru*) echo geohide_ru ;; + https://dns.malw.link/*) echo malw ;; + https://dns.zapret.moe/*) echo zapretmoe ;; + *) echo "" ;; + esac +} + +_hosts_extra() { + [ -s "$HOSTS_FILE" ] || { echo false; return; } + awk -v me="$(uci -q get system.@system[0].hostname 2>/dev/null)" ' + BEGIN { split("localhost localhost.localdomain ip6-localhost ip6-loopback ip6-localnet ip6-mcastprefix ip6-allnodes ip6-allrouters ip6-allhosts broadcasthost", a, " "); for (i in a) ok[a[i]] = 1; r = "false" } + { sub(/#.*/, ""); if (NF < 2) next + for (i = 2; i <= NF; i++) { n = tolower($i); if (n in ok || n == tolower(me) || n ~ /\.lan$/) continue; r = "true"; exit } } + END { print r }' "$HOSTS_FILE" 2>/dev/null || echo false +} + +doh_status() { + local installed="false" current="" running=false force=false list="" sep="" i url port prov n=0 provs="" + _doh_pkg && [ ! -f "$DOH_OFF_FLAG" ] && installed="true" + if [ -f "$_doh_file" ]; then + i=0 + while url="$(uci -q get "https-dns-proxy.@https-dns-proxy[$i].resolver_url")"; do + port="$(uci -q get "https-dns-proxy.@https-dns-proxy[$i].listen_port")" + [ -n "$port" ] || port=$((5053 + i)) + prov="$(_doh_provider_of "$url")" + list="$list$sep{\"url\":\"$(esc "$url")\",\"port\":\"$(esc "$port")\",\"provider\":\"$prov\",\"bootstrap\":\"$(esc "$(uci -q get "https-dns-proxy.@https-dns-proxy[$i].bootstrap_dns")")\"}" + sep=","; n=$((n + 1)); provs="$prov" + i=$((i + 1)) + [ "$i" -gt 16 ] && break + done + [ "$n" = 1 ] && current="$provs" + [ "$(uci -q get https-dns-proxy.config.force_dns)" != "0" ] && force=true + fi + /etc/init.d/https-dns-proxy running >/dev/null 2>&1 && running=true + printf '{"installed":%s,"current":"%s","running":%s,"force_dns":%s,"force_mode":"%s","resolvers":[%s],"steer_active":%s,"steer_installed":%s,"forkozz":%s,"bootstrap_custom":"%s","hosts_extra":%s,"failover":%s}\n' \ + "$installed" "$(esc "$current")" "$running" "$force" "$(_doh_force_mode)" "$list" \ + "$(_doh_steer_active && echo true || echo false)" \ + "$(grep -qx 'engine' /etc/zm-steer/owned 2>/dev/null && stl_present && echo true || echo false)" \ + "$(_fk_present && echo true || echo false)" "$(esc "$(cat "$DOH_BOOT_FILE" 2>/dev/null)")" "$(_hosts_extra)" "$(_doh_fo_json)" +} + +DOH_BOOT_FILE="/opt/zapret-manager-luci/doh_bootstrap" + +_doh_boot_of() { + case "$1" in + cloudflare) echo "1.1.1.1,1.0.0.1,2606:4700:4700::1111,2606:4700:4700::1001" ;; + google) echo "8.8.8.8,8.8.4.4,2001:4860:4860::8888,2001:4860:4860::8844" ;; + quad9) echo "9.9.9.9,149.112.112.112,2620:fe::fe,2620:fe::9" ;; + yandex_family) echo "77.88.8.7,77.88.8.3" ;; + yandex_safe) echo "77.88.8.88,77.88.8.2" ;; + esac +} + +_doh_ip_ok() { + 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 + case "$v" in *[*?\[]*) echo '{"error":"укажите IP-адреса через запятую, например 77.88.8.8, 1.1.1.1"}'; return 1 ;; esac + for ip in $(printf '%s' "$v" | tr ',;' ' '); do + _doh_ip_ok "$ip" || { printf '{"error":"%s"}\n' "$(esc "«$ip» — не IP-адрес. Укажите IP через запятую, например 77.88.8.8, 1.1.1.1")"; return 1; } + case ",$out," in *",$ip,"*) continue ;; esac + out="$out${out:+,}$ip"; n=$((n + 1)) + done + [ "$n" -le 6 ] || { echo '{"error":"укажите не больше 6 адресов"}'; return 1; } + mkdir -p "$(dirname "$DOH_BOOT_FILE")" + if [ -n "$out" ]; then printf '%s\n' "$out" > "$DOH_BOOT_FILE"; else rm -f "$DOH_BOOT_FILE"; fi + if [ -f "$_doh_file" ] && _doh_pkg; then + i=0 + while url="$(uci -q get "https-dns-proxy.@https-dns-proxy[$i].resolver_url")"; do + b="$out" + [ -n "$b" ] || b="$(_doh_boot_of "$(_doh_provider_of "$url")")" + if [ -n "$b" ]; then uci -q set "https-dns-proxy.@https-dns-proxy[$i].bootstrap_dns=$b" + else uci -q delete "https-dns-proxy.@https-dns-proxy[$i].bootstrap_dns"; fi + i=$((i + 1)); [ "$i" -gt 16 ] && break + done + uci -q commit https-dns-proxy + if [ ! -f "$DOH_OFF_FLAG" ]; then + _zm_run 30 /etc/init.d/https-dns-proxy restart >/dev/null 2>&1 + /etc/init.d/dnsmasq restart >/dev/null 2>&1 + fi + fi + printf '{"ok":true,"bootstrap":"%s"}\n' "$(esc "$out")" +} + +_doh_url_of() { + case "$1" in + cloudflare) echo "https://cloudflare-dns.com/dns-query" ;; + google) echo "https://dns.google/dns-query" ;; + quad9) echo "https://dns.quad9.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" ;; + 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" ;; + 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 +} + +# Bootstrap DNS, через который https-dns-proxy узнаёт адрес DoH-сервера: свои → провайдера → по умолчанию +_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-сервера — через Bootstrap DNS, а не через DNS роутера: тот может как раз не работать +_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-запрос (example.com, A) напрямую к серверу. Печатает «ok МС» или «fail ПРИЧИНА». +# Мало, чтобы адрес открывался: нужен код 200, тип application/dns-message и ответ с адресом внутри. +_doh_probe() { + local url="$1" t="${3:-/tmp/zm-doh.$$}" rest hp host port ips res="" w rc code ct hx f1 f2 an r + rest="${url#*://}"; hp="${rest%%/*}" + case "$hp" in + *:*) host="${hp%%:*}"; port="${hp##*:}" ;; + *) host="$hp"; case "$url" in http://*) port=80 ;; *) port=443 ;; esac ;; + esac + printf '\022\064\001\000\000\001\000\000\000\000\000\000\007example\003com\000\000\001\000\001' > "$t.q" + : > "$t.b" + ips="$(_doh_host_ips "$host" "$2")" + case "$host" in *[!0-9.]*) [ -n "$ips" ] && res="--resolve $host:$port:$ips" ;; esac + w="$(curl -q --noproxy '*' -s -o "$t.b" -w '%{http_code} %{time_pretransfer} %{time_starttransfer} %{content_type}' \ + --connect-timeout 3 --max-time 5 $res \ + -H 'Content-Type: application/dns-message' -H 'Accept: application/dns-message' \ + --data-binary "@$t.q" "$url" 2>/dev/null)" + rc=$? + case "$rc" in + 0) ;; + 6) r="fail resolve" ;; + 28) r="fail timeout" ;; + 35|51|53|58|59|60|77|83|90|91) r="fail tls" ;; + *) r="fail connect" ;; + esac + if [ "$rc" = 0 ]; then + code="${w%% *}" + ct="$(printf '%s' "$w" | cut -d' ' -f4- | tr 'A-Z' 'a-z')" + if [ "$code" != 200 ]; then r="fail http_$code" + else + case "$ct" in *application/dns-message*) r="" ;; *) r="fail bad" ;; esac + if [ -z "$r" ]; then + hx="$(head -c 8 "$t.b" 2>/dev/null | hexdump -v -e '1/1 "%02x"' 2>/dev/null)" + if [ "${#hx}" = 16 ]; then + f1=$((0x$(printf '%s' "$hx" | cut -c5-6))) + f2=$((0x$(printf '%s' "$hx" | cut -c7-8))) + an=$((0x$(printf '%s' "$hx" | cut -c13-16))) + if [ "$(printf '%s' "$hx" | cut -c1-4)" != 1234 ] || [ "$f1" -lt 128 ]; then r="fail bad" + elif [ $((f2 % 16)) != 0 ]; then r="fail rcode" + elif [ "$an" = 0 ]; then r="fail empty" + fi + elif [ ! -s "$t.b" ]; then r="fail bad" + fi + fi + [ -n "$r" ] || r="ok $(printf '%s' "$w" | awk '{ gsub(/,/, "."); v = ($3 - $2) * 1000; if (v < 1) v = 1; printf "%d", v + 0.5 }')" + fi + fi + rm -f "$t.q" "$t.b" + echo "$r" +} + +_doh_err_text() { + case "$1" in + resolve) echo "не удалось узнать адрес сервера" ;; + timeout) echo "сервер не ответил за 5 секунд" ;; + tls) echo "не установилось защищённое соединение" ;; + connect) echo "сервер недоступен" ;; + http_*) echo "сервер ответил ошибкой ${1#http_}" ;; + rcode|empty) echo "сервер ответил, но адрес сайта не выдал" ;; + *) echo "ответ сервера не похож на DNS" ;; + esac +} + +_doh_res_json() { + case "$1" in + "ok "*) printf '"ok":true,"ms":%s' "${1#ok }" ;; + *) printf '"ok":false,"err":"%s"' "$(esc "$(_doh_err_text "${1#fail }")")" ;; + esac +} + +# Кнопка «Проверить»: отвечает ли провайдер, без изменения настроек +doh_probe() { + local url + url="$(_doh_url_of "$1")" || { echo '{"error":"адрес сервера не подходит — нужна ссылка вида https://…/dns-query"}'; return 1; } + printf '{%s}\n' "$(_doh_res_json "$(_doh_probe "$url" "$(_doh_boots_for "$1")")")" +} + +# Отвечает ли DNS на самом роутере: сначала сам https-dns-proxy на своём порту, затем весь путь через dnsmasq. +# Для второго берём имя, которого нет в кэше: ответ «такого сайта нет» тоже значит, что DNS работает. +_doh_local_ok() { + local port="${1:-5053}" o + _doh_t 3 nslookup -type=a example.com "127.0.0.1:$port" >/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_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 +} + +# Строка «Служба» на странице: не «процесс запущен», а «DNS действительно отвечает» +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" ] || [ -f "$DOH_OFF_FLAG" ] || ! /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_local_ok "${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 + _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 + r="$(_doh_apply "$provider")" || { printf '%s\n' "$r"; return 1; } + # выбор человека — сервер, к которому автопереключение возвращается + _doh_fo_put pref "$provider" + _doh_fo_put active "$provider" + rm -f "$DOH_FO_STATE" + _ab_dns_bg + printf '%s\n' "$r" +} + +# Ставит сервер: конфиг https-dns-proxy, перезапуск, проверка, что DNS роутера отвечает; не ответил — откат. +_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" + /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 + # применили — проверяем, что DNS роутера отвечает; если нет, возвращаем прежние настройки + 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 ---------- +# Сторож cron раз в 2 минуты проверяет сервер настоящим DoH-запросом (как кнопка «Проверить»). Не ответил +# дважды подряд — DNS переходит на первый отвечающий сервер из цепочки запасных, которую человек собрал сам +# (до трёх, по порядку); выбранный человеком сервер +# снова ответил дважды подряд — возвращаемся на него. Не отвечает никто и включено «без DoH» — служба +# останавливается (https-dns-proxy сам возвращает dnsmasq к обычным серверам) и запускается снова, как только +# какой-нибудь сервер ответит. Выбор человека (pref) и настройки — на флеше, текущее состояние — в ОЗУ. +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="$(sed -n "s/^$1=//p" "$DOH_FO_FILE" 2>/dev/null | tail -n1)"; 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="$(sed -n "s/^$1=//p" "$DOH_FO_STATE" 2>/dev/null | tail -n1)"; 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_group_of() { + case "$1" in + google|cloudflare|quad9) echo plain ;; + xbox|comss|dnsai|geohide_ru|geohide_eu|geohide_us|malw|zapretmoe) echo geo ;; + yandex_safe|yandex_family) echo family ;; + esac +} +_doh_group_ids() { + case "$1" in + plain) echo "google cloudflare quad9" ;; + geo) echo "xbox comss dnsai geohide_ru geohide_eu geohide_us malw zapretmoe" ;; + family) echo "yandex_safe yandex_family" ;; + esac +} +_doh_name() { + case "$1" in + google) echo Google ;; cloudflare) echo Cloudflare ;; quad9) echo Quad9 ;; xbox) echo XBOX ;; comss) echo Comss ;; dnsai) echo DNS-AI ;; + 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 ;; + https://*) 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; } +# Цепочка запасных, которую собрал человек: «chain=id,id,id» (id из списка или свой адрес https://…). +# Настроек от старых версий (только группа «pool») — берём первые три сервера этой группы, пока цепочку не сохранят. +_doh_fo_chain() { + local pref="$1" g id n=0 + if grep -q '^chain=' "$DOH_FO_FILE" 2>/dev/null; then + _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 + fi + [ "$(_doh_fo_get fo 0)" = 1 ] || return 0 + g="$(_doh_fo_get pool same)" + [ "$g" = same ] && g="$(_doh_group_of "$pref")" + for id in $(_doh_group_ids "$g"); do + [ "$id" = "$pref" ] && continue + echo "$id"; n=$((n + 1)); [ "$n" -ge 3 ] && break + done +} +# Запасные по порядку цепочки, без текущего и без выбранного +_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 && [ ! -f "$DOH_OFF_FLAG" ] || return 0 + _fk_present && return 0 + 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"; } + # сервер сменили в обход панели (страница https-dns-proxy в LuCI) — это новый выбор человека, а не запасной + 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 + # без DoH: ждём, когда ответит выбранный или запасной, и запускаем службу снова + 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 + # службу остановили не мы — не вмешиваемся + [ "$run" = 1 ] || { rmdir "$lk" 2>/dev/null; return 0; } + 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 _doh_ok "$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,"pool":"%s","pref":"%s","chain":[%s],"chain_set":%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 pool same)")" "$(esc "$(_doh_fo_get pref "")")" "$ch" \ + "$(grep -q '^chain=' "$DOH_FO_FILE" 2>/dev/null && echo true || echo false)" \ + "$(esc "$(_doh_st_get mode normal)")" "$(_doh_st_get since 0)" "$(_doh_st_get fails 0 | tr -cd 0-9)" "$ev" +} +# Настройки из страницы: «fo=1 direct=0 chain=malw,zapretmoe,https://dns.example.com/dns-query» +doh_failover_set() { + local kv k v fo direct pool chain="" setch="" id n=0 pref bad="" + fo="$(_doh_fo_get fo 0)"; direct="$(_doh_fo_get direct 0)"; pool="$(_doh_fo_get pool same)" + 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 ;; + pool) case "$v" in same|plain|geo|family) pool="$v" ;; esac ;; + 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"; _doh_fo_put pool "$pool" + [ -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 + # выключили, пока DNS шёл без DoH, — запускаем DoH обратно + if [ "$(_doh_st_get mode normal)" = direct ] && [ ! -f "$DOH_OFF_FLAG" ]; then + /etc/init.d/https-dns-proxy start >/dev/null 2>&1 + _doh_fo_event "автопереключение выключено — DNS снова идёт через DoH" + fi + rm -f "$DOH_FO_STATE" + fi + printf '%s\n' "$(_doh_fo_json)" +} + +RB_SHARE="/usr/share/zm-redbtn" +ZM_LISTS_DIR="/opt/zapret-manager-luci/lists" +ZM_LISTS_ITD="${GH_RAW}/itdoginfo/allow-domains/main" +# Meta, Instagram, Facebook и WhatsApp — списки v2fly (и встроенные дополнения из itdoginfo); те же ссылки берёт Forkozz +# (PKG_LISTS в netshift.uc). Пункты не пересекаются: каждый включает только свой сервис. Подсети Meta (AS32934) не берём — +# по IP не отличить Instagram от Facebook и WhatsApp, они включили бы всё сразу. svc_meta.lst, svc_facebook_dev.lst и +# meta.lst остались только для старых настроек. +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_meta.lst) echo "$ZM_LISTS_ITD/Services/meta.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_v2meta.lst) echo "$ZM_LISTS_V2F/meta" ;; + svc_threads.lst) echo "$ZM_LISTS_V2F/threads" ;; + svc_oculus.lst) echo "$ZM_LISTS_V2F/oculus" ;; + svc_facebook_dev.lst) echo "$ZM_LISTS_V2F/facebook-dev" ;; + svc_twitter.lst) echo "$ZM_LISTS_ITD/Services/twitter.lst" ;; + telegram.lst) echo "$ZM_LISTS_ITD/Subnets/IPv4/telegram.lst" ;; + discord.lst) echo "$ZM_LISTS_ITD/Subnets/IPv4/discord.lst" ;; + meta.lst|whatsapp.lst) echo "$ZM_LISTS_ITD/Subnets/IPv4/meta.lst" ;; + twitter_x.lst) echo "$ZM_LISTS_ITD/Subnets/IPv4/twitter.lst" ;; + own_github.lst) echo "${GH_RAW}/v2fly/domain-list-community/master/data/github" ;; + *) return 1 ;; + esac +} + +_rb_list_norm() { + tr -d '\r' < "$2" | sed 's/[[:space:]]*#.*$//; s/^[[:space:]]*//; s/[[:space:]]*$//' | tr 'A-Z' 'a-z' > "$2.n" + case "$1" in + svc_*|own_*) + grep -v '^include:\|^regexp:\|^keyword:\|[[:space:]]@' "$2.n" | sed 's/^domain://; s/^full://; s/^suffix://; s/^\*\.//; s/^\.//' | + grep -E '^[a-z0-9]([a-z0-9_-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9_-]*[a-z0-9])?)+$' | grep -vE '^[0-9.]+$' | awk '!s[$0]++' ;; + *) + grep -E '^[0-9]{1,3}(\.[0-9]{1,3}){3}(/[0-9]{1,2})?$' "$2.n" | awk '!s[$0]++' ;; + esac + rm -f "$2.n" +} + +_rb_list_get() { + local f="$1" p="$ZM_LISTS_DIR/$1" u tmp + u="$(_rb_list_src "$f")" || return 1 + 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() { + if [ "$(cat "$ST_WARP_MODE" 2>/dev/null)" = own ]; then + ST_WARP_N=1; ST_WIF1="$ST_OWN_IF"; ST_WCONF1="$ST_WARP_OWN" + else + ST_WARP_N="$ST_WARP_MAX"; ST_WIF1="zmwarp"; ST_WCONF1="$ST_DIR/warp.conf" + fi + ST_WARP_IF="$ST_WIF1"; ST_WARP_CONF="$ST_WCONF1" +} +_st_mode_vars +ST_WARP_UP="$ST_DIR/warp.up" +ST_WARP_GEO="$ST_DIR/warp.geo" +ST_RU_COLOS="DME SVX LED KJA REN OVB KZN AER VVO" +ST_DEFAULT_SEL="" + +# ---- Слой steer ------------------------------------------------------------------------------- +# Единственное место, где Zapret Manager говорит с ядром steer 2.x. Остальной бекенд говорит на +# своём языке (выход WARP или VPN, сервисы и их списки, подписка), а слой переводит это в спеку v2 +# (/etc/steer/spec.json) и зовёт CLI ядра: клиент steer сам отдаёт команды демону steerd. +# ядро и пакеты stl_present, stl_version, stl_v2, stl_has vpn|hy2|proxy, stl_busy_by, stl_latest, +# stl_engine_install ВЕРСИЯ [vpn hy2 proxy], stl_engine_remove ПАКЕТЫ, stl_leftovers +# настройка stl_apply МОДЕЛЬ, stl_spec_whose, stl_spec_restore, stl_rules +# служба stl_running, stl_enabled, stl_restart, stl_stop, stl_reload +# состояние stl_state warp|vpn, stl_vpn_json, stl_diag, stl_diag_lines, stl_explain ЦЕЛЬ +# подписка stl_sub_fetch, stl_sub_norm, stl_sub_mods, stl_sub_nodes, stl_sub_check, stl_probe +# МОДЕЛЬ — файл, строка на запись, поля через табуляцию: +# lan УСТРОЙСТВО... откуда клиенты +# warp УСТРОЙСТВО... выход — туннели WARP, по предпочтению +# vpn ПОДПИСКА выход — подписка (VLESS, Hysteria2, Trojan, SS, SOCKS, HTTP, VMess) +# tun ПРОТОКОЛ УЗЛЫ МАРКЕРЫ туннель протокола подписки; УЗЛЫ — номера в протоколе через +# запятую или «-» (первый рабочий), МАРКЕРЫ — куски имён через | или «-» +# svc ID ИМЯ сервис; за ним его списки: +# srs|dom|pfx ФАЙЛ набор sing-box, домены, подсети +STL_TMP="$ST_RUN" +STL_KEEP="$ST_DIR/spec.before" +STL_CACHE="$ZM_STATE_DIR/steer.latest" +STL_ETC="${STL_ETC:-/etc/steer}" +STL_SPEC="$STL_ETC/spec.json" +STL_YAML="$STL_ETC/spec.yaml" +STL_SOCK="$STL_ETC/steer.sock" +STL_INIT="${STL_INIT:-/etc/init.d/steer}" +STL_SBIN="${STL_SBIN:-/usr/sbin}" +STL_STATE="${STL_STATE:-/var/lib/steer}" +STL_MIN="2.0.0" +STL_MODS="vless xsteer obfs tgws hysteria2 proxy" +STL_REL_URLS="${STL_REL_URLS:-https://raw.githubusercontent.com/splify2/releases/main/version.json https://cdn.jsdelivr.net/gh/splify2/releases@main/version.json https://splify2.github.io/releases/version.json}" +STL_PKG_URLS="${STL_PKG_URLS:-https://github.com/splify2/releases/releases/download/steer-v@V@ https://github.com/splify2/steer/releases/download/v@V@ https://gitlab.com/xyzmean/steer/-/raw/dist}" +STL_WARP_OUT="zm_warp" +STL_VPN_OUT="zm_vpn" +STL_VPN_DEV="zm_vpn" + +_stl_say() { echo "==> $*"; } +_st_onfail() { [ "$(cat /etc/zm-steer/onfail 2>/dev/null)" = drop ] && echo drop || echo direct; } +_stl_t() { _zm_tmo "$@"; } +_stl_out() { case "$1" in vpn) echo "$STL_VPN_OUT" ;; *) echo "$STL_WARP_OUT" ;; esac; } +_stl_js() { printf '"%s"' "$(esc "$1")"; } +_stl_ident() { + local n + n="$(printf '%s' "$1" | tr -c 'A-Za-z0-9_.-' '_')" + case "$n" in ''|all|lan|[-.]*) n="z$n" ;; esac + [ "${#n}" -le 31 ] || n="$(printf '%s' "$n" | cut -c1-26)-$(printf '%s' "$1" | md5sum | cut -c1-4)" + printf '%s' "$n" +} +_stl_label() { + local n + n="$(printf '%s' "$1" | tr -d '"\\\000-\037')" + [ -n "$n" ] && [ "$(printf '%s' "$n" | wc -c)" -le 31 ] && { printf '%s' "$n"; return; } + _stl_ident "$2" +} +_stl_ver_ok() { _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 forkop; do [ -e "${i%/*}/$b" ] && by="${by:+$by, }$b"; done + echo "${by:-steer-box-connector}" +} +_stl_mods_present() { local m; for m in $STL_MODS; do [ -x "$STL_SBIN/steer-$m" ] && printf '%s ' "$m"; done; } + +_stl_rel() { + local f="$STL_TMP/version.json" u + mkdir -p "$STL_TMP" + [ -s "$f" ] && [ -z "$(find "$f" -mmin +60 2>/dev/null)" ] && { echo "$f"; return 0; } + for u in $STL_REL_URLS; do + curl -fsSL --connect-timeout 6 --max-time 20 -o "$f.tmp" "$u" 2>/dev/null || continue + [ "$(jsonfilter -i "$f.tmp" -e '@.schema' 2>/dev/null)" = 1 ] || continue + mv -f "$f.tmp" "$f"; echo "$f"; return 0 + done + rm -f "$f.tmp" + [ -s "$f" ] && { echo "$f"; return 0; } + return 1 +} + +stl_latest() { + local v="" f u + if [ -z "$ZM_VER_FORCE" ] && [ -s "$STL_CACHE" ] && [ -z "$(find "$STL_CACHE" -mmin +360 2>/dev/null)" ]; then cat "$STL_CACHE"; return 0; fi + f="$(_stl_rel)" && v="$(jsonfilter -i "$f" -e '@.products.steer.stable' 2>/dev/null)" + if ! _stl_ver_ok "$v"; then + for u in $STL_PKG_URLS; do + case "$u" in *@V@*) continue ;; esac + v="$(curl -fsSL --connect-timeout 6 --max-time 12 "$u/VERSION" 2>/dev/null | tr -d '[:space:]')" + _stl_ver_ok "$v" && break + v="" + done + fi + if _stl_ver_ok "$v"; then + mkdir -p "$(dirname "$STL_CACHE")"; echo "$v" > "$STL_CACHE"; echo "$v" + elif [ -s "$STL_CACHE" ]; then cat "$STL_CACHE" + else echo "$STL_MIN"; fi +} + +_stl_fetch() { + local ver="$1" file="$2" out="$3" f sum="" u urls="" + if f="$(_stl_rel)"; then + urls="$(jsonfilter -i "$f" -e "@.products.steer.versions[@.version='$ver'].assets[@.name='$file'].urls[*]" 2>/dev/null | grep -E '^https://[A-Za-z0-9._~:/%+@=-]+$')" + sum="$(jsonfilter -i "$f" -e "@.products.steer.versions[@.version='$ver'].assets[@.name='$file'].sha256" 2>/dev/null)" + fi + for u in $urls $(echo "$STL_PKG_URLS" | sed "s/@V@/$ver/g"); do + case "$u" in */"$file") ;; *) u="$u/$file" ;; esac + _rb_fetch_pkg "$u" "$out" >&2 || continue + [ -z "$sum" ] || [ "$(sha256sum "$out" | cut -d' ' -f1)" = "$sum" ] && return 0 + echo " у файла не та контрольная сумма: $u" >&2 + rm -f "$out" + done + return 1 +} + +stl_engine_install() { + local ver="$1" arch m p n f mods="" pkgs files="" old="" back="" legacy="" keep="$STL_TMP/spec.keep" was_on="" rc + shift + _stl_ver_ok "$ver" || { echo "ОШИБКА: версия ядра steer $ver — нужна 2.0.0 или новее"; return 1; } + arch="$(_rb_arch)" + [ -n "$arch" ] || { echo "ОШИБКА: не удалось определить архитектуру роутера"; return 1; } + for m in "$@"; do case "$m" in vpn) mods="$mods vless" ;; 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 +} + +# Пакеты ядра steer, которые сейчас стоят на роутере: ядро, его модули и прежние имена +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 -q "\"$STL_WARP_OUT\"\|\"$STL_VPN_OUT\"" "$STL_SPEC" && { echo zm; return; } + tr '\n\r' ' ' < "$STL_SPEC" | grep -q '"\(channels\|rules\)"[[:space:]]*:[[:space:]]*\[[[:space:]]*{' && { echo foreign; return; } + echo none +} +stl_spec_restore() { if [ -s "$STL_KEEP" ]; then mv -f "$STL_KEEP" "$STL_SPEC"; else rm -f "$STL_SPEC"; fi; } +stl_rules() { grep -o '"out":' "$STL_SPEC" 2>/dev/null | wc -l | tr -d ' '; } + +_stl_rule() { + [ -n "$lid" ] && [ -n "$srs$dom$pfx" ] || return 0 + body="" + [ -n "$srs" ] && body="\"srs\":[$srs]" + [ -n "$dom" ] && body="$body${body:+,}\"domains_file\":[$dom]" + [ -n "$pfx" ] && body="$body${body:+,}\"prefixes_file\":[$pfx]" + lists="$lists${lists:+,}$(_stl_js "$lid"):{$body}" + rules="$rules${rules:+,}{\"name\":$(_stl_js "$lab"),\"to\":$(_stl_js "$lid"),\"out\":\"$out\"}" +} + +# Выход-туннель одного протокола: ИМЯ ПРОТОКОЛ ПОДПИСКА УЗЛЫ МАРКЕРЫ УСТРОЙСТВО (без закрывающей «}») +_stl_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" +} +# Выходы VPN по строкам «tun ПРОТОКОЛ УЗЛЫ МАРКЕРЫ» модели: один протокол — выход zm_vpn, несколько — +# туннель на протокол и группа zm_vpn «первый живой» по порядку строк. +_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_spec() { + local tab k a b c lists="" rules="" outs="" lan="" sep="" lid="" lab="" srs="" dom="" pfx="" d m + local clients="" drules="" cid dout byp="" xd="" xp="" xc="" xr="" + tab="$(printf '\t')" + local out="" body wp=latency + while IFS="$tab" read -r k a b c; do + case "$k" in warp) out="$STL_WARP_OUT" ;; vpn) out="$STL_VPN_OUT" ;; wpick) [ "$a" = order ] && wp=order ;; esac + done < "$1" + [ -n "$out" ] || return 1 + while IFS="$tab" read -r k a b c; do + case "$k" in + lan) lan="$(printf '%s\n' "$a" "$b" "$c" | tr "$tab" '\n' | grep . | while read -r d; do printf ',%s' "$(_stl_js "$d")"; done | sed 's/^,//')" ;; + warp) + set -- $(printf '%s\t%s\t%s' "$a" "$b" "$c" | tr "$tab" ' ') + if [ $# = 1 ]; then + outs="\"$STL_WARP_OUT\":{\"kind\":\"interface\",\"device\":$(_stl_js "$1"),\"ipv6\":\"off\",\"on_fail\":\"$(_st_onfail)\"}" + else + m=""; for d; do m="$m${m:+,}$(_stl_js "$d")"; outs="$outs,$(_stl_js "$d"):{\"kind\":\"interface\",\"device\":$(_stl_js "$d"),\"ipv6\":\"off\"}"; done + # «самый быстрый»: смена члена группы рвёт все соединения WARP (ядро снимает их записи conntrack), + # поэтому уходим с живого туннеля только за явный выигрыш и меряем реже — без прыжков под нагрузкой + d=""; [ "$wp" = latency ] && d=',"tolerance":200,"interval":600' + outs="\"$STL_WARP_OUT\":{\"kind\":\"group\",\"pick\":\"$wp\"$d,\"members\":[$m],\"ipv6\":\"off\",\"on_fail\":\"$(_st_onfail)\"}$outs" + fi ;; + vpn) outs="$(_stl_vpn_outs "$1" "$a")" ;; + svc) _stl_rule; lid="$(_stl_ident "$a")"; lab="$(_stl_label "$b" "$a")"; srs=""; dom=""; pfx="" ;; + srs) srs="$srs${srs:+,}$(_stl_js "$a")" ;; + dom) dom="$dom${dom:+,}$(_stl_js "$a")" ;; + pfx) pfx="$pfx${pfx:+,}$(_stl_js "$a")" ;; + xdom) xd="$xd${xd:+,}$(_stl_js "$a")" ;; + xpfx) xp="$xp${xp:+,}$(_stl_js "$a")" ;; + dev) + # устройство целиком: клиент (MAC или один адрес) и правило «scope: device» + cid="$(_stl_ident "d_$b")" + case "$clients" in *"\"$cid\":"*) continue ;; esac + case "$b" in *:*) d="\"mac\":[$(_stl_js "$b")]" ;; *) d="\"addr\":[$(_stl_js "$b")]" ;; esac + clients="$clients${clients:+,}\"$cid\":{$d}" + if [ "$a" = byp ]; then dout="$ST_DIRECT_OUT"; byp=1; else dout="$out"; xc="$xc $cid"; fi + drules="$drules${drules:+,}{\"name\":$(_stl_js "$(_stl_ident "${a}_$b")"),\"for\":[\"$cid\"],\"to\":\"all\",\"out\":\"$dout\",\"scope\":\"device\"}" ;; + esac + done < "$1" + _stl_rule + [ -n "$rules$drules" ] || return 1 + # Исключения — напрямую и с настоящими адресами (realip): первым общим правилом и правилом на каждое + # устройство «весь трафик через туннель» (правила на устройство старше общих, без своего исключения + # такое устройство увело бы исключённый сайт в туннель). + 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 $xc; 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}," + printf '{"version":2,"lan":{"devices":[%s]},%s"lists":{%s},"outputs":{%s},"rules":[%s]}\n' "${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 + rm -f "$new" + echo "ОШИБКА: ядро steer отвергло настройку" + printf '%s\n' "$err" | tail -n 5 + return 1 + fi + [ "$(stl_spec_whose)" = none ] && [ -s "$STL_SPEC" ] && [ ! -f "$STL_KEEP" ] && cp -f "$STL_SPEC" "$STL_KEEP" + rm -f "$prev"; [ -s "$STL_SPEC" ] && cp -f "$STL_SPEC" "$prev" + mv -f "$new" "$STL_SPEC" + "$STL_INIT" enable >/dev/null 2>&1 + if stl_running; then + if ! err="$(_stl_t 300 steer apply 2>&1 >/dev/null)"; then + if [ -s "$prev" ]; then mv -f "$prev" "$STL_SPEC"; else rm -f "$STL_SPEC"; fi + echo "ОШИБКА: ядро steer не применило правила — остались прежние" + printf '%s\n' "$err" | tail -n 5 + return 1 + fi + rm -f "$prev" + _stl_say "Правила Steer применены — ядро тронуло только изменившееся" + return 0 + fi + rm -f "$prev" + "$STL_INIT" start >/dev/null 2>&1 + while [ "$w" -lt 10 ] && ! stl_running; do sleep 1; w=$((w + 1)); done + stl_running || { echo "ОШИБКА: служба steer не запустилась — загляните в системный журнал"; return 1; } + _stl_say "Правила Steer применены" +} + +stl_running() { [ -x "$STL_INIT" ] && "$STL_INIT" running >/dev/null 2>&1; } +stl_enabled() { [ -x "$STL_INIT" ] && "$STL_INIT" enabled >/dev/null 2>&1; } +stl_restart() { + [ -x "$STL_INIT" ] || return 1 + "$STL_INIT" enable >/dev/null 2>&1 + "$STL_INIT" restart >/dev/null 2>&1 + sleep 1 + stl_running +} +stl_stop() { [ -x "$STL_INIT" ] || return 0; "$STL_INIT" stop >/dev/null 2>&1; "$STL_INIT" disable >/dev/null 2>&1; return 0; } +stl_reload() { stl_running || return 0; _stl_t 120 steer reload >/dev/null 2>&1; } + +stl_state() { + local o j 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 + j="$(_stl_t 10 steer status 2>/dev/null)" + case "$j" in '{'*) ;; *) return 1 ;; esac + # Ядро 2.0: узел, который сейчас несёт трафик, — в объекте vless/proxy/hysteria2 (node), потерю + # узла под работающим туннелем ядро сообщает полем node_down (причина), без probe. Подписка с + # несколькими протоколами — группа: живой член — group.selected, сведения об узле — у него. + 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 '^zmv_[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_vpn_json() { _st_vpn_json; } + +stl_diag() { + local d + d="$(_stl_t 30 steer diag 2>/dev/null | tr '\n' ' ')" + case "$d" in '{'*'}'*) printf '%s' "$d" ;; *) echo null ;; esac +} +stl_diag_lines() { + local f="$STL_TMP/stl-diag.json" n i=0 + mkdir -p "$STL_TMP" + _stl_t 30 steer diag > "$f" 2>/dev/null + n="$(jsonfilter -i "$f" -e '@.checks[*].id' 2>/dev/null | wc -l)" + while [ "$i" -lt "$n" ]; do + printf '%s\t%s\t%s\n' "$(jsonfilter -i "$f" -e "@.checks[$i].verdict" 2>/dev/null)" \ + "$(jsonfilter -i "$f" -e "@.checks[$i].what" 2>/dev/null)" "$(jsonfilter -i "$f" -e "@.checks[$i].why" 2>/dev/null)" + i=$((i + 1)) + done + rm -f "$f" +} + +stl_explain() { + local t line + STL_X_VERDICT=unknown; STL_X_OUT=""; STL_X_DEV=""; STL_X_SET=""; STL_X_ADDR=""; STL_X_FAKE=false + t="$(_stl_t 20 steer explain "$1" 2>&1)" + STL_X_TEXT="$t" + line="$(printf '%s\n' "$t" | grep -m1 -- '-> output "')" + STL_X_ADDR="$(printf '%s\n' "$t" | head -n1 | sed -n 's/^[^ ]* -> \([0-9a-fA-F.:]*\) (.*/\1/p')" + printf '%s\n' "$t" | head -n1 | grep -q 'fake-IP' && STL_X_FAKE=true + if [ -n "$line" ]; then + STL_X_OUT="$(printf '%s' "$line" | sed -n 's/.*-> output "\([^"]*\)".*/\1/p')" + STL_X_SET="$(printf '%s' "$line" | sed -n 's/.*"\([^"]*\)" -> output ".*/\1/p')" + STL_X_DEV="$(printf '%s' "$line" | sed -n 's/.*-> dev \([^ ]*\).*/\1/p')" + case "$STL_X_OUT" in "$STL_WARP_OUT") STL_X_VERDICT=warp ;; "$STL_VPN_OUT"|zmv_*) STL_X_VERDICT=vpn ;; "$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 + # ядро отвечает по-русски, когда до адреса дело не дошло: у имени нет адреса (у домена только поддомены), + # резолвер steer молчит или доменных правил нет вовсе — это разные ответы, а не «не удалось определить» + 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 +} +# У самого имени нет адреса (cdninstagram.com: есть только static., scontent. …) — правило из списка действует на +# все поддомены, поэтому путь показываем по первому поддомену, у которого адрес есть +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 +} +# ---- Подписка: все протоколы ядра steer 2.0 ---------------------------------------------------- +# VLESS — модуль steer-vless, hysteria2 — steer-hysteria2, trojan/shadowsocks/socks/http/vmess — +# steer-proxy. У каждого протокола свой выход-туннель (номера узлов в nodes — внутри протокола), а +# несколько протоколов собирает группа zm_vpn: «первый живой» в порядке подписки. +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 zmv_hy2 ;; shadowsocks) echo zmv_ss ;; *) echo "zmv_$1" ;; esac; } + +# Нормализация подписки (base64 → ссылки, имена узлов сырыми UTF-8) и карта строк рядом: ФАЙЛ ВЫХОД +stl_sub_norm() { + rm -f "$2" "$2.map" + LC_ALL=C awk -v OUT="$2" -v MAP="$2.map" ' +# Нормализация подписки для ядра steer: base64 → ссылки построчно, имя после «#» — сырыми байтами +# UTF-8. Ядро 2.0 режет имя узла по 128 байт ДО раскодирования процентов, и «%D0%A4…» съедало +# буфер втрое: «Финляндия » Хельсинки #1» и «#2» становились одним «Финляндия ». Сырые байты +# ядро читает как есть. ASCII во фрагменте остаётся в процентной форме — пробел, «#», «%». +# Рядом (MAP) — карта строк: номер, семейство, хост, порт: по ней узлы идут в порядке подписки. +# JSON-подписку (Xray, sing-box) не трогаем: имена там и так без процентов. +BEGIN { A = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; for (i = 1; i <= 64; i++) B64[substr(A, i, 1)] = i - 1; for (i = 0; i <= 16; i++) P2[i] = 2 ^ i } +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" ] +} + +# Какие модули ядра нужны подписке: vpn (VLESS — им же скачивается подписка) и hy2/proxy по ссылкам +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" +} + +# Единый список узлов: ФАЙЛ ВЫХОД_TSV СВОДКА. Строка: номер, протокол, номер в протоколе, номер в +# выводе модуля, имя (одинаковые — с «(2)», «(3)»), транспорт, защита, vision. +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" ' +# Единый список узлов подписки по модулям ядра steer (vless, hysteria2, proxy). +# Вход: строки «@модульJSON узлов» (вывод steer <модуль>-nodes ФАЙЛ). MAP — карта строк +# подписки от нормализатора. Выход (до сортировки): позиция, протокол, номер в протоколе (для +# nodes спеки), номер в выводе модуля (для *-probe по файлу), имя, транспорт, защита, vision. +# В META — сводка: пригодных, пропущено, причины пропуска. +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() { _st_owns "net $ST_WARP_IF" && [ -s "$ST_WARP_CONF" ]; } +_st_warp_own() { [ "$(cat "$ST_WARP_MODE" 2>/dev/null)" = own ]; } +_st_sel() { [ -s "$ST_SEL" ] || return 0; local id; for id in $(cat "$ST_SEL"); do _rb_routable "$id" || continue; [ "$id" = custom ] && [ ! -s "$ST_USER_DIR/custom.lst" ] && [ ! -s "$ST_USER_DIR/custom.pfx" ] && continue; echo "$id"; done; } + +# ---- Устройства целиком (как «Весь трафик устройства» в Forkozz) --------------------------------- +# $ST_DEVS — строка на устройство: РЕЖИМКЛЮЧИМЯ. РЕЖИМ: all — весь трафик устройства идёт +# через туннель Steer (WARP или VPN — куда идут и сервисы), byp — устройство ходит мимо Steer совсем. +# КЛЮЧ — MAC (надёжнее: адрес по DHCP меняется) или один адрес IPv4. В спеке ядра это клиент и правило +# «scope: device» — ядро ставит такие правила выше правил сервисов. Своя сеть (LAN, принтер, NAS) у +# «весь трафик» в туннель не уходит — ядро само её исключает. +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() { 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}"; } +# Есть ли у Steer работа: выбран сервис или устройство целиком +_st_work() { [ -n "$(_st_sel)" ] || _st_devs_all; } +# Исключения Steer: домены и подсети, которые всегда идут напрямую. Правило стоит первым — выше сервисов, +# а у устройств «весь трафик через туннель» есть своё такое же правило на устройство (они старше общих). +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") && $2 != "" { printf "%s{\"mode\":\"%s\",\"key\":\"%s\",\"name\":\"%s\"}", (n++ ? "," : ""), $1, j($2), j($3) }' "$ST_DEVS" +} +# Устройства LAN для выбора: аренды DHCP, постоянные адреса из настроек DHCP и соседи ARP на LAN. +# Строка JSON: mac, ip, name. +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" +} +# Сохраняет выбор устройств: строки «all|КЛЮЧ|ИМЯ» или «byp|КЛЮЧ|ИМЯ». Одно устройство — один режим. +_st_devs_save() { + local t="$ST_DEVS.tmp" m k n line bad="" + mkdir -p "$ST_DIR" + : > "$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="" + case "$m" in all|byp) ;; *) continue ;; esac + 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" + _rb_say "Устройства: весь трафик через туннель — $(_st_devs_n all), мимо Steer — $(_st_devs_n byp)" + 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_migrate() { + local old=/etc/zm-redbtn re='^(pkg (steer|kmod-amneziawg|amneziawg-tools|luci-proto-amneziawg)|net zmwarp|fw zmwarp|steer-spec)$' f id l + grep -qE "$re" "$old/owned" 2>/dev/null || return 0 + mkdir -p "$ST_DIR" + grep -E "$re" "$old/owned" | while read -r l; do _rb_in "$l" "$ST_OWNED" || echo "$l" >> "$ST_OWNED"; done + sed -i -E "/$re/d" "$old/owned" + for f in warp.conf warp.colo spec.before; do [ -f "$old/$f" ] && [ ! -f "$ST_DIR/$f" ] && mv "$old/$f" "$ST_DIR/$f"; done + if [ ! -s "$ST_SEL" ]; then + { + grep '|warp$' "$old/services" 2>/dev/null | cut -d'|' -f1 + cat "$old/warp.pick" 2>/dev/null + } | sort -u | while read -r id; do _rb_in "$id" "$old/warp.skip" || echo "$id"; done > "$ST_SEL" + fi + [ -s "$old/warp.skip" ] && [ ! -s "$ST_SKIP" ] && mv "$old/warp.skip" "$ST_SKIP" + rm -f "$old/warp.pick" "$old/warp.skip" + sed -i 's/# zm-autobypass$/# zm-steer/' "$CRON_FILE" 2>/dev/null +} +_st_sel_remap() { + local f + for f in "$ST_SEL" "$ST_SKIP"; do + grep -q '^c_itdoginfo_' "$f" 2>/dev/null || continue + awk -v conf="$RB_SHARE/services.conf" -v ex="$([ "$f" = "$ST_SEL" ] && echo 1)" 'BEGIN { while ((getline l < conf) > 0) { split(l, a, "|"); if (a[1] != "" && a[1] !~ /^#/) { b[a[1]] = 1; m = split(a[8], c, ","); for (i = 1; i <= m; i++) if (c[i] != "") bs[c[i]] = bs[c[i]] " " a[1] } } } + function put(k) { if (k != "" && !(k in s)) { s[k] = 1; print k } } + { k = $0; if (k ~ /^c_itdoginfo_/) { x = substr(k, 13); if (x in b) { put(x); next } if (ex && (x in bs)) { m = split(bs[x], q, " "); for (i = 1; i <= m; i++) put(q[i]); next } } put(k) }' "$f" > "$f.$$.tmp" && { if cmp -s "$f.$$.tmp" "$f"; then rm -f "$f.$$.tmp"; else mv -f "$f.$$.tmp" "$f"; fi; } + rm -f "$f.$$.tmp" + done +} +# Старая «Meta» из каталога itdoginfo (c_itdoginfo_meta) — это была вся Meta: превращаем её в четыре отдельных пункта. +# Обычная Meta больше ничего за собой не тянет — Instagram, Facebook и WhatsApp выбираются сами по себе. +_st_sel_meta() { + local f + for f in "$ST_SEL" "$ST_SKIP"; do + grep -qx 'c_itdoginfo_meta' "$f" 2>/dev/null || continue + awk -v kids="$([ "$f" = "$ST_SEL" ] && echo 1)" 'function put(k) { if (k != "" && !(k in s)) { s[k] = 1; print k } } + { if ($0 == "c_itdoginfo_meta") { put("meta"); if (kids) { put("instagram"); put("facebook"); put("whatsapp") } } else put($0) }' "$f" > "$f.$$.tmp" && + { if cmp -s "$f.$$.tmp" "$f"; then rm -f "$f.$$.tmp"; else mv -f "$f.$$.tmp" "$f"; fi; } + rm -f "$f.$$.tmp" + done + return 0 +} +_st_migrate +_st_sel_meta +_st_sel_remap +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 +} +# Модули ядра, нужные подписке (vpn — VLESS, hy2 — hysteria2, proxy — trojan/ss/socks/http/vmess) +_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 +} + +# Пакеты kmod в OpenWrt жёстко привязаны к ядру: зависимость kernel=<версия>~<хеш конфигурации ядра>. +# Готовые модули собраны SDK официальной сборки, и на прошивке, собранной самостоятельно или +# сторонним сборщиком, хеш другой — apk/opkg отказываются ставить пакет («breaks: …[kernel=…]»), +# хотя при той же версии ядра модуль обычно загружается. Такой модуль ставим в обход проверки, +# а подойдёт ли он к ядру, проверит insmod в _awg_kmod_load. +AWG_KMOD_MANUAL="/etc/zm-awg/kmod-manual" +_awg_kmod_pin_bad() { # $1 — файл kmod-amneziawg; 0 и AWG_KPIN/AWG_KMV — если пакет не принимают только из-за ядра + 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() { # $1 — файл пакета; 2 — модуль собран под другую версию ядра + 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" + # amneziawg-tools зависит от kmod-amneziawg — отмечаем модуль в базе apk пустым виртуальным пакетом + _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 + # хеш ядра у всех источников один (официальный SDK) — перебирать их бесполезно + _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 changed=0 + want="$(for i in $(_st_wifs_every); do { _st_owns "net $i" || _st_wifs_all | grep -qx "$i"; } && echo "$i"; done | tr '\n' ' ' | sed 's/ $//')" + if [ "$(uci -q get "firewall.$ST_WARP_ZONE")" = "zone" ] && [ "$(uci -q get "firewall.$ST_WARP_ZONE.network")" != "$want" ]; then + uci -q delete "firewall.$ST_WARP_ZONE.network" + for i in $want; do uci add_list "firewall.$ST_WARP_ZONE.network=$i"; done + uci commit firewall + /etc/init.d/firewall reload >/dev/null 2>&1 + fi + if [ "$(uci -q get "firewall.$ST_WARP_ZONE")" != "zone" ]; then + uci set "firewall.$ST_WARP_ZONE=zone" + uci set "firewall.$ST_WARP_ZONE.name=$ST_WARP_ZONE" + for i in $want; do uci add_list "firewall.$ST_WARP_ZONE.network=$i"; done + uci set "firewall.$ST_WARP_ZONE.input=REJECT" + uci set "firewall.$ST_WARP_ZONE.output=ACCEPT" + uci set "firewall.$ST_WARP_ZONE.forward=REJECT" + uci set "firewall.$ST_WARP_ZONE.masq=1" + uci set "firewall.$ST_WARP_ZONE.mtu_fix=1" + uci set "firewall.${ST_WARP_ZONE}_fwd=forwarding" + uci set "firewall.${ST_WARP_ZONE}_fwd.src=$(_zm_lan_zone)" + uci set "firewall.${ST_WARP_ZONE}_fwd.dest=$ST_WARP_ZONE" + uci commit firewall + _st_own "fw $ST_WARP_ZONE" + /etc/init.d/firewall reload >/dev/null 2>&1 + fi + _zm_fwd_fix "${ST_WARP_ZONE}_fwd" +} + +_zm_fwd_fix() { + local lz + [ "$(uci -q get "firewall.$1")" = forwarding ] || return 0 + lz="$(_zm_lan_zone)" + [ "$(uci -q get "firewall.$1.src")" = "$lz" ] && return 0 + uci set "firewall.$1.src=$lz" + uci commit firewall + /etc/init.d/firewall reload >/dev/null 2>&1 +} + +_st_peers_drop() { + local p + for p in $(uci -q -X show network | sed -n "s/^network\.\([^.=]*\)=amneziawg_$1\$/\1/p"); do uci -q delete "network.$p"; done +} + +_st_warp_is_ru() { case " $ST_RU_COLOS " in *" $1 "*) return 0 ;; esac; [ "$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 + +# ---- Быстрая разведка точек WARP -------------------------------------------------------------- +# Раньше точки проверялись по одной: молчащая отнимала до 8 с, а запасные порты — до трёх минут. +# Теперь рукопожатие проверяется сразу с несколькими точками на временных интерфейсах zmws1…: тот же +# ключ, что у туннеля (своя сессия у сервера не ломает чужие туннели), те же параметры AmneziaWG и маска +# I1. Ответившие точки идут первыми, остальные — как прежде, в конце. Модуль не даёт создать временный +# интерфейс — код 2, и проверка идёт по одной, как раньше. +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 +} +# УСТРОЙСТВО АДРЕС:ПОРТ… → ответившие АДРЕС:ПОРТ в том же порядке. Код 2 — проверить так нельзя. +_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 k v i="$ST_WARP_IF" + mkdir -p "$ST_RUN" + _awg_conf_kv "$f" > "$kv" + priv="$(_awg_cv "$kv" interface privatekey)"; pub="$(_awg_cv "$kv" peer publickey)" + addr="$(_awg_cv "$kv" interface address)"; ep="$(_awg_cv "$kv" peer endpoint)" + psk="$(_awg_cv "$kv" peer presharedkey)"; keep="$(_awg_cv "$kv" peer persistentkeepalive)"; mtu="$(_awg_cv "$kv" interface mtu)" + case "$ep" in + \[*\]:*) host="${ep%]:*}"; host="${host#[}"; port="${ep##*]:}" ;; + ?*:*) host="${ep%:*}"; port="${ep##*:}" ;; + esac + if [ -z "$priv" ] || [ -z "$pub" ] || [ -z "$addr" ] || [ -z "$host" ] || [ -z "$port" ]; then + rm -f "$kv" + echo "ОШИБКА: в конфиге нет PrivateKey, Address, PublicKey или Endpoint (адрес:порт)" + return 1 + fi + uci -q delete "network.$i" + uci -q delete "network.${i}_peer" + _st_peers_drop "$i" + uci set "network.$i=interface" + uci set "network.$i.proto=amneziawg" + uci set "network.$i.private_key=$priv" + for a in $(echo "$addr" | tr ',' ' '); do + case "$a" in */*) ;; *:*) a="$a/128" ;; *) a="$a/32" ;; esac + uci add_list "network.$i.addresses=$a" + done + uci set "network.$i.mtu=${mtu:-1280}" + _awg_iface_set "$f" "$i" + uci set "network.${i}_peer=amneziawg_$i" + uci set "network.${i}_peer.description=Свой WARP" + uci set "network.${i}_peer.public_key=$pub" + [ -n "$psk" ] && uci set "network.${i}_peer.preshared_key=$psk" + uci add_list "network.${i}_peer.allowed_ips=0.0.0.0/0" + case "$addr" in *:*) uci add_list "network.${i}_peer.allowed_ips=::/0" ;; esac + uci set "network.${i}_peer.route_allowed_ips=0" + uci set "network.${i}_peer.persistent_keepalive=${keep:-25}" + uci set "network.${i}_peer.endpoint_host=$host" + uci set "network.${i}_peer.endpoint_port=$port" + uci commit network + _st_own "net $i" + rm -f "$kv" +} + +_st_warp_park() { + local i + for i in "$@"; do + _st_owns "net $i" || continue + ifdown "$i" >/dev/null 2>&1 + uci -q set "network.$i.auto=0" + done + uci -q commit network + return 0 +} + +_st_warp_own_up() { + local i="$ST_WARP_IF" w=0 col + _st_install_awg || return 1 + _st_awg_loaded || modprobe amneziawg >/dev/null 2>&1 + if [ "$(uci -q get "network.$i.proto")" != amneziawg ]; then + _st_own_restore + [ -s "$ST_WARP_CONF" ] || { echo "ОШИБКА: своего конфига WARP нет — вставьте его на вкладке WARP"; return 1; } + _st_warp_own_iface "$ST_WARP_CONF" || return 1 + ubus call network reload >/dev/null 2>&1 + sleep 2 + fi + _st_warp_zone + uci -q get "network.$i.auto" >/dev/null && { uci -q delete "network.$i.auto"; uci -q commit network; } + _rb_say "Поднимаем свой туннель WARP" + ubus call "network.interface.$i" up >/dev/null 2>&1 + while [ "$w" -lt 25 ]; do + _st_stopped && return 1 + _st_warp_alive_if "$i" && break + [ -d "/sys/class/net/$i" ] || sleep 1 + ping -I "$i" -c 1 -W 1 1.1.1.1 >/dev/null 2>&1 + w=$((w + 1)) + done + if ! _st_warp_alive_if "$i"; then + rm -f "$ST_WARP_UP" "$ST_DIR/warp.colo" + echo "ОШИБКА: сервер из вашего конфига не отвечает — проверьте ключи и Endpoint" + return 1 + fi + col="$(_st_colo_of "$i")" + echo "$i ${col:-?}" > "$ST_WARP_UP" + if [ -n "$col" ]; then echo "$col" > "$ST_DIR/warp.colo"; else rm -f "$ST_DIR/warp.colo"; fi + _st_tgws_warp + _st_warp_geo "$i" + _rb_say "Свой WARP работает: $(uci -q get "network.${i}_peer.endpoint_host"):$(uci -q get "network.${i}_peer.endpoint_port")${col:+, колония $col}" + col="$(_st_geo_line)" + [ -n "$col" ] && _rb_say "Сайты видят WARP как: $col" + col="$(awk '{ print $2; exit }' "$ST_WARP_UP")" + [ -n "$col" ] && _st_warp_is_ru "$col" && _rb_warn "Туннель идёт через российскую колонию $col: заблокированное через неё не откроется" + return 0 +} + +_st_warp_up() { + local repick="$1" n i peer got busy="" busyip="" ru=0 c ready="" w need=0 colos="" alt p + _st_warp_own && { _st_warp_own_up; return; } + _st_install_awg || return 1 + [ "$repick" = repick ] && rm -f "$ST_DIR/warp.ports" + _rb_say "Поднимаем туннели WARP в разных колониях" + # ключи второго и следующих туннелей запрашиваются разом, а не по очереди; вывод — по порядку + 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_USER_DIR/$id.lst" 2>/dev/null | grep -c .), подсетей $(cat "$ST_USER_DIR/$id.pfx" 2>/dev/null | grep -c .)" >&2 + else echo " → «$name»: список из репозитория" >&2; fi + for set in $sets; do + [ -f "$ST_RUN/used.$set" ] && continue + if _st_srs_get "$set"; then + took="$took $set" + f="$ST_DIR/lists/$set" + fmt="$(awk -F'|' -v k="$set" '$1 == k { print $2; exit }' "$ST_CAT_IDX" 2>/dev/null)" + if [ "$fmt" != lst ]; then srs="$srs $f.srs"; continue; fi + [ -s "$f.dom" ] && dom="$dom $f.dom" + [ -s "$f.pfx" ] && pfx="$pfx $f.pfx" + else + if [ -n "$(_rb_svc_field "$id" 3)$(_rb_svc_field "$id" 4)" ]; then _rb_warn "Список $set не скачался или не раскрылся — беру резервный список" >&2 + else _rb_warn "Список «$name» не скачался или не раскрылся — пропускаем его в этот раз" >&2; fi + sets="" + break + fi + done + for set in $took; do [ -n "$sets" ] && touch "$ST_RUN/used.$set"; done + if [ -z "$sets" ]; then + dom="$(_st_pkg_lists "$(_rb_svc_field "$id" 3 | tr ',' ' ')")" + pfx="$(_st_pkg_lists "$(_rb_svc_field "$id" 4 | tr ',' ' ')")" + srs="" + if [ -z "$dom$pfx" ] && [ "$id" != custom ]; then + f="$ST_DIR/lists/$id.base.dom" + mkdir -p "$ST_DIR/lists" + printf '%s,%s\n' "$(_rb_svc_field "$id" 5)" "$(_rb_svc_field "$id" 6)" | tr ',' '\n' | grep -E '^[a-z0-9.-]+\.[a-z]+$' | awk '!s[$0]++' > "$f" + if [ -s "$f" ]; then + _rb_warn "Списки «$name» не скачались — пока беру только основные домены сервиса ($(wc -l < "$f" | tr -d ' ')), позже нажмите «Применить» ещё раз" >&2 + dom="$f" + else + rm -f "$f" + fi + fi + fi + [ "$id" = custom ] && [ -s "$ST_USER_DIR/$id.lst" ] && dom="$ST_USER_DIR/$id.lst" + [ "$id" = custom ] && [ -s "$ST_USER_DIR/$id.pfx" ] && pfx="$ST_USER_DIR/$id.pfx" + [ -n "$srs$dom$pfx" ] || return 0 + printf 'svc\t%s\t%s\n' "$id" "$name" + for f in $srs; do printf 'srs\t%s\n' "$f"; done + for f in $dom; do printf 'dom\t%s\n' "$f"; done + for f in $pfx; do printf 'pfx\t%s\n' "$f"; done +} + +_st_pkg_lists() { + local f + for f in $1; do _rb_list_get "$f" && [ -s "$ZM_LISTS_DIR/$f" ] && echo "$ZM_LISTS_DIR/$f"; done +} + +_st_model() { + local id + rm -f "$ST_RUN"/used.* "$ST_RUN"/srs.*.done + mkdir -p "$ST_RUN" + printf 'lan'; for id in $(_zm_lan_devs); do printf '\t%s' "$id"; done; echo + if _st_use_vpn; then + printf 'vpn\t%s\n' "$ST_SUB" + _st_sub_filter + else + 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 + _st_excl_model + for id; do + _st_svc_lists "$id" + done +} + +_st_spec_apply() { + local m="$ST_RUN/model" + [ $# -gt 0 ] && _rb_say "Скачиваем списки выбранных сервисов" + _st_model "$@" > "$m" + if ! grep -q '^svc ' "$m"; then + if [ $# -gt 0 ] || ! grep -q '^dev all ' "$m"; then rm -f "$m"; echo "ОШИБКА: для выбранных сервисов нет ни одного списка"; return 1; fi + fi + stl_apply "$m" || { rm -f "$m"; return 1; } + grep -v '^\(lan\|warp\|wpick\|vpn\|tun\|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_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_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() { + 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; then + _st_spec_clear + _st_down + _rb_say "Ничего не выбрано — туннель и Steer выключены" + return 0 + fi + case "$(_st_exit)" in + vpn) _st_vpn_zone on ;; + warp) [ "$1" = tunnel_ready ] || _st_warp_resume || _st_warp_up || return 1 ;; + *) + _st_spec_clear + _st_down + _rb_warn "Туннеля нет — подключите WARP или подписку VPN, и выбранные сервисы пойдут через него" + return 0 ;; + esac + local what dn + what="$(_rb_svc_names "$sel")" + dn="$(_st_devs_n all)" + [ "$dn" -gt 0 ] && what="${what:+$what; }весь трафик устройств: $dn" + if _st_use_vpn; then _rb_say "Через VPN ($(_st_sub_label)): $what" + else _rb_say "Через WARP: $what"; fi + [ "$(_st_devs_n byp)" -gt 0 ] && echo " · Мимо Steer ходят устройства: $(_st_devs_n byp)" + _st_engine_ready || return 1 + _st_spec_apply $sel +} + +ST_UP2_FAIL="$ST_DIR/engine2.fail" +_st_legacy_check() { + stl_present && ! stl_v2 || return 0 + _st_installed && _st_owns "engine" || return 0 + _st_owns "pkg steer" || _st_owns "pkg steer-extended" || return 0 + [ -z "$(_st_blocker)" ] || return 0 + _st_running && return 0 + _zm_busy_job >/dev/null 2>&1 && return 0 + [ -f "$ST_UP2_FAIL" ] && [ -z "$(find "$ST_UP2_FAIL" -mmin +10 2>/dev/null)" ] && return 0 + job_start steer do_steer_engine2 >/dev/null 2>&1 + return 0 +} + +do_steer_engine2() { + _st_phase pkgs + rm -f "$ST_STOP_FLAG" + _rb_say "Ядро steer $(stl_version) устарело — переводим на 2.x" + rm -f "$STL_CACHE" + if ! _st_install_steer; then + mkdir -p "$ST_DIR"; touch "$ST_UP2_FAIL" + _rb_warn "Ядро steer 2.x сейчас не поставить — прежнее работает, попробуем позже" + return 1 + fi + rm -f "$ST_UP2_FAIL" + if _st_owns "steer-spec" && [ ! -f "$ST_OFF" ] && _st_work; then + _st_phase rules + _st_apply || return 1 + fi + _rb_say "Готово, ядро steer $(stl_version)" +} + +_st_engine_ready() { + stl_v2 && { ! _st_use_vpn || _st_feats_ok $(_st_sub_feats); } && return 0 + _st_install_steer $(_st_use_vpn && _st_sub_feats) +} + +_st_failopen() { stl_state "$(_st_exit)" && [ "$STL_FAILED" = true ]; } + +# Самопочинка по итогам самодиагностики ядра: сначала перезапуск (клиенты протоколов поднимаются не сразу — +# ждём до 30 секунд), не помогло — переустановка той же версии ядра с нужными модулями (правила остаются). +# Переустановка — не чаще раза в 30 минут, чтобы при поломке, которую так не исправить, не качать ядро по кругу. +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() { + local f="$ST_RUN/diag.json" n i v what why bad=0 trace healed="" did="" vdead="" core="" moved="" + _rb_say "Проверяем, что всё работает" + if _st_work && _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 && _st_warp_on; then + trace="$(curl -s --interface "$(_st_warp_first)" --connect-timeout 5 --max-time 10 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null)" + case "$trace" in + *warp=on*|*warp=plus*) echo "[ OK ] Туннель WARP: трафик идёт через Cloudflare ($(echo "$trace" | sed -n 's/^colo=//p'))" ;; + *ip=*) + if _st_warp_own; then + local tip dip + tip="$(echo "$trace" | sed -n 's/^ip=//p')" + dip="$(curl -s --connect-timeout 5 --max-time 10 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null | sed -n 's/^ip=//p')" + if [ -n "$dip" ] && [ "$tip" = "$dip" ]; then echo "[FAIL] Свой туннель: выход $tip совпадает с IP провайдера — конфиг не пускает трафик в интернет"; bad=1 + else echo "[ OK ] Свой туннель: трафик идёт (выход $tip${dip:+, провайдер $dip})"; fi + else echo "[FAIL] Туннель WARP: трафик идёт мимо Cloudflare WARP"; bad=1; fi ;; + *) echo "[FAIL] Туннель WARP: трафик через него не идёт"; bad=1 ;; + esac + stl_running && echo "[ OK ] Служба Steer запущена" || { echo "[FAIL] Служба Steer не запущена"; bad=1; } + fi + _st_owns "steer-spec" && stl_v2 || return $bad + if _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" + # Поломки ядра (служба, правила, DNS) — отдельно от отказа узла: узел не лечится ни перезапуском, + # ни переустановкой ядра, а починка по кругу только качала бы ядро заново. + 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_present && { echo "ОШИБКА: стоит Forkozz — он сам направляет сервисы в туннель. Удалите Forkozz, чтобы поставить Steer"; return 1; } + case "$blk" in + '') ;; + splify2) echo "ОШИБКА: установлен splify2 — туннели и списки настраиваются в нём"; return 1 ;; + steer) echo "ОШИБКА: у ядра steer уже есть чужие правила — не перезаписываем их"; return 1 ;; + *) echo "ОШИБКА: $blk"; return 1 ;; + esac + _ensure_deps + mkdir -p "$ST_DIR" + _st_phase pkgs + _st_install_steer || return 1 + if ! command -v conntrack >/dev/null 2>&1; then + $INSTALL conntrack >&2 && _st_own "pkg conntrack" + fi + _st_own "engine" + rm -f "$ST_OFF" + if [ "$(_st_exit)" != none ] && _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_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_auto() { + _st_phase awg + rm -f "$ST_STOP_FLAG" + _st_installed || { echo "ОШИБКА: сначала установите Steer"; return 1; } + [ -n "$(_st_blocker)" ] && { echo "ОШИБКА: ядро steer сейчас настраивает не Zapret Manager"; return 1; } + _ensure_deps + _st_install_awg || return 1 + _st_phase tunnel + _rb_say "Автоматический WARP: три туннеля с ключами от Cloudflare" + _st_owns "net $ST_OWN_IF" && _rb_say "Свой туннель $ST_OWN_IF выключаем — он сохранится, вернуться к нему можно в один клик" + rm -f "$ST_WARP_MODE" + _st_mode_vars + _st_warp_park "$ST_OWN_IF" + if [ "$(uci -q get "network.zmwarp_peer.description")" = "Свой WARP" ]; then + ifdown zmwarp >/dev/null 2>&1 + uci -q delete network.zmwarp; uci -q delete network.zmwarp_peer; uci commit network + rm -f "$ST_DIR/warp.conf" "$ST_WARP_UP_AUTO" + fi + rm -f "$ST_WARP_UP" + [ -s "$ST_WARP_UP_AUTO" ] && mv -f "$ST_WARP_UP_AUTO" "$ST_WARP_UP" + if _st_warp_resume; then + awk '{ printf "%s%s", (NR > 1 ? ", " : ""), $2 }' "$ST_WARP_UP" > "$ST_DIR/warp.colo" + _st_tgws_warp + else + _st_warp_up || return 1 + fi + _st_cron_refresh + [ "$(cat "$ST_EXIT" 2>/dev/null)" = vpn ] && [ -s "$ST_SUB" ] || echo warp > "$ST_EXIT" + if [ ! -f "$ST_OFF" ] && _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 — замените его на вкладке 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 ']}' +} + +# Смена узла, фильтра узлов или скрытия — меняется только выход VPN. Списки сервисов уже скачаны и +# стоят в ядре, поэтому каталог и списки не трогаем: спека собирается из запомненного состава, и ядро +# steer перезапускает один туннель (резолвер, наборы адресов и правила остаются). Нет запомненного +# состава или файла из него — полное применение, как раньше. +_st_spec_quick() { + local m="$ST_RUN/model" f id + [ -f "$ST_DIR/model.svc" ] && _st_owns "steer-spec" && stl_running && _st_use_vpn || 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_engine_ready || return 1 + _st_vpn_zone on + mkdir -p "$ST_RUN" + { + printf 'lan'; for id in $(_zm_lan_devs); do printf '\t%s' "$id"; done; echo + printf 'vpn\t%s\n' "$ST_SUB" + _st_sub_filter + _st_dev_model + _st_excl_model + cat "$ST_DIR/model.svc" + } > "$m" + stl_apply "$m"; f=$? + rm -f "$m" + return $f +} + +_st_spec_quick_warp() { + local m="$ST_RUN/model" f id + [ -f "$ST_DIR/model.svc" ] && _st_owns "steer-spec" && stl_running && ! _st_use_vpn && [ -s "$ST_WARP_UP" ] || 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_engine_ready || return 1 + mkdir -p "$ST_RUN" + { + printf 'lan'; for id in $(_zm_lan_devs); do printf '\t%s' "$id"; done; echo + printf 'wpick\t%s\n' "$(_st_warp_pick)" + printf 'warp'; for id in $(_st_warp_use); do printf '\t%s' "$id"; done; echo + _st_dev_model + _st_excl_model + cat "$ST_DIR/model.svc" + } > "$m" + stl_apply "$m"; f=$? + rm -f "$m" + return $f +} + +_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_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 'автовыбор'; } + +# ПРЕЖНИЙ — что было выбрано до смены: имя узла, «auto» (автовыбор) или «-» (не откатывать) +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 ещё не подключён — подключите его на вкладке WARP" + return 1 +} + +do_steer_warp_restart() { + _st_phase warp + _st_need_warp || return 1 + rm -f "$ST_STOP_FLAG" + _rb_say "Перезапускаем туннель WARP" + _st_warp_up || return 1 + _st_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 + 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 + # на место вернулись правила, которые были у ядра до панели, — такое ядро не наше + [ "$(stl_spec_whose)" = foreign ] && alien=1 + fi + local wi netrl=0 + : > "$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 + done + [ "$netrl" = 1 ] && { uci commit network; /etc/init.d/network reload >/dev/null 2>&1; } + [ -n "$keptif" ] && _rb_warn "Туннели$keptif оставлены — они записаны в правилах ядра steer, которые ведёт $foreign" + 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 + 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 + 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 + # Ядром больше никто не пользуется — убираем его целиком, даже если оно уже стояло до «Установить» + # или запись о том, что его ставила панель, потерялась. Иначе Steer «удалён», а ядро остаётся на роутере. + 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" || rc=1 + fi + rm -f "$keepf" + [ -n "$foreign" ] || stl_leftovers + if [ "$rc" != 0 ]; then + # страница должна по-прежнему показывать Steer с кнопкой «Удалить», а не «не установлен» + [ -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() { + local running=false phase="" blk colo="" warp_up=false installed=false ver="" run=false chans=0 dns=false + local host="" port="" hs="" age="" rx=0 tx=0 off=false svc="" sep="" id sel=" " skip w k r + _st_legacy_check + _st_running && running=true + [ -f "$ST_PHASE_FILE" ] && phase=$(cat "$ST_PHASE_FILE") + blk="$(_st_blocker)" + _st_installed && installed=true + [ -z "$blk" ] && [ "$installed" = false ] && _fk_present && blk=forkozz + [ -f "$ST_OFF" ] && off=true + if [ -s "$ST_WARP_UP" ]; then + colo=$(while read -r w k; do [ -d "/sys/class/net/$w" ] && printf '%s\n' "$k"; done < "$ST_WARP_UP" | tr '\n' ',' | sed 's/,$//; s/,/, /g') + [ -n "$colo" ] && warp_up=true + elif [ -f "$ST_DIR/warp.colo" ]; then + colo=$(cat "$ST_DIR/warp.colo") + [ -d "/sys/class/net/$ST_WARP_IF" ] && warp_up=true + fi + if _st_owns "net $ST_WARP_IF"; then + host="$(uci -q get "network.${ST_WARP_IF}_peer.endpoint_host")" + port="$(uci -q get "network.${ST_WARP_IF}_peer.endpoint_port")" + fi + if [ "$warp_up" = true ] && command -v awg >/dev/null 2>&1; then + for w in $(_st_wifs_all); do + [ -d "/sys/class/net/$w" ] || continue + k=$(awg show "$w" latest-handshakes 2>/dev/null | awk '{print $2; exit}') + [ "${k:-0}" -gt "${hs:-0}" ] 2>/dev/null && hs="$k" + set -- $(awg show "$w" transfer 2>/dev/null | head -n1) + rx=$((rx + ${2:-0})); tx=$((tx + ${3:-0})) + done + [ -n "$hs" ] && [ "$hs" != 0 ] && age=$(( $(date +%s) - hs )) + fi + if stl_present; then + ver="$(stl_version)" + stl_running && run=true + _st_owns "steer-spec" && chans="$(stl_rules)" + fi + _st_dns_conflict && dns=true + local doh="" dohn=0 u + if [ -x /etc/init.d/https-dns-proxy ] && [ ! -f "$DOH_OFF_FLAG" ] && /etc/init.d/https-dns-proxy running >/dev/null 2>&1; then + while u="$(uci -q get "https-dns-proxy.@https-dns-proxy[$dohn].resolver_url")"; do + dohn=$((dohn + 1)); [ "$dohn" = 1 ] && doh="$(_doh_provider_of "$u")"; [ "$dohn" -gt 16 ] && break + done + [ "$dohn" -gt 1 ] && doh=multi + [ "$dohn" -ge 1 ] && [ -z "$doh" ] && doh=other + fi + _st_cat_bg + [ -n "$(_st_wfix_min)" ] && _st_wfix_cron + sel=" $(_st_sel | tr '\n' ' ') " + local cust=0 + [ -s "$ST_USER_DIR/custom.lst" ] && cust=1 + # dom — есть ли в списке пункта домены (значок «≡» на карточке): известное число из $ST_DOMCNT, иначе по + # источникам — у наборов каталога с одними подсетями («s» в индексе) доменов нет + 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="" + 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 + _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_pick":"%s","onfail":"%s","warp_own_saved":%s,"tunnels":%s,"warp_active":"%s","catalog":%s,"wfix":%s,"lists_via":%s,"failopen":%s,"hosts_extra":%s,"devices":[%s],"services":[%s]}\n' \ + "$running" "$(esc "$phase")" "$(esc "$blk")" "$installed" "$off" "$(esc "$ver")" "$run" "${chans:-0}" "$warp_up" "$(esc "$colo")" \ + "$(esc "$host")" "$(esc "$port")" "$age" "${rx:-0}" "${tx:-0}" "$(_st_cron_get)" "$dns" "$doh" "$(_doh_force_mode)" \ + "$vexit" "$vup" "$vlive" "$vsub" "$(esc "$(_st_sub_label)")" "$(esc "$latest")" "$ext" "$won" "$(_st_warp_own && echo own || echo auto)" "$(_st_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" "$(_hosts_extra)" "$(_st_devs_json)" "$svc" +} + +_st_active_if() { + [ "$(_st_exit)" = vpn ] && return 0 + _st_owns "steer-spec" && stl_running && stl_state warp || return 0 + [ "$STL_FAILED" = true ] || printf '%s' "$STL_DEV" +} + +_st_tunnels_json() { + local n=1 i up hs age rx tx host port colo out="" sep="" seen city cc pos isru + while [ "$n" -le "$ST_WARP_N" ]; do + i="$(_st_wif "$n")" + if _st_owns "net $i"; then + up=false; age=""; rx=0; tx=0 + [ -d "/sys/class/net/$i" ] && up=true + host="$(uci -q get "network.${i}_peer.endpoint_host")" + port="$(uci -q get "network.${i}_peer.endpoint_port")" + colo="$(awk -v i="$i" '$1 == i {print $2; exit}' "$ST_WARP_UP" 2>/dev/null)" + if [ "$up" = true ] && command -v awg >/dev/null 2>&1; then + hs=$(awg show "$i" latest-handshakes 2>/dev/null | awk '{print $2; exit}') + [ "${hs:-0}" -gt 0 ] 2>/dev/null && age=$(( $(date +%s) - hs )) + set -- $(awg show "$i" transfer 2>/dev/null | head -n1) + rx="${2:-0}"; tx="${3:-0}" + fi + seen=""; city=""; cc="" + set -- $(_st_geo_get "$i") + if [ $# -ge 4 ] && { [ "$1" = "$colo" ] || [ -z "$colo" ] || [ "$colo" = "?" ]; }; then + [ -n "$colo" ] && [ "$colo" != "?" ] || colo="$1" + seen="$2"; cc="$3"; shift 3; city="$*" + [ "$seen" = - ] && seen=""; [ "$cc" = - ] && cc=""; [ "$city" = - ] && city="" + fi + 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_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 + if [ -s "$ST_USER_DIR/$id.lst" ] || [ -s "$ST_USER_DIR/$id.pfx" ]; then ST_LIST_SRC=user; cat "$ST_USER_DIR/$id.lst" "$ST_USER_DIR/$id.pfx" 2>/dev/null; return 0; fi + for set in $(_rb_svc_field "$id" 8 | tr ',' ' '); do + f="$ST_DIR/lists/$set.dom" + [ -s "$f" ] && { cat "$f"; any=1; } + done + if [ "$any" = 1 ]; then ST_LIST_SRC=sets; return 0; fi + ST_LIST_SRC=package + for f in $(_rb_svc_field "$id" 3 | tr ',' ' '); do _rb_list_get "$f" 2>/dev/null && [ -s "$ZM_LISTS_DIR/$f" ] && cat "$ZM_LISTS_DIR/$f"; done +} + +# Списки, которые правит человек: «Свой список» (custom — через Steer) и «Исключения» (exclude — всегда напрямую). +_st_user_list() { [ "$1" = exclude ] || { [ "$1" = custom ] && _rb_routable "$1"; }; } + +# Домены сервиса для просмотра (значок «≡» у карточки на вкладке «Сервисы»). Источники — те же, что берёт +# применение (_st_svc_lists): наборы каталога, а если их нет или один не скачался — списки пакета, а без них — +# основные домены сервиса; у «Своего списка» — ваши строки. Показываем только домены: подсети и адреса +# отбрасываются. Наборы .srs раскладывает в текст ядро (steer srs-read) — тот же кэш, что у «Куда пойдёт +# запрос». Пока идёт применение, ничего не качаем — берём то, что уже на роутере. Число доменов запоминаем +# в $ST_DOMCNT: карточка, у которой в списке одни подсети, значка не показывает. +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 + }' +} +# Набор .srs ($1) в текст: домены в $2, подсети в $3. Раскладывает ядро steer, а без него (стоит только +# Forkozz) — sing-box: rule-set decompile отдаёт JSON, из него берём domain/domain_suffix и ip_cidr. +_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 +} + +# $1 — сервис, $2 — итоговый файл. В $ST_DOM_SRC — откуда взяты домены (для подписи). +_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_USER_DIR/custom.lst" ] && echo "$ST_USER_DIR/custom.lst" >> "$src" + [ -s "$ST_USER_DIR/custom.pfx" ] && echo "$ST_USER_DIR/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" + # подсети и адреса IP списка (IPv4 и IPv6, с маской или без, диапазоны): $out.ips — сами строки (для + # «Скачать .txt»), $out.ipn — их число (для подписи панели) + 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" +} + +# Есть ли в списке пункта домены — для значка «≡» на карточках Forkozz (у Steer это поле dom в его статусе): +# известное число из $ST_DOMCNT, иначе по источникам; у наборов каталога с одними подсетями доменов нет. +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#*:}" + # четвёртое поле «ip» — отдать не домены, а IP-подсети списка (для «Скачать .txt») + 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" + # файл моложе минуты берём как есть: «Скачать .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' ' ')" +} + +# «Найти сайт» на вкладке «Сервисы»: в каких пунктах есть домен (вместе с поддоменами) или его адрес IPv4 +# (подсети — так находятся и пункты b4geoip). Смотрим только то, что уже лежит на роутере, чтобы ответ был +# быстрым; наборы, которых ещё нет, докачиваются в фоне, а в ответе — сколько их ждём (missing), и панель +# повторяет поиск. Адрес берём у DNS роутера, а если там поддельный адрес Steer (198.18.0.0/15) — у внешнего. +_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 +} + +# адрес $1 в подсетях файлов $2…: печатает сработавшую строку (самую узкую) +_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 | 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" + 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_USER_DIR/$id.lst" 2>/dev/null | grep -c .)" "$(cat "$ST_USER_DIR/$id.pfx" 2>/dev/null | grep -c .)" "$(esc_ml "$body")" +} + +steer_list_set() { + local id="${1%%|*}" body="${1#*|}" f tmp n + case "$1" in *'|'*) ;; *) echo '{"error":"нет списка"}'; return 1 ;; esac + _st_user_list "$id" || { echo '{"error":"редактируется только свой список или исключения"}'; return 1; } + mkdir -p "$ST_USER_DIR" + f="$ST_USER_DIR/$id.lst"; tmp="$f.tmp" + printf '%s\n' "$body" | tr -d '\r' | tr 'A-Z' 'a-z' | tr ',;' '\n\n' | + sed 's/[[:space:]]*#.*$//; s/^[[:space:]]*//; s/[[:space:]]*$//; s#^[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" "$ST_USER_DIR/$id.pfx"; else rm -f "$tmp.pfx" "$ST_USER_DIR/$id.pfx"; fi + _st_list_changed "$id" "$n" +} + +steer_list_reset() { + _st_user_list "$1" || { echo '{"error":"редактируется только свой список или исключения"}'; return 1; } + rm -f "$ST_USER_DIR/$1.lst" "$ST_USER_DIR/$1.pfx" + if [ "$1" = custom ]; then + if _rb_in custom "$ST_SEL"; then + sed -i '/^custom$/d' "$ST_SEL" + if _st_installed && [ ! -f "$ST_OFF" ] && [ -z "$(_st_blocker)" ]; then + job_start steer do_steer_apply + return + fi + fi + printf '{"ok":true,"saved":true,"count":0}\n' + return + fi + _st_list_changed "$1" 0 +} + +_st_list_changed() { + if [ "$1" = exclude ]; then + # исключения действуют без выбора в списке сервисов: применяем, если Steer уже ведёт правила + 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 "$1" "$ST_SEL"; then + job_start steer do_steer_apply + else + printf '{"ok":true,"saved":true,"count":%s}\n' "$2" + fi +} + +_st_sel_set() { + local want id + want=" $(echo "$1" | tr ',' ' ') " + for id in $want; do + case "$id" in *[!a-z0-9_-]*) echo '{"error":"неизвестный сервис"}'; return 1 ;; esac + [ -n "$(_rb_svc_field "$id" 1)" ] && _rb_routable "$id" || { echo '{"error":"неизвестный сервис"}'; return 1; } + [ "$id" = custom ] && [ ! -s "$ST_USER_DIR/custom.lst" ] && [ ! -s "$ST_USER_DIR/custom.pfx" ] && { echo '{"error":"свой список пуст — сначала добавьте в него домены или IP"}'; return 1; } + done + mkdir -p "$ST_DIR" + touch "$ST_SKIP" + for id in $(_st_sel); do case "$want" in *" $id "*) ;; *) _rb_in "$id" "$ST_SKIP" || echo "$id" >> "$ST_SKIP" ;; esac; done + for id in $want; do sed -i "/^$id\$/d" "$ST_SKIP"; done + printf '%s\n' $want | grep . > "$ST_SEL" + [ -s "$ST_SKIP" ] || rm -f "$ST_SKIP" + return 0 +} + +ST_VPN_OUT="$STL_VPN_DEV" +ST_VPN_ZONE="zmvpn" +ST_SUB="$ST_DIR/sub.txt" +ST_SUB_INFO="$ST_DIR/sub.userinfo" +ST_SUB_URL="$ST_DIR/sub.url" +ST_SUB_TITLE="$ST_DIR/sub.title" +ST_SUB_NODE="$ST_DIR/sub.node" +ST_SUB_EXCL="$ST_DIR/sub.exclude" +ST_SUB_HIDE="$ST_DIR/sub.hidden" +ST_EXIT="$ST_DIR/exit" + +_st_exit() { + local e + e="$(cat "$ST_EXIT" 2>/dev/null)" + if [ "$e" = vpn ] && [ -s "$ST_SUB" ] && _st_vpn_ok; then echo vpn; return; fi + if _st_warp_on; then echo warp; return; fi + if [ -s "$ST_SUB" ] && _st_vpn_ok; then echo vpn; return; fi + echo none +} +_st_use_vpn() { [ "$(_st_exit)" = vpn ]; } + +ST_VPN_PROBE="$JOBS_DIR/steer.vpnprobe" +_st_vpn_probe() { + local trace ip="" loc="" r=fail 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" +} + +# Список узлов подписки (кэш рядом с ней): путь к TSV. Подписку, сохранённую до 2.18 (имена в +# процентах, без карты строк), один раз нормализуем. +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" +} +# Есть ли узел с таким именем в списке ФАЙЛ (awk читает файл целиком: «cut | grep -q» ронял cut +# в «Broken pipe», когда grep выходил на первом совпадении) +_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"; } + +# Выбор и скрытие узлов хранятся по имени. Имена, записанные до 2.18, ядро обрезало — переводим их на +# полные: закреплённый узел — если такое начало у одного узла, скрытые — на все узлы с таким началом. +_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 +} + +# Строки «tun ПРОТОКОЛ УЗЛЫ МАРКЕРЫ» для спеки: закреплённый узел — один протокол и один номер; +# иначе каждый протокол подписки, где остались видимые узлы, в порядке подписки. +_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 + # shellcheck disable=SC2086 + 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" +} + +# Узел, который сейчас несёт трафик VPN, — именем из списка подписки (после stl_state vpn) +_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 '}' +} +# Устройство, через которое сейчас идёт VPN (у группы — туннель живого протокола) +_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 +} +# Запрос самого роутера через туннель: ответы приходят через TUN, а маршрут к их источнику ведёт в +# WAN, и строгий rp_filter их отбрасывал — рабочий туннель выглядел мёртвым. Мягкий режим — только +# на устройстве туннеля; адрес проверки — IP, без DNS. +_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 $STL_VPN_DEVS; 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 $STL_VPN_DEVS; 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 + 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}" + # Ядро 2.0 создаёт устройство туннеля сразу и рапортует «поднят» ещё до ответа узла; у подписки с + # несколькими протоколами устройство — того протокола, что жив. Ждём, пока ядро назовёт рабочее + # устройство (перебор узлов — до 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 + # узел уже признан мёртвым (node_down) или перебор кончился — ждать дальше нечего + [ "$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 + # Вердикт — по свежему состоянию ядра, а не по первому «поднят»: мёртвый закреплённый узел + # выглядел как «туннель поднят, но трафик не идёт». Потерю узла ядро называет полем node_down. + 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 +} +# Модули ядра для протоколов подписки ФАЙЛ (hysteria2 и прокси ставятся отдельными пакетами) +_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.new, поставить модули, проверить узлы +_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_DIR/sub.pending" in + rm -f "$ST_STOP_FLAG" + _st_phase sub + [ -s "$f" ] || { echo "ОШИБКА: нет ссылки"; return 1; } + in="$(cat "$f")" + rm -f "$f" + _st_need_ext || return 1 + case "$in" in + http://*|https://*) + _rb_say "Скачиваем подписку" + _st_sub_fetch "$(printf '%s' "$in" | tr -d ' \r\n\t')" || return 1 + ;; + *) + printf '%s\n' "$in" | tr ' \t\r' '\n\n\n' | grep -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_DIR/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" +# Если закреплённый (выбранный вручную) узел не отвечает: back — перейти на рабочий и вернуться на выбранный, +# когда он снова ответит (по умолчанию); switch — перейти на рабочий; hold — держать выбранный, ничего не делать. +# ST_SUB_HOME — узел, на который вернуться (только для back): проверяем его раз в 10 минут. +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 "выбранные сервисы идут напрямую"; } +# Ядро уже назвало причину: узел не отвечает (node_down), перебор не нашёл живого или узла нет в подписке. +# Смотрит на STL_* после stl_state vpn. +_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 +} + +# Закреплённый узел не отвечает — поступаем по настройке. 0 — VPN снова работает (через другой узел), +# 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_DIR" + 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 +} + +# «Вернуться на выбранный»: раз в 10 минут проверяем запомненный узел запросом через него; ответил — +# возвращаемся на него операцией (как при ручном выборе). Пользователь сам выбрал узел — возвращаться некуда. +_st_home_tick() { + local home cur t i st="$JOBS_DIR/steer.hometick" + 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_DIR" + 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 || { 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"; 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" 2>/dev/null; then + cp -f "$ST_SUB_NODE" "$JOBS_DIR/steer.holdlog" 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" + echo warp > "$ST_EXIT" + _st_vpn_zone off + _st_sub_auto_set off >/dev/null + _st_ping_cron + if _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_DIR" + printf '%s\n' "$mode" > "$ST_DIR/sub.pending" + chmod 600 "$ST_DIR/sub.pending" + job_start steer do_steer_sub_set + ;; + sub_links) + _st_installed || { echo '{"error":"сначала установите Steer"}'; return 1; } + [ -s "$ST_SUB_URL" ] && { echo '{"error":"сейчас подключена подписка по ссылке — свои серверы можно задать, удалив её"}'; return 1; } + printf '%s\n' "$mode" | tr ' \t\r' '\n\n\n' | grep -qiE "$STL_LINK_RE" || { echo '{"error":"нет ни одной ссылки на сервер"}'; return 1; } + mkdir -p "$ST_DIR" + printf '%s\n' "$mode" > "$ST_DIR/sub.pending" + chmod 600 "$ST_DIR/sub.pending" + job_start steer do_steer_sub_links + ;; + sub_update) + [ -s "$ST_SUB_URL" ] || { echo '{"error":"обновлять нечего"}'; return 1; } + job_start steer do_steer_sub_update + ;; + sub_remove) + [ -s "$ST_SUB" ] || { echo '{"error":"подписки нет"}'; return 1; } + job_start steer do_steer_sub_remove + ;; + sub_exit) + case "$mode" in warp|vpn) ;; *) echo '{"error":"неизвестный выход"}'; return 1 ;; esac + [ "$mode" = vpn ] && [ ! -s "$ST_SUB" ] && { echo '{"error":"сначала добавьте подписку"}'; return 1; } + [ "$mode" = warp ] && ! _st_warp_on && { echo '{"error":"сначала подключите WARP"}'; return 1; } + mkdir -p "$ST_DIR" + echo "$mode" > "$ST_EXIT" + [ "$mode" = warp ] && _st_vpn_zone off + if _st_installed && [ ! -f "$ST_OFF" ] && [ -z "$(_st_blocker)" ] && _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_DIR/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_DIR/sub.exclude.try" "$ST_SUB_HIDE" | grep -c .)" + rm -f "$ST_DIR/sub.exclude.try" + [ "$total" -gt 0 ] && [ "$hid" -ge "$total" ] && { echo '{"error":"под эти маркеры попадают все узлы подписки — тогда VPN работать не сможет. Уберите лишние маркеры"}'; return 1; } + fi + err="$(_zm_excl_save "$ST_SUB_EXCL" "$m")" || { printf '{"error":"%s"}\n' "$(esc "$err")"; return 1; } + if [ -s "$ST_SUB_NODE" ] && _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_DIR" + if [ "$(printf '%s' "$mode" | jsonfilter -e '@.reset' 2>/dev/null)" = true ]; then + rm -f "$ST_SUB_HIDE" "$ST_SUB_EXCL" + else + case "$mode" in *'"markers":'*|*'"markers" :'*) + mk="$(printf '%s' "$mode" | jsonfilter -e '@.markers' 2>/dev/null)" + xf="$ST_DIR/sub.exclude.try.$$" + err="$(_zm_excl_save "$xf" "$mk")" || { printf '{"error":"%s"}\n' "$(esc "$err")"; return 1; } + ;; + esac + printf '%s' "$mode" | jsonfilter -e '@.names[*]' > "$nf" 2>/dev/null + tsv="$(_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_DIR/sub.exclude.try.$$"; echo '{"error":"нельзя скрыть все узлы — тогда VPN работать не сможет"}'; return 1; fi + if [ "$xf" != "$ST_SUB_EXCL" ]; then + if [ -s "$xf" ]; then mv -f "$xf" "$ST_SUB_EXCL"; else rm -f "$xf" "$ST_SUB_EXCL"; fi + fi + _zm_names_save "$nf" "$ST_SUB_HIDE" + rm -f "$nf" + if [ -s "$ST_SUB_NODE" ] && printf '%s\n' "$tsv" | ZM_W="$(cat "$ST_SUB_NODE")" awk -F'\t' '$2 == ENVIRON["ZM_W"]' | _zm_excl_hits "$ST_SUB_EXCL" "$ST_SUB_HIDE" | grep -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_DIR" + 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_DIR" + 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 +} + +# Узлы для интерфейса: JSON из списка подписки (TSV) и сводки модулей +_st_nodes_json() { + awk -F'\t' -v meta="$ST_SUB_NODES.meta" ' + function j(v) { gsub(/\\/, "\\\\", v); gsub(/"/, "\\\"", v); return v } + 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='"' '{ n = $1; gsub(/\\/, "\\\\", n); gsub(/"/, "\\\"", n); 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='"' '$3 == "b" { n = $1; gsub(/\\/, "\\\\", n); gsub(/"/, "\\\"", n); printf "%s%s%s%s", (c++ ? "," : ""), q, n, q }' "$ST_SUB_PING")" \ + "$([ -s "$ST_SUB_PING" ] && awk -F'\t' -v q='"' '$3 == "t" { n = $1; gsub(/\\/, "\\\\", n); gsub(/"/, "\\\"", n); printf "%s%s%s%s", (c++ ? "," : ""), q, n, q }' "$ST_SUB_PING")" \ + "$(esc "$(head -n1 "$ST_SUB_HOME" 2>/dev/null)")" "$(_st_node_pol)" +} + +# TCP-пинг узла (как «TCP» в Happ): время установки соединения с сервером, без туннеля. Хост и порт +# берём из вывода модулей ядра steer (кэш рядом со списком узлов). Hysteria2 работает по UDP — по TCP +# его не проверить, для него остаётся проверка ядром. +_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" +} + +# Пачка узлов: «1,5,7» → {"results":{"1":{"ok":true,"ms":42},"5":{"ok":false},"7":{"core":true}}}. +# Все узлы пачки проверяются одновременно; кто не ответил за 3 с, проверяется ещё раз с запасом в 6 с — +# «нет ответа» только после обоих проходов. core — узел по TCP не проверить (Hysteria2): его проверит ядро. +steer_sub_tcp() { + local t dir list="" i p f hp h port 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" +} + +# ---- Автовыбор узла по пингу (как «Авто · По пингу» в Forkozz) --------------------------------- +# Роутер сам замеряет пинг всех видимых узлов подписки и отдаёт ядру steer узлы по скорости: +# в спеке туннель берёт первый рабочий по порядку nodes, так что работает самый быстрый, а если он +# оборвётся — ядро само перейдёт на следующий по скорости. Раз в ИНТЕРВАЛ замер повторяется; с рабочего +# узла уходим, только если другой быстрее больше чем на ДОПУСК мс — без прыжков туда-сюда. +# Ручной выбор узла важнее: пока он закреплён, порядок по пингу не применяется. +ST_SUB_PICK="$ST_DIR/sub.pick" +# Как проверять узлы: «fast» (по умолчанию) — только TCP; «real» — сначала TCP, затем настоящий запрос через +# узел (проверка ядра steer). Так видно узлы, у которых порт открыт, а трафик не идёт. +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_ping_cron() { + local iv spec want="" had="" + grep -qF "$ST_PING_TAG" "$CRON_FILE" 2>/dev/null && had=1 + if [ "$(_st_pick)" = ping ] && [ -s "$ST_SUB" ] && _st_installed; then + iv="$(_st_pick_iv)" + 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_SUB_PING строки «имямс[как]» (0 — не ответил, -1 — не мерили; +# как: r — проверен запросом через узел, t — только TCP, b — порт открыт, а запрос через узел не прошёл). +# TCP-узлы — время соединения с сервером (две попытки, затем ещё одна с запасом); Hysteria2 по TCP +# не проверить — первые шесть из них проверяет ядро steer. +_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 + # «через узел»: восемь самых быстрых по TCP проверяем настоящим запросом через сам узел (ядро steer). + # Прошёл — его задержка настоящая (r); порт открыт, а запрос не прошёл — узел заблокирован (b, в конец); + # остальные живые по TCP идут после проверенных (t). + 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" ] +} + +# Порядок узлов для ядра по замеру: живые по возрастанию пинга, затем не мерянные, затем молчащие. +# ТЕКУЩИЙ (узел, на котором стоим) остаётся первым, пока он не медленнее лучшего больше чем на допуск. +# Код 0 — первый узел сменился (надо применить), 1 — нет. +_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 +} + +# Плановый замер (cron): тихо, без операции в панели; переключение — только если сменился первый узел +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() { + local rc + _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 +} + +steer_action() { + local action="$1" mode="$2" w + 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_pick|onfail) + _st_running && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } + [ "$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_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 ;; + 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) + [ -s "$ST_SUB_URL" ] || return 0 + _st_running && return 0 + job_start steer do_steer_sub_update auto >/dev/null + ;; + sub_probe) steer_sub_probe "$mode" ;; + sub_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; 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_owns "net $wi" && [ -d "/sys/class/net/$wi" ]; then + trace="$(curl -s --interface "$wi" --connect-timeout 4 --max-time 8 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null)" + wc="$(echo "$trace" | sed -n 's/^colo=//p')" + wv="" + case "$trace" in + *warp=on*|*warp=plus*) wv=on ;; + *ip=*) _st_warp_own && wv=on ;; + esac + gs=""; gt="" + if [ "$wv" = on ]; then + warp=on; [ -n "$colo" ] || colo="$wc" + _st_warp_geo "$wi" + set -- $(_st_geo_get "$wi") + if [ $# -ge 4 ]; then + [ -n "$wc" ] || wc="$1" + gs="$2"; shift 3; gt="$*" + [ "$gs" = - ] && gs=""; [ "$gt" = - ] && gt="" + fi + else wc="$(_st_colo_of "$wi")"; [ -n "$wc" ] && wv=notls || wv=off; fi + tun="$tun$tsep{\"n\":$wn,\"warp\":\"$wv\",\"colo\":\"$(esc "$wc")\",\"city\":\"$(esc "$gt")\",\"seen\":\"$(esc "$gs")\"}" + tsep="," + fi + wn=$((wn + 1)) + done + fi + local d="" + stl_present && _st_owns "steer-spec" && d="$(stl_diag)" + printf '{"warp":"%s","colo":"%s","vpn":"%s","vpn_ip":"%s","vpn_loc":"%s","tunnels":[%s],"diag":%s}\n' "$warp" "$(esc "$colo")" "$vpn" "$(esc "$vip")" "$(esc "$vloc")" "$tun" "${d:-null}" + ;; + dns_fix) + do_steer_dns_fix + printf '{"ok":true}\n' + ;; + cron_restart) _st_cron_restart ;; + sync) + _st_cron_refresh + _st_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 info -v 2>/dev/null | sed -n "s/^$1-\([0-9][^ ]*\)\$/\1/p" | head -n1 + else + opkg list-installed 2>/dev/null | awk -v p="$1" '$1 == p { print $3; exit }' + fi +} +_awg_installed() { command -v awg >/dev/null 2>&1 && { _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_zone_of() { + local z + for z in $(uci -q -X show firewall | sed -n "s/^firewall\.\([^.=]*\)=zone\$/\1/p"); do + case " $(uci -q get "firewall.$z.network") " in *" $1 "*) uci -q get "firewall.$z.name"; return 0 ;; esac + done + return 1 +} +_awg_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="$(_awg_zone_of "$i")" + route="$(uci -q get "network.$p.route_allowed_ips")" + auto="$(uci -q get "network.$i.auto")" + desc="$(uci -q get "network.$p.description")" + [ "$(uci -q get "network.$p.public_key")" = "$AWG_WARP_PEER" ] && warp=true + _awg_is_steer "$i" && owner=steer + printf '{"name":"%s","owner":"%s","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 list="" sep="" running=false conf=false ep="" mih=false lp=luci-proto-amneziawg lv + lv="$(_awg_pkg_ver luci-proto-amneziawg)" + [ -n "$lv" ] || { lv="$(_awg_pkg_ver luci-app-amneziawg)"; [ -n "$lv" ] && lp=luci-app-amneziawg; } + _job_alive awg && running=true + for i in $(_awg_ifaces); do list="$list$sep$(_awg_iface_json "$i")"; sep=","; done + if [ -s "$MIXOMO_WARP_CONF" ]; then + conf=true + ep="$(sed -n 's/^[[:space:]]*Endpoint[[:space:]]*=[[:space:]]*//p' "$MIXOMO_WARP_CONF" | head -n1)" + fi + [ -x /etc/init.d/mihomo ] && mih=true + printf '{"running":%s,"phase":"%s","installed":%s,"kmod":"%s","tools":"%s","luci":"%s","luci_pkg":"%s","module":%s,"proto":%s,"steer":%s,"warp_conf":%s,"warp_path":"%s","warp_endpoint":"%s","mihomo":%s,"endpoints":"%s","steer_own":%s,"steer_active":"%s","ifaces":[%s]}\n' \ + "$running" "$(cat "$AWG_RUN/phase" 2>/dev/null)" "$(_awg_installed && echo true || echo false)" \ + "$(esc "$(_awg_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")" "$mih" "$AWG_ENDPOINTS" "$(_st_warp_own && echo true || echo false)" "$(_st_warp_on && _st_active_if)" "$list" +} + +do_awg_install() { + echo "${1:-install}" > "$AWG_RUN/phase" + _ensure_deps + mkdir -p "$ST_RUN" "$AWG_DIR" + [ "$1" = update ] && _awg_say "Переустанавливаем AmneziaWG" + ST_AWG_OWN=0 _st_install_awg "$1" || return 1 + _awg_say "Готово: AmneziaWG $(_awg_pkg_ver amneziawg-tools) установлен" +} + +do_awg_remove() { + local p i 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" ]; then + _awg_say "Forkozz работает через туннель${used} — сначала выключаем Forkozz, чтобы сервисы не зависли" + /etc/init.d/netshift disable >/dev/null 2>&1 + _zm_run 90 /etc/init.d/netshift stop >/dev/null 2>&1 + nft delete table inet "$FK_NFT" >/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 + _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 + rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* 2>/dev/null + _rb_rpcd_ensure + # внутри удаления другого раздела (Forkozz) итог и проверку интернета печатает он сам + 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_API1="https://api.cloudflareclient.com/v0a4005/reg" +AWG_API2="https://api.cloudflareclient.com/v0i1909051800/reg" +AWG_RELAY="https://edge-client-api.vercel.app/v0a4005/reg" +AWG_CF_VER="a-6.11-2223" +AWG_BOOT_PRIV="4OnO86dDLpqJ2U10ODwX3tarx6xlRGLfkmbSBtMgaHg=" +AWG_BOOT_IP="172.16.0.3" +AWG_TEST_IF="zmawgtest" +AWG_TEST_HOSTS="162.159.192.1 188.114.97.1 188.114.96.3 162.159.193.2 162.159.195.2 188.114.98.2 188.114.99.2 8.6.112.2 8.34.70.2 8.34.146.2 8.39.125.2" +AWG_TEST_PORTS="2408 500 4500 1701" +AWG_I1_ICLOUD="" +AWG_I1_QUIC1="" + +AWG_I1_SET="quic2 dns stun icloud sip quic" +AWG_I1_HOSTS="www.apple.com www.google.com www.microsoft.com cdn.jsdelivr.net" + +_i1_hex() { if command -v hexdump >/dev/null 2>&1; then hexdump -ve '1/1 "%02x"'; else od -An -v -tx1 | tr -d ' \n'; fi; } +_i1_num() { + awk -v a="$1" -v b="$2" -v s="$(head -c 4 /dev/urandom | _i1_hex)" 'BEGIN { x = 0; for (i = 1; i <= length(s); i++) x = x * 16 + index("0123456789abcdef", substr(s, i, 1)) - 1; srand(x); print a + int(rand() * (b - a + 1)) }' +} +_i1_str() { tr -dc 'a-z0-9' < /dev/urandom 2>/dev/null | head -c "$1"; } +_i1_host() { set -- $AWG_I1_HOSTS; eval "echo \"\${$(_i1_num 1 $#)}\""; } + +_i1_dns() { + local h="${1:-$(_i1_host)}" q="" l p + for l in $(echo "$h" | tr '.' ' '); do q="$q$(printf '%02x' "${#l}")$(printf '%s' "$l" | _i1_hex)"; done + p="$(_i1_num 60 180)" + printf '' "$q" $((p + 4)) "$p" "$p" +} + +_i1_stun() { + local s + s=$(( $(_i1_num 4 8) * 4 )) + printf '' $((s + 4)) "$s" "$(_i1_str "$s" | _i1_hex)" +} + +_i1_sip() { + local h="${1:-$(_i1_host)}" + printf '' "$(printf '%s\r\n' \ + "OPTIONS sip:$h SIP/2.0" \ + "Via: SIP/2.0/UDP 192.168.$(_i1_num 0 255).$(_i1_num 2 254):5060;branch=z9hG4bK$(_i1_str 10)" \ + "From: ;tag=$(_i1_num 100000 999999)" \ + "To: " \ + "Call-ID: $(_i1_str 16)@$h" \ + "CSeq: $(_i1_num 1000 9999) OPTIONS" \ + "Max-Forwards: 70" \ + "User-Agent: PJSIP/2.13" \ + "Content-Length: 0" \ + "" | _i1_hex)" +} + +_awg_i1() { + case "$1" in + quic) echo "$MIXOMO_AWG_I1" ;; + quic2) echo "$AWG_I1_QUIC1" ;; + icloud) echo "$AWG_I1_ICLOUD" ;; + dns) _i1_dns ;; + stun) _i1_stun ;; + sip) _i1_sip ;; + esac +} + +_awg_write_conf() { + mkdir -p "$(dirname "$MIXOMO_WARP_CONF")" + printf '%s\n' \ + "[Interface]" "PrivateKey = $1" "Address = ${3}${4:+, $4}" "DNS = 1.1.1.1, 1.0.0.1" "MTU = 1280" \ + "S1 = $MIXOMO_AWG_S1" "S2 = $MIXOMO_AWG_S2" "Jc = $MIXOMO_AWG_JC" "Jmin = $MIXOMO_AWG_JMIN" "Jmax = $MIXOMO_AWG_JMAX" \ + "H1 = $MIXOMO_AWG_H1" "H2 = $MIXOMO_AWG_H2" "H3 = $MIXOMO_AWG_H3" "H4 = $MIXOMO_AWG_H4" "I1 = ${6:-$MIXOMO_AWG_I1}" "" \ + "[Peer]" "PublicKey = $2" "AllowedIPs = 0.0.0.0/0, ::/0" "Endpoint = $5" "PersistentKeepalive = 25" \ + > "$MIXOMO_WARP_CONF" + chmod 600 "$MIXOMO_WARP_CONF" +} + +_awg_keys_main() { + local reg="$AWG_RUN/reg.$$.${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" path="" i hs a host port m n=0 best="" fall="" ru="" rucnt=0 t0 colo="" bmask="$MIXOMO_AWG_I1" bep="" masks tried="" + mkdir -p "$AWG_RUN" + [ "$ZM_GEN_NOPHASE" = 1 ] || echo gen > "$AWG_RUN/phase" + G_PRIV=""; G_PEER=""; G_V4=""; G_V6="" + if [ "$mode" = std ]; then + _awg_say "Генерируем WARP" + _awg_keys_get || { echo "ОШИБКА: ключи не получены — ни один источник не ответил. Попробуйте «С проверкой связи»"; return 1; } + _awg_say "Ключи получены, адрес $G_V4" + if [ "$ep" = auto ]; then + _awg_say "Подбираем точку входа" + ep="$(_zm_wait "проверяем точки входа" _mixomo_warp_best_endpoint)" + cat "$JOBS_DIR/mixomo_warp/pick.info" 2>/dev/null || echo " зарубежный узел не нашёлся — беру стандартную точку" + rm -f "$JOBS_DIR/mixomo_warp/pick.info" + fi + _awg_valid_ep "$ep" || ep="engage.cloudflareclient.com:4500" + _awg_say "Точка входа: $ep" + _awg_write_conf "$G_PRIV" "$G_PEER" "$G_V4" "$G_V6" "$ep" + _awg_say "Готово: WARP.conf сохранён" + return 0 + fi + + _awg_installed || { echo "ОШИБКА: для проверки связи нужен AmneziaWG — установите его выше"; return 1; } + _st_awg_loaded || modprobe amneziawg >/dev/null 2>&1 + trap '_awg_try_down "$AWG_TEST_IF"' EXIT INT TERM + AWG_NO_I1=0 + _awg_try "$AWG_TEST_IF" "$AWG_BOOT_PRIV" "$AWG_BOOT_IP" "127.0.0.1:9" "$MIXOMO_AWG_I1" + if [ $? = 2 ]; then echo "ОШИБКА: модуль ядра AmneziaWG не загружен — переустановите AmneziaWG или перезагрузите роутер"; return 1; fi + _awg_try_down "$AWG_TEST_IF" + _awg_say "Шаг 1 из 2: получаем ключи WARP (ключ создаётся на роутере)" + if _awg_cf_register ""; then + : + elif _awg_keys_main; then + echo " ключи выдал внешний генератор" + else + echo " внешний генератор тоже не ответил" + echo " ищем обходной путь через туннель" + path="" + for i in $(_awg_ifaces); do + a="$(_awg_hs_age "$i")" + [ -n "$a" ] && [ "$a" -lt 180 ] || continue + if _awg_api_ok "--interface $i"; then path="--interface $i"; echo " через туннель $i"; break; fi + done + if [ -z "$path" ]; then + echo " поднимаем временный туннель на общих ключах" + for host in 188.114.96.3 162.159.192.1 188.114.97.1; do + for port in $AWG_TEST_PORTS; do + for m in "$MIXOMO_AWG_I1" "$AWG_I1_ICLOUD" "$AWG_I1_QUIC1"; do + [ "$AWG_NO_I1" = 1 ] && [ "$m" != "$MIXOMO_AWG_I1" ] && continue + if _awg_try "$AWG_TEST_IF" "$AWG_BOOT_PRIV" "$AWG_BOOT_IP" "$host:$port" "$m" && _awg_api_ok "--interface $AWG_TEST_IF"; then + path="--interface $AWG_TEST_IF"; bep="$host:$port"; bmask="$m" + echo " временный туннель: $host:$port$(_awg_ml "$m")" + break 3 + fi + done + done + done + fi + [ -n "$path" ] || { _awg_try_down "$AWG_TEST_IF"; echo "ОШИБКА: источник ключей недоступен ни напрямую, ни через туннели — похоже, провайдер закрыл WARP"; return 1; } + if ! _awg_cf_register "$path"; then + _awg_try_down "$AWG_TEST_IF" + echo "ОШИБКА: ключи не выданы"; return 1 + fi + _awg_try_down "$AWG_TEST_IF" + fi + _awg_say "Ключи получены, адрес $G_V4" + + _awg_say "Шаг 2 из 2: ищем рабочую точку входа и маску" + masks="$bmask" + for m in "$MIXOMO_AWG_I1" "$AWG_I1_ICLOUD" "$AWG_I1_QUIC1"; do [ "$m" = "$bmask" ] || masks="$masks +$m"; done + local ports="" oifs="$IFS" nl=' +' h1="${bep%%:*}" allm meta torn seen + allm="$masks +$(_i1_dns) +$(_i1_stun) +$(_i1_sip)" + [ -n "$h1" ] || h1="${AWG_TEST_HOSTS%% *}" + for port in $AWG_TEST_PORTS; do + IFS="$nl" + for m in $masks; do + IFS="$oifs" + [ "$AWG_NO_I1" = 1 ] && [ "$m" != "$bmask" ] && continue + n=$((n + 1)) + if _awg_try "$AWG_TEST_IF" "$G_PRIV" "$G_V4" "$h1:$port" "$m" "$G_PEER"; then ports="$ports $port"; break; fi + done + IFS="$oifs" + echo " порт $port: $(case " $ports " in *" $port "*) echo открыт ;; *) echo закрыт ;; esac)" + done + if [ -z "$ports" ]; then + echo " основные порты молчат — пробуем 50 запасных, это до двух минут" + AWG_TRY_WAIT=4 + for port in $ST_WARP_PORTS_EXT; do + _awg_try "$AWG_TEST_IF" "$G_PRIV" "$G_V4" "$h1:$port" "$bmask" "$G_PEER" && ports="$ports $port" + [ "$(echo $ports | wc -w)" -ge 2 ] && break + done + AWG_TRY_WAIT=8 + [ -n "$ports" ] && echo " открыты запасные порты:$ports" + fi + [ -n "$ports" ] || _rb_warn "Ни один порт не ответил — похоже, провайдер режет UDP для WARP" + t0="$(date +%s)" + for host in $h1 $AWG_TEST_HOSTS $(_awg_rand_hosts 2); do + if [ "$rucnt" -ge 12 ]; then echo " все проверенные точки ведут на российские узлы — похоже, провайдер направляет туда весь WARP"; break; fi + for port in $ports; do + a="$host:$port" + case " $tried " in *" $a "*) continue ;; esac + tried="$tried $a" + IFS="$nl" + for m in $allm; do + IFS="$oifs" + [ "$AWG_NO_I1" = 1 ] && [ "$m" != "$bmask" ] && continue + [ $(( $(date +%s) - t0 )) -gt 420 ] && { echo " поиск идёт больше 7 минут — останавливаемся"; break 3; } + n=$((n + 1)) + [ "$n" -gt 80 ] && break 3 + if _awg_try "$AWG_TEST_IF" "$G_PRIV" "$G_V4" "$a" "$m" "$G_PEER"; then + if _awg_trace "$AWG_TEST_IF"; then + set -- $(_st_warp_probe "$AWG_TEST_IF"); torn="$3" + if [ "$torn" = 1 ]; then + echo " $a$(_awg_ml "$m") — трафик пошёл и оборвался: так DPI рвёт туннель" + [ -n "$fall" ] || fall="$a|$m" + continue + fi + meta="потери $1%, $2 мс" + colo="" + set -- $(_cf_meta "$AWG_TEST_IF") + if [ $# -ge 4 ]; then + colo="$1" + meta="$meta, узел $1" + [ "$2" != - ] && seen="$2" || seen="" + shift 3 + [ "$*" != - ] && meta="$meta ($*)" + [ -n "$seen" ] && meta="$meta, сайты видят $seen" + fi + if [ -n "$colo" ] && _st_warp_is_ru "$colo"; then + echo " $a$(_awg_ml "$m") — трафик идёт, но через российский узел $colo: там свой DPI, ищем зарубежный" + [ -n "$ru" ] || ru="$a|$m|$colo" + rucnt=$((rucnt + 1)) + break 2 + fi + best="$a"; bmask="$m" + echo " $a$(_awg_ml "$m") — трафик идёт: $meta" + break 3 + fi + echo " $a$(_awg_ml "$m") — рукопожатие есть, трафика нет" + [ -n "$fall" ] || fall="$a|$m" + else + echo " $a$(_awg_ml "$m") — нет ответа" + fi + done + IFS="$oifs" + done + done + IFS="$oifs" + _awg_try_down "$AWG_TEST_IF" + if [ -z "$best" ] && [ -n "$ru" ]; then + best="${ru%%|*}"; bmask="${ru#*|}"; bmask="${bmask%|*}" + _rb_warn "Зарубежный узел не нашёлся — записываю точку через российский узел ${ru##*|} ($best): WARP работает, но часть сайтов через него может не открыться" + fi + if [ -z "$best" ]; then + if [ -n "$fall" ]; then + best="${fall%%|*}"; bmask="${fall#*|}" + _rb_warn "Трафик нигде не пошёл — записываю точку с рукопожатием ($best), туннель может не заработать" + else + best="engage.cloudflareclient.com:4500"; bmask="$MIXOMO_AWG_I1" + _rb_warn "Ни одна точка не ответила — записываю стандартную ($best)" + fi + fi + _awg_write_conf "$G_PRIV" "$G_PEER" "$G_V4" "$G_V6" "$best" "$bmask" + if [ "$AWG_NO_I1" = 1 ]; then + sed -i '/^I1 = /d' "$MIXOMO_WARP_CONF" + _awg_say "Готово: WARP.conf сохранён (точка $best, без маски I1)" + else + _awg_say "Готово: WARP.conf сохранён (точка $best, маска $(_awg_mask_name "$bmask"))" + fi +} + +_awg_iface_opts() { + awk 'function trim(s) { gsub(/^[ \t\r]+|[ \t\r]+$/, "", s); return s } + { l = $0; sub(/[#;].*$/, "", l); l = trim(l); if (l == "") next + if (l ~ /^\[.*\]$/) { sec = tolower(trim(substr(l, 2, length(l) - 2))); next } + if (sec != "interface") next + p = index(l, "="); if (!p) next + k = trim(substr(l, 1, p - 1)); v = trim(substr(l, p + 1)) + if (v == "" || tolower(k) ~ /^(privatekey|address|dns|mtu|listenport|table|saveconfig|preup|postup|predown|postdown|fwmark)$/) next + n = "" + for (i = 1; i <= length(k); i++) { c = substr(k, i, 1); if (c ~ /[A-Z]/ && i > 1 && substr(k, i - 1, 1) ~ /[a-z0-9]/) n = n "_"; n = n tolower(c) } + gsub(/[^a-z0-9_]/, "", n); if (n == "") next + print "awg_" n, v }' "$1" +} +_awg_iface_set() { + local o v + _awg_iface_opts "$1" | while read -r o v; do uci set "network.$2.$o=$v"; done +} +_awg_uci_conf() { + local o v + for o in $(uci -q show "network.$1" | sed -n "s/^network\.$1\.\(awg_[a-z0-9_]*\)=.*/\1/p"); do + v="$(uci -q get "network.$1.$o")" + [ -n "$v" ] || continue + echo "$(echo "${o#awg_}" | awk -F_ '{ for (i = 1; i <= NF; i++) printf "%s%s", toupper(substr($i, 1, 1)), substr($i, 2) }') = $v" + done +} + +_awg_conf_kv() { + awk 'function trim(s) { gsub(/^[ \t\r]+|[ \t\r]+$/, "", s); return s } + { l = $0; sub(/[#;].*$/, "", l); l = trim(l); if (l == "") next + if (l ~ /^\[.*\]$/) { sec = tolower(trim(substr(l, 2, length(l) - 2))); next } + p = index(l, "="); if (!p) next + print sec, tolower(trim(substr(l, 1, p - 1))), trim(substr(l, p + 1)) }' "$1" +} +_awg_cv() { awk -v s="$2" -v k="$3" '$1 == s && $2 == k { $1 = ""; $2 = ""; sub(/^ /, ""); print; exit }' "$1"; } + +do_awg_create() { + local name="$1" route="$2" fw="$3" f="$AWG_DIR/pending.conf" kv="$AWG_RUN/kv" priv pub psk addr a allowed ep host port keep mtu k v p zone w=0 age + echo create > "$AWG_RUN/phase" + [ -s "$f" ] || { echo "ОШИБКА: нет конфигурации"; return 1; } + _awg_installed || { echo "ОШИБКА: сначала установите AmneziaWG"; return 1; } + _awg_conf_kv "$f" > "$kv" + priv="$(_awg_cv "$kv" interface privatekey)"; pub="$(_awg_cv "$kv" peer publickey)" + addr="$(_awg_cv "$kv" interface address)"; ep="$(_awg_cv "$kv" peer endpoint)" + [ -n "$priv" ] && [ -n "$pub" ] && [ -n "$addr" ] && [ -n "$ep" ] || { echo "ОШИБКА: в файле нет PrivateKey, Address, PublicKey или Endpoint"; return 1; } + case "$ep" in + \[*\]:*) host="${ep%]:*}"; host="${host#[}"; port="${ep##*]:}" ;; + *) host="${ep%:*}"; port="${ep##*:}" ;; + esac + psk="$(_awg_cv "$kv" peer presharedkey)"; allowed="$(_awg_cv "$kv" peer allowedips)" + keep="$(_awg_cv "$kv" peer persistentkeepalive)"; mtu="$(_awg_cv "$kv" interface mtu)" + + _awg_say "Создаём интерфейс $name" + uci -q delete "network.$name" + for p in $(uci -q -X show network | sed -n "s/^network\.\([^.=]*\)=amneziawg_$name\$/\1/p"); do uci -q delete "network.$p"; done + uci set "network.$name=interface" + uci set "network.$name.proto=amneziawg" + uci set "network.$name.private_key=$priv" + for a in $(echo "$addr" | tr ',' ' '); do + case "$a" in */*) ;; *:*) a="$a/128" ;; *) a="$a/32" ;; esac + uci add_list "network.$name.addresses=$a" + done + uci set "network.$name.mtu=${mtu:-1280}" + _awg_iface_set "$f" "$name" + p="$(uci add network "amneziawg_$name")" + uci set "network.$p.description=$name" + uci set "network.$p.public_key=$pub" + [ -n "$psk" ] && uci set "network.$p.preshared_key=$psk" + for a in $(echo "${allowed:-0.0.0.0/0, ::/0}" | tr ',' ' '); do uci add_list "network.$p.allowed_ips=$a"; done + uci set "network.$p.endpoint_host=$host" + uci set "network.$p.endpoint_port=${port:-51820}" + uci set "network.$p.persistent_keepalive=${keep:-25}" + uci set "network.$p.route_allowed_ips=$route" + uci commit network + grep -qxF "$name" "$AWG_DIR/owned" 2>/dev/null || echo "$name" >> "$AWG_DIR/owned" + + if [ "$fw" = 1 ]; then + zone="$(echo "$name" | cut -c1-11)" + _awg_say "Создаём зону firewall «$zone» — устройства сети смогут ходить в туннель" + uci -q delete "firewall.zmawg_$name"; uci -q delete "firewall.zmawg_${name}_fwd" + uci set "firewall.zmawg_$name=zone" + uci set "firewall.zmawg_$name.name=$zone" + uci add_list "firewall.zmawg_$name.network=$name" + uci set "firewall.zmawg_$name.input=REJECT" + uci set "firewall.zmawg_$name.output=ACCEPT" + uci set "firewall.zmawg_$name.forward=REJECT" + uci set "firewall.zmawg_$name.masq=1" + uci set "firewall.zmawg_$name.mtu_fix=1" + uci set "firewall.zmawg_${name}_fwd=forwarding" + uci set "firewall.zmawg_${name}_fwd.src=$(_zm_lan_zone)" + uci set "firewall.zmawg_${name}_fwd.dest=$zone" + uci commit firewall + /etc/init.d/firewall reload >/dev/null 2>&1 + fi + [ "$route" = 1 ] && echo "!! Весь трафик роутера пойдёт через $name. Упадёт туннель — пропадёт интернет" + _awg_say "Поднимаем $name" + ubus call network reload >/dev/null 2>&1 + sleep 2 + ubus call "network.interface.$name" up >/dev/null 2>&1 + _awg_say "Ждём ответа сервера" + printf 'Подождите ' + while [ "$w" -lt 20 ]; do + age="$(_awg_hs_age "$name")" + [ -n "$age" ] && break + [ -d "/sys/class/net/$name" ] || sleep 1 + ping -I "$name" -c 1 -W 1 1.1.1.1 >/dev/null 2>&1 + w=$((w + 1)) + [ $((w % 5)) = 0 ] && printf '.' + done + echo + rm -f "$f" "$kv" + if [ -n "$age" ]; then + _awg_say "Готово: $name работает" + else + echo "!! $name создан, но сервер не отвечает — проверьте точку входа" + fi +} + +do_awg_pick() { + local i="$1" p got + echo pick > "$AWG_RUN/phase" + p="$(_awg_peer_sec "$i")" + [ "$(uci -q get "network.$p.public_key")" = "$AWG_WARP_PEER" ] || { echo "ОШИБКА: подбор точки есть только для туннелей WARP"; return 1; } + mkdir -p "$ST_DIR" "$ST_RUN" + rm -f "$ST_STOP_FLAG" + ubus call "network.interface.$i" up >/dev/null 2>&1 + sleep 3 + _awg_say "Подбираем точку входа для $i — это до пары минут" + got="$(_st_warp_scan "$i" "$AWG_WARP_PEER" "" "")" || { echo "ОШИБКА: ни одна точка не ответила — провайдер может резать UDP к WARP"; return 1; } + set -- $got + _st_warp_link "$i" "$AWG_WARP_PEER" "$1" "$2" >/dev/null 2>&1 + uci set "network.$p.endpoint_host=$1" + uci set "network.$p.endpoint_port=$2" + uci commit network + _awg_say "Готово: $i → $1:$2 (сервер $3)" + set -- $(_cf_meta "$i") + [ $# -ge 4 ] && [ "$2" != - ] && _awg_say "Сайты видят страну: $2" + return 0 +} + +do_awg_regen() { + local i="$1" p host port ep warp=0 route k v kk have=0 f="$AWG_DIR/pending.conf" rc + 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_own; then + mv -f "$f" "$ST_DIR/warp.pending" + do_steer_warp_own + return + fi + mv -f "$f" "$ST_WARP_OWN"; chmod 600 "$ST_WARP_OWN" + _st_own_keep + ( ST_WARP_IF="$ST_OWN_IF"; _st_warp_own_iface "$ST_WARP_OWN" ) || return 1 + _st_warp_park "$ST_OWN_IF" + _awg_say "Готово: конфиг сохранён. Он заработает, когда в Steer выберете «Свой конфиг»" + return 0 + fi + if _st_warp_own; then + echo "ОШИБКА: сейчас у Steer работает свой конфиг ($ST_OWN_IF) — автоматические туннели меняются, когда они включены" + return 1 + fi + n="$(_awg_steer_n "$i")"; c="$(_st_wconf "$n")" + _awg_conf_kv "$f" > "$kv" + [ -n "$(_awg_cv "$kv" interface privatekey)" ] && [ -n "$(_awg_cv "$kv" peer publickey)" ] && [ -n "$(_awg_cv "$kv" interface address)" ] || + { rm -f "$f" "$kv"; echo "ОШИБКА: в конфиге нет PrivateKey, Address или PublicKey"; return 1; } + ep="$(_awg_cv "$kv" peer endpoint)" + case "$ep" in \[*\]:*) host="${ep%]:*}"; host="${host#[}"; port="${ep##*]:}" ;; ?*:*) host="${ep%:*}"; port="${ep##*:}" ;; esac + [ -n "$host" ] || host="$(uci -q get "network.${i}_peer.endpoint_host")" + [ -n "$port" ] || port="$(uci -q get "network.${i}_peer.endpoint_port")" + mkdir -p "$ST_DIR" + cp "$f" "$c"; chmod 600 "$c" + _awg_say "Записываем конфиг в туннель Steer $i" + _st_with "$n" _st_warp_iface_write "${host:-162.159.192.1}" "${port:-2408}" + _awg_iface_set "$f" "$i" + uci -q delete "network.$i.auto" + uci commit network + rm -f "$f" "$kv" + ubus call network reload >/dev/null 2>&1 + sleep 2 + ubus call "network.interface.$i" up >/dev/null 2>&1 + while [ "$w" -lt 20 ]; do + _st_warp_alive_if "$i" && break + [ -d "/sys/class/net/$i" ] || sleep 1 + ping -I "$i" -c 1 -W 1 1.1.1.1 >/dev/null 2>&1 + w=$((w + 1)) + done + if _st_warp_alive_if "$i"; then + col="$(_st_colo_of "$i")" + if [ -s "$ST_WARP_UP" ]; then + { while read -r x cc; do [ "$x" = "$i" ] || echo "$x $cc"; done < "$ST_WARP_UP"; echo "$i ${col:-?}"; } > "$ST_WARP_UP.tmp" + mv "$ST_WARP_UP.tmp" "$ST_WARP_UP" + fi + _st_kick >/dev/null 2>&1 + _awg_say "Готово: $i работает${col:+, сервер $col}" + else + echo "!! $i: конфиг записан, но сервер не отвечает — проверьте точку входа или нажмите «Новый WARP»" + fi +} + +do_awg_steer_regen() { + echo regen > "$AWG_RUN/phase" + _job_alive steer && { echo "ОШИБКА: на странице Steer идёт операция — дождитесь её окончания"; return 1; } + _st_warp_fix "$(_awg_steer_n "$1")" keys || return 1 + _st_kick >/dev/null 2>&1 + _awg_say "Готово: у $1 новые ключи WARP" +} + +_awg_export() { + local i="$1" p a k v + p="$(_awg_peer_sec "$i")" + echo "[Interface]" + echo "PrivateKey = $(uci -q get "network.$i.private_key")" + echo "Address = $(uci -q get "network.$i.addresses" | sed 's/ /, /g')" + v="$(uci -q get "network.$i.mtu")"; [ -n "$v" ] && echo "MTU = $v" + _awg_uci_conf "$i" + echo "" + echo "[Peer]" + echo "PublicKey = $(uci -q get "network.$p.public_key")" + v="$(uci -q get "network.$p.preshared_key")"; [ -n "$v" ] && echo "PresharedKey = $v" + echo "AllowedIPs = $(uci -q get "network.$p.allowed_ips" | sed 's/ /, /g')" + a="$(uci -q get "network.$p.endpoint_host")" + case "$a" in *:*) a="[$a]" ;; esac + echo "Endpoint = $a:$(uci -q get "network.$p.endpoint_port")" + v="$(uci -q get "network.$p.persistent_keepalive")"; [ -n "$v" ] && echo "PersistentKeepalive = $v" +} + +_awg_delete() { + local i="$1" p z + _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 ;; + conf_set) mixomo_warp_config_set "$mode" ;; + create) + name="${mode%%|*}"; mode="${mode#*|}" + route="${mode%%|*}"; mode="${mode#*|}" + fw="${mode%%|*}"; conf="${mode#*|}" + _awg_check_name "$name" || return 1 + case "$route$fw" in [01][01]) ;; *) echo '{"error":"неверные параметры"}'; return 1 ;; esac + if [ -n "$(uci -q get "network.$name")" ] && [ "$(uci -q get "network.$name.proto")" != amneziawg ]; then + echo '{"error":"интерфейс с таким именем уже есть и это не AmneziaWG"}'; return 1 + fi + printf '%s\n' "$conf" | grep -qi '^[[:space:]]*\[interface\]' || { echo '{"error":"в конфигурации нет секции [Interface]"}'; return 1; } + printf '%s\n' "$conf" | grep -qi '^[[:space:]]*\[peer\]' || { echo '{"error":"в конфигурации нет секции [Peer]"}'; return 1; } + mkdir -p "$AWG_DIR" + printf '%s\n' "$conf" | tr -d '\r' > "$AWG_DIR/pending.conf" + chmod 600 "$AWG_DIR/pending.conf" + job_start awg do_awg_create "$name" "$route" "$fw" + ;; + replace) + i="${mode%%|*}"; conf="${mode#*|}" + [ "$(uci -q get "network.$i.proto")" = amneziawg ] || { echo '{"error":"нет такого интерфейса"}'; return 1; } + if _awg_is_steer "$i"; then + printf '%s\n' "$conf" | grep -qi '^[[:space:]]*\[peer\]' || { echo '{"error":"в конфигурации нет секции [Peer]"}'; return 1; } + mkdir -p "$AWG_DIR" + printf '%s\n' "$conf" | tr -d '\r' > "$AWG_DIR/pending.conf" + chmod 600 "$AWG_DIR/pending.conf" + job_start awg do_awg_steer_replace "$i" + return + fi + printf '%s\n' "$conf" | grep -qi '^[[:space:]]*\[interface\]' || { echo '{"error":"в конфигурации нет секции [Interface]"}'; return 1; } + printf '%s\n' "$conf" | grep -qi '^[[:space:]]*\[peer\]' || { echo '{"error":"в конфигурации нет секции [Peer]"}'; return 1; } + route="$(uci -q get "network.$(_awg_peer_sec "$i").route_allowed_ips")"; [ "$route" = 1 ] || route=0 + mkdir -p "$AWG_DIR" + printf '%s\n' "$conf" | tr -d '\r' > "$AWG_DIR/pending.conf" + chmod 600 "$AWG_DIR/pending.conf" + job_start awg do_awg_create "$i" "$route" 0 + ;; + regen) + [ "$(uci -q get "network.$mode.proto")" = amneziawg ] || { echo '{"error":"нет такого интерфейса"}'; return 1; } + if _awg_is_steer "$mode"; then job_start awg do_awg_steer_regen "$mode"; return; fi + job_start awg do_awg_regen "$mode" + ;; + pick) + [ "$(uci -q get "network.$mode.proto")" = amneziawg ] || { echo '{"error":"нет такого интерфейса"}'; return 1; } + _awg_is_steer "$mode" && { echo '{"error":"точки туннелей Steer подбираются на странице Steer"}'; return 1; } + job_start awg do_awg_pick "$mode" + ;; + endpoint) + i="${mode%%|*}"; ep="${mode#*|}" + [ "$(uci -q get "network.$i.proto")" = amneziawg ] || { echo '{"error":"нет такого интерфейса"}'; return 1; } + _awg_is_steer "$i" && { echo '{"error":"точки туннелей Steer меняются на странице Steer"}'; return 1; } + _awg_valid_ep "$ep" || { echo '{"error":"точка входа: адрес:порт, например 162.159.192.1:2408"}'; return 1; } + name="$(_awg_peer_sec "$i")" + case "$ep" in \[*\]:*) route="${ep%]:*}"; route="${route#[}" ;; *) route="${ep%:*}" ;; esac + uci set "network.$name.endpoint_host=$route" + uci set "network.$name.endpoint_port=${ep##*:}" + uci commit network + ifup "$i" >/dev/null 2>&1 + printf '{"ok":true}\n' + ;; + restart|up|down) + [ "$(uci -q get "network.$mode.proto")" = amneziawg ] || { echo '{"error":"нет такого интерфейса"}'; return 1; } + case "$action" in + restart) ifup "$mode" >/dev/null 2>&1 ;; + up) uci -q delete "network.$mode.auto"; uci commit network; ifup "$mode" >/dev/null 2>&1 ;; + down) + _awg_is_steer "$mode" && { echo '{"error":"туннели Steer выключаются на странице Steer"}'; return 1; } + _awg_fk_live "$mode" && { printf '{"error":"%s"}\n' "$(esc "через туннель $mode сейчас работает Forkozz. Сначала выключите Forkozz или выберите в нём другое подключение")"; 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; } + _awg_fk_live "$mode" && { printf '{"error":"%s"}\n' "$(esc "через туннель $mode сейчас работает Forkozz. Сначала выключите Forkozz или выберите в нём другое подключение")"; 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 +} + +# ---------- Терминал (ttyd) ---------- +_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" +} + +# Настройки по умолчанию: только домашняя сеть, вход через /bin/login. Уже заданное пользователем не трогаем. +_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 + [ -n "$(uci -q get 'ttyd.@ttyd[0].interface')" ] || 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 + /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","job":%s}\n' \ + "$inst" "$run" "$en" "$(esc "$(_term_version)")" "$(_term_port)" "$(esc "$(_zm_lan_ip)")" \ + "$(_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 /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' ;; + *) echo '{"error":"неизвестное действие"}'; return 1 ;; + esac +} + +# ---------- Forkozz: панель sing-box — Zashboard вместо YACD ---------- +FK_ZASH_MARK=".zm-zashboard" + +# Каталог панели берём из настоящего конфига sing-box (experimental.clash_api.external_ui) +_fk_ui_dir() { + local c=/etc/sing-box/config.json ui pid base + [ -s "$c" ] || return 1 + ui="$(jsonfilter -i "$c" -e '@.experimental.clash_api.external_ui' 2>/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 +} + +# Заменяет скачанный sing-box YACD на Zashboard. Не вышло — остаётся рабочий YACD. +# Аргумент quiet: вызов из фоновой проверки — молча и не чаще раза в 30 минут при неудаче. +_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.9" +FK_MARK="/usr/lib/netshift/zm-managed" +FK_UC="/opt/zapret-manager-luci/netshift.uc" +FK_SAVE="$ZM_STATE_DIR/forkop.config" +FK_SB_OWN="$ZM_STATE_DIR/netshift.singbox" +FK_DEPS="ca-bundle kmod-inet-diag kmod-tun curl jq ucode ucode-mod-fs ucode-mod-uci kmod-nft-tproxy coreutils-base64 bind-dig ip-full" +FK_NFT="NetShiftTable" +FK_MIRRORS='mirror\.(infotechtg|51343)\.ru|fold8\.ru' +FK_SB_PKGS="sing-box sing-box-tiny sing-box-extended podkop-engine podkop-engine-full" + +_fk_say() { echo "==> $*"; } +_fk_installed() { [ -x /usr/bin/netshift ] && [ -f /usr/lib/netshift/constants.sh ]; } +_fk_legacy() { [ -x /usr/bin/forkop ] || [ -d /usr/lib/forkop ] || [ -x /etc/init.d/forkop ]; } +_fk_present() { _fk_installed || _fk_legacy; } +_fk_version() { sed -n 's/^NETSHIFT_VERSION="\([^"]*\)".*/\1/p' /usr/lib/netshift/constants.sh 2>/dev/null | head -n1; } +_fk_enabled() { ls /etc/rc.d/S*netshift >/dev/null 2>&1; } +_fk_up() { nft list table inet "$FK_NFT" >/dev/null 2>&1 && pidof sing-box >/dev/null 2>&1; } +# Замок движка (его ставит _fk_serial_patch): пока он взят, движок запускается, останавливается, применяет +# подписку или поднимает упавший sing-box. Запуск через procd идёт в фоне — его видно по службе netshift. +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' +} +# Работает и закончил запуск: правила стоят, sing-box запущен, конфиг и наборы правил собраны до конца +_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_sbx_latest() { + curl -Ls --connect-timeout 5 --max-time 10 -o /dev/null -w '%{url_effective}' "https://github.com/shtorm-7/sing-box-extended/releases/latest" 2>/dev/null | + sed -n 's#.*/tag/[vV]\{0,1\}##p' | head -n1 +} +_fk_foreign() { _pkg_is_installed netshift || _pkg_is_installed luci-app-netshift; } +_fk_uc() { command -v ucode >/dev/null 2>&1 && [ -f "$FK_UC" ] || { echo '{"error":"Forkozz не установлен"}'; return 1; }; ucode "$FK_UC" "$@"; } +_fk_ucj() { + local out rc ef="$JOBS_DIR/fk-uc.$$.err" + mkdir -p "$JOBS_DIR" + out="$(_fk_uc "$@" 2>"$ef")"; rc=$? + case "$out" in + '{'*|'['*) rm -f "$ef"; printf '%s\n' "$out" ;; + *) [ -n "$out" ] || out="$(cat "$ef" 2>/dev/null)"; rm -f "$ef" + printf '{"error":"%s"}\n' "$(esc "Forkozz не ответил: $(printf '%s' "${out:-пустой ответ}" | head -n 3 | tr '\t' ' ')")"; return 1 ;; + esac + return $rc +} + +_fk_device_setup() { + local res + 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" + echo $w +} + +_fk_health() { + if ! _fk_installed && _fk_legacy; then + ls /etc/rc.d/S*forkop >/dev/null 2>&1 || { echo 5; return; } + nft list table inet ForkopTable >/dev/null 2>&1 && pidof sing-box >/dev/null 2>&1 && echo 1 || echo 2 + return + fi + _fk_installed || { echo 0; return; } + _fk_enabled || { echo 5; return; } + _fk_up && echo 1 || echo 2 +} + +forkop_status() { + local inst=false ver="" latest="" newer=false en=false up=false sb sbp="" blk busy=false warn="" x sbu="" legacy=false + _fk_installed && { inst=true; ver="$(_fk_version)"; } + [ "$inst" = false ] && _fk_legacy && legacy=true + latest="$(_fk_latest)" + [ -n "$ver" ] && [ -n "$latest" ] && _st_ver_lt "$ver" "$latest" && newer=true + _fk_enabled && en=true + _fk_up && up=true + if [ "$legacy" = true ]; then + ls /etc/rc.d/S*forkop >/dev/null 2>&1 && en=true + nft list table inet ForkopTable >/dev/null 2>&1 && pidof sing-box >/dev/null 2>&1 && up=true + fi + sb="$(_fk_sb_ver)" + sbp="$(_fk_sb_var)" + [ "$inst" = false ] && [ -n "$sb" ] && sbu="$(_fk_sb_users)" + blk="$(_fk_blocker)" + _job_running forkop && busy=true + for x in $(_fk_warn); do warn="$warn${warn:+,}\"$x\""; done + printf '{"installed":%s,"legacy":%s,"version":"%s","latest":"%s","newer":%s,"enabled":%s,"running":%s,"managed":%s,"foreign":%s,"singbox":"%s","singbox_pkg":"%s","singbox_users":"%s","blocker":"%s","blocker_text":"%s","warn":[%s],"busy":%s,"lan_ip":"%s","saved":%s,"watch":%s,"hosts_extra":%s}\n' \ + "$inst" "$legacy" "$(esc "$ver")" "$(esc "$latest")" "$newer" "$en" "$up" \ + "$([ -f "$FK_MARK" ] && echo true || echo false)" "$(_fk_foreign && echo true || echo false)" \ + "$(esc "$sb")" "$(esc "$sbp")" "$(esc "$sbu")" "$(esc "$blk")" "$(esc "$(_fk_blocker_text "$blk")")" "$warn" "$busy" \ + "$(esc "$(_zm_lan_ip)")" "$([ -s "$FK_SAVE" ] && echo true || echo false)" \ + "$(awk '{print $1 + 0; exit}' "$ZM_STATE_DIR/fk.watch" 2>/dev/null | grep . || echo 0)" "$(_hosts_extra)" +} + +_fk_feeds_official() { + local f b ch=0 + for f in /etc/opkg/distfeeds.conf /etc/opkg/customfeeds.conf /etc/apk/repositories /etc/apk/repositories.d/*.list; do + [ -f "$f" ] || continue + case "$f" in */repositories.d/forkop.list) rm -f "$f"; ch=1; continue ;; esac + grep -qE "$FK_MIRRORS" "$f" || continue + b="$f.pre-forkop-mirror" + if [ -f "$b" ] && ! grep -qE "$FK_MIRRORS" "$b"; then cp "$b" "$f" + elif { [ "${f##*/}" = distfeeds.conf ] || [ "${f##*/}" = distfeeds.list ]; } && [ -f "/rom$f" ] && ! grep -qE "$FK_MIRRORS" "/rom$f"; then cp "/rom$f" "$f" + else sed -i -E 's#https?://mirror\.(infotechtg|51343)\.ru/openwrt/releases/#https://downloads.openwrt.org/releases/#g' "$f"; fi + sed -i -E "/$FK_MIRRORS/d" "$f" + rm -f "$b" + ch=1 + _fk_say "Вернули официальный репозиторий пакетов: $f" + done + [ -f /etc/apk/keys/forkop-mirror.pem ] && { rm -f /etc/apk/keys/forkop-mirror.pem; ch=1; } + [ "$ch" = 1 ] && return 0 + return 1 +} + +# Old Forkop configs may point lists at third-party mirrors: put the official sources back before importing. +_fk_legacy_conf_fix() { + sed -i -E \ + -e 's#https?://mirror\.(infotechtg|51343)\.ru/forkop/lists/allow-domains/#https://raw.githubusercontent.com/itdoginfo/allow-domains/main/#g' \ + -e 's#https?://mirror\.(infotechtg|51343)\.ru/forkop/lists/b4geoip-forkop/#https://raw.githubusercontent.com/Greeg0ry/b4geoip-forkop/main/#g' \ + -e 's#https?://mirror\.(infotechtg|51343)\.ru/forkop/lists/rulesets/community/#https://github.com/itdoginfo/allow-domains/releases/latest/download/#g' \ + -e 's#https?://mirror\.(infotechtg|51343)\.ru/forkop/lists/rulesets/adlist\.srs#https://github.com/zxc-rv/ad-filter/releases/latest/download/adlist.srs#g' \ + -e 's#https?://mirror\.(infotechtg|51343)\.ru/forkop/lists/rulesets/supercell\.srs#https://raw.githubusercontent.com/ushan0v/sing-box-supercell-ruleset/main/supercell.srs#g' \ + -e 's#https?://mirror\.(infotechtg|51343)\.ru/forkop/lists/rulesets/github\.srs#https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/sing/geo/geosite/github.srs#g' "$1" +} + +_fk_restore_old() { + [ -d /usr/lib/netshift.zm-old ] || return 0 + rm -rf /usr/lib/netshift + mv /usr/lib/netshift.zm-old/.bin /usr/bin/netshift 2>/dev/null + mv /usr/lib/netshift.zm-old/.init /etc/init.d/netshift 2>/dev/null + mv /usr/lib/netshift.zm-old /usr/lib/netshift + _fk_say "Вернули прежнюю версию Forkozz" +} + +# Смена sing-box на пакет OpenWrt (tiny — облегчённый, stable — обычный). Прежний вариант уходит +# целиком: пакет другого варианта удаляется, а сборка, поставленная не пакетом (расширенная, её +# облегчённая версия), — вместе с libcronet и сжатым ядром; иначе её файл мешал пакету встать и +# оставался жить рядом. Не встал новый — всё прежнее возвращается из копии в /tmp. +_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" && $DELETE "$p" >/dev/null 2>&1 + done + if _pkg_is_installed "$pkg"; then + if command -v apk >/dev/null 2>&1; then apk fix --reinstall "$pkg" >/dev/null 2>&1 || apk add --force-overwrite "$pkg" >/dev/null 2>&1 + else opkg install --force-reinstall "$pkg" >/dev/null 2>&1; fi + else + $INSTALL "$pkg" >/dev/null 2>&1 + fi + if _pkg_is_installed "$pkg" && [ -x /usr/bin/sing-box ] && sing-box version >/dev/null 2>&1 && ! _fk_sb_is_ext; then + 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 +} + +# Расширенный sing-box поставлен поверх пакета: пакет прежнего варианта удаляем, чтобы его не +# осталось в системе (и обновление пакетов не вернуло облегчённый поверх расширенного). Файл +# расширенного на время удаления отодвигается, служба sing-box (её init-скрипт нужен Forkozz) +# остаётся на месте. +_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 + # удаление пакета забирает и папку /etc/sing-box, а движок кладёт конфиг туда, не создавая её + mkdir -p /etc/sing-box + rm -rf "$k" + _fk_sb_leftovers + echo " ✓ Прежний sing-box удалён полностью (пакет$(echo "$had" | sed 's/^ / /; s/ \([^ ]\)/, \1/g; s/^,//')) — остался только расширенный" +} + +# Остатки прежних вариантов: копии на откат, распакованные архивы, сжатое ядро и кэши версий +# от варианта, который сейчас не стоит. +_fk_sb_leftovers() { + local var + var="$(_fk_sb_var)" + rm -f /usr/bin/sing-box.forkop-backup.* /usr/lib/libcronet.so.forkop-backup.* /usr/bin/.sing-box.zm-keep 2>/dev/null + rm -rf /tmp/netshift-sbext.* 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" +} + +# Удаляет sing-box целиком: пакет любого варианта, файл, поставленный не пакетом (расширенный), +# libcronet, сжатое ядро, настройки и кэш. Нужен другим программам — оставляет. +_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 + if [ -z "$v" ] && [ -z "$had" ] && [ ! -e /usr/bin/sing-box ] && [ ! -e /etc/init.d/sing-box ]; then + _fk_say "sing-box не установлен — удалять нечего" + return 0 + fi + _fk_say "Удаляем sing-box${v:+ $v}${var:+ ($(_fk_sb_name "$var"))}" + if [ -x /etc/init.d/sing-box ]; then + /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 /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/lib/libcronet.so.forkop-backup.* /usr/bin/sing-box.forkop-backup.* \ + /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 forkop; do + for o in noresolv cachesize; do + if uci -q get "dhcp.$s.${pre}_$o" >/dev/null; then + v="$(uci -q get "dhcp.$s.${pre}_$o")" + if [ -n "$v" ]; then uci -q set "dhcp.$s.$o=$v"; else uci -q delete "dhcp.$s.$o"; fi + uci -q delete "dhcp.$s.${pre}_$o" + ch=1 + fi + done + if uci -q get "dhcp.$s.${pre}_server" >/dev/null; then + v="$(uci -q get "dhcp.$s.${pre}_server")" + uci -q delete "dhcp.$s.server" + for x in $v; do uci -q add_list "dhcp.$s.server=$x"; done + uci -q delete "dhcp.$s.${pre}_server" + ch=1 + fi + done + uci -q get "dhcp.$s.netshift_configured" >/dev/null && { uci -q delete "dhcp.$s.netshift_configured"; ch=1; } + for v in $(uci -q get "dhcp.$s.server"); do + case "$v" in 127.0.0.4[0-9]|127.0.0.4[0-9]#*) uci -q del_list "dhcp.$s.server=$v"; ch=1 ;; esac + done + left="$(uci -q get "dhcp.$s.server")" + if [ -z "$left" ] && [ "$(uci -q get "dhcp.$s.noresolv")" = 1 ]; then + uci -q delete "dhcp.$s.noresolv" + ch=1 + fi + [ "$(uci -q get "dhcp.$s.cachesize")" = 0 ] && { uci -q delete "dhcp.$s.cachesize"; ch=1; } + done + if [ "$ch" = 1 ]; then + uci -q commit dhcp + [ "$1" = q ] || echo " ✓ Настройки DNS роутера возвращены" + fi + return 0 +} + +_fk_ip_clean() { + local f l pr t + for t in netshift forkop; do + for f in "" -6; do + ip $f rule show 2>/dev/null | grep -i "lookup $t" | while IFS= read -r l; do + pr="${l%%:*}" + ip $f rule del pref "$pr" >/dev/null 2>&1 + done + ip $f route flush table "$t" >/dev/null 2>&1 + done + grep -qi "[[:space:]]$t\$" /etc/iproute2/rt_tables 2>/dev/null && sed -i "/[[:space:]]$t\$/d" /etc/iproute2/rt_tables + done + if ip rule show 2>/dev/null | grep -qiE 'netshift|forkop'; 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 2$' "$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 2 +_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" '{ gsub(/\r/, ""); sub(/^[ \t]+/, ""); sub(/[ \t]+$/, ""); if ($0 == "") next + d = tolower($0); 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_tmp="$(mktemp)" + _zm_fast_domains "$plain_list_filepath" "$chunk_size" > "$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" + 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 +} + +# «Скачивать списки через VPN»: движок качает списки через sing-box, но запасного пути у него нет — если VPN +# в этот момент не отвечает (подписка ещё не скачалась, сервер лёг), проверка GitHub проваливается и не +# скачивается ни один список, а после перезапуска наборы правил пустые до следующего планового обновления. +# Дописываем то же, что у Steer: VPN не ответил три раза подряд, а напрямую GitHub открывается — в этот раз +# списки качаются напрямую. Подписки и наборы правил sing-box по-прежнему идут через VPN. +_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 +} + +# Движок запускают, останавливают и перезапускают сразу несколько участников: панель, сторож из cron, +# отложенная загрузка подписки, сторож sing-box внутри движка и сам движок после смены ядра sing-box. Две +# такие операции одновременно ломают друг друга: остановка одной стирает /tmp/sing-box/rulesets, пока +# другая уже запускает sing-box, — и он падает с FATAL «open …/rulesets/…json: no such file or directory», +# а вместе с ним пропадают и серверы подписки. Дописываем в движок общий замок: запуск, остановка, +# перезапуск, применение подписки и подъём упавшего sing-box идут строго по очереди. Перед запуском +# sing-box и перед перечитыванием конфига недостающий набор правил создаётся пустым: sing-box не падает, +# а списки наполнят набор при следующей загрузке. Правка всё-или-ничего: нет хоть одного места — движок не трогаем. +_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 +# Zapret Manager: start, stop, restart, a subscription apply and the crash recovery of the health +# monitor run one at a time. Run in parallel, the stop of one wiped $TMP_RULESET_FOLDER while the +# other was starting sing-box, and sing-box aborted with "open .../rulesets/.json: no such file". +# A lock left by a killed process is taken over. Before sing-box starts or re-reads its config, a +# missing local source rule set is recreated empty, so sing-box keeps running until lists refill it. +ZM_SERIAL_DIR="/var/run/netshift-zm-serial" + +# The pid of this very shell (a subshell included): `read` is a builtin, /proc/self is not forked. +zm_serial_me() { + local zm_rest + read -r ZM_SERIAL_ME zm_rest < /proc/self/stat +} + +# 0 while another live process holds the lock. +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 +} + +# Removes the lock of a process that is gone. Takeovers run one at a time, and the lock is removed +# only while it still names the owner seen gone: a fresh lock of a live process cannot be removed, +# because nobody else can create the lock while the stale one is still there. +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 + # released between the two checks: try again at once; only a lock that cannot be created + # at all (no space, no /var/run) is given up after a few tries + 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]*) + # the owner is writing its pid right now; a directory that stays empty lost its owner in between + 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 +} + +# Every local source rule set of the config (default: settings.config_path) must exist before +# sing-box opens it: a missing one makes sing-box abort and take routing and DNS with it. +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 +} + +# A subscription apply rebuilds the rule sets and may restart NetShift: under the lock. +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 +} + +# Серверы REALITY на Xray 26.9.8+ (в ссылке support-x25519mlkem768=true) пускают только клиента с ключом +# X25519MLKEM768. Он есть лишь в отпечатке chrome, и оставить его умеет только расширенный sing-box 2.7.2+ +# (поле support_x25519mlkem768). Сам движок делает это флагом reality_mlkem на всю секцию и только для +# fp=chrome — а старым серверам ключ может мешать. Поэтому дописываем в движок поштучно: ссылке с этим +# параметром — chrome и ключ; узлу подписки — то же, а при сборке подписки ключ остаётся только у таких +# узлов и только на подходящем ядре. Остальные ссылки и узлы не меняются. +_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 + # место вставки — единственный вызов сборки TLS внутри _add_outbound_security + [ "$(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 + # Zapret Manager: ссылка с support-x25519mlkem768=true — сервер REALITY на Xray 26.9.8+, он пускает только + # клиента с ключом X25519MLKEM768. Ключ есть лишь в отпечатке chrome, поле — только у расширенного sing-box + # 2.7.2+. Разбор подписки (тег <секция>-fbN-out) идёт в кэш, поле там потом оставляет или убирает сборка + # подписки по ядру — поэтому в подписке помечаем всегда, в ссылках из настроек — только на нужном ядре. + 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 +} + +# Признак ссылки или подписки, которой нужен ключ X25519MLKEM768, и годится ли под неё нынешний sing-box. +_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_log_mark). +# Попытка, после которой список всё же скачался («Download failed [1/3]»), ошибкой не считается; +# если Forkozz отчитался, что списки загружены, про его попытки молчим совсем. +_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 +} + +# Подписку, которая при запуске не скачалась (sing-box в этот момент ещё не работал), движок догружает сам +# примерно через 10 с и применяет на ходу. Ждём это, чтобы списки и серверы ниже были уже с подпиской. +_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 +} + +# Серверы секций с прокси (подписка или свои ссылки) — то же, что показывает панель: сколько их, через какой +# сейчас идёт трафик, пинг. Clash API только что запущенного sing-box может ответить не сразу — до ~16 с. +_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 +} + +_fk_legacy_resume() { + [ -x /etc/init.d/forkop ] || return 0 + [ "$1" = 1 ] && /etc/init.d/forkop enable >/dev/null 2>&1 + [ "$2" = 1 ] && { _fk_say "Запускаем прежний движок Forkop обратно"; /etc/init.d/forkop start >/dev/null 2>&1; } + return 0 +} + +# Removes the old Forkop engine (files, firewall, routes, cron, dnsmasq changes). sing-box stays. +# С аргументом remove (удаление Forkozz) DNS и маршруты уже вернул вызывающий — не повторяем. +_fk_legacy_remove() { + local p + if [ "$1" = remove ]; then _fk_say "Удаляем прежний движок Forkop" + else _fk_say "Убираем прежний движок Forkop (настройки уже перенесены)"; fi + if [ -x /etc/init.d/forkop ]; then + _zm_run 60 /etc/init.d/forkop stop >/dev/null 2>&1 + /etc/init.d/forkop disable >/dev/null 2>&1 + fi + [ -x /usr/bin/forkop ] && _zm_run 60 /usr/bin/forkop restore_dnsmasq >/dev/null 2>&1 + nft delete table inet ForkopTable >/dev/null 2>&1 + nft delete table inet ForkopTableDpiGuard >/dev/null 2>&1 + _zm_pkg_wipe luci-i18n-forkop-ru luci-app-forkop forkop + rm -rf /usr/lib/forkop /usr/lib/forkop.zm-old /usr/share/forkop /etc/forkop /var/run/forkop /tmp/forkop* "$JOBS_DIR/forkop-src" \ + /www/luci-static/resources/view/forkop /usr/share/luci/menu.d/luci-app-forkop.json /usr/share/rpcd/acl.d/luci-app-forkop.json + rm -f /usr/bin/forkop /etc/init.d/forkop /etc/rc.d/*forkop /etc/config/forkop /etc/config/forkop-opkg /etc/uci-defaults/*forkop* \ + /etc/hotplug.d/*/*forkop* "$ZM_STATE_DIR/latest.forkop" "$JOBS_DIR/forkop-sb.json" "$JOBS_DIR/forkop.conf.bak" /opt/zapret-manager-luci/forkop.uc + sed -i '/forkop/d' /etc/sysupgrade.conf 2>/dev/null + if grep -q 'forkop' "$CRON_FILE" 2>/dev/null; then + sed -i '/forkop/d' "$CRON_FILE" + /etc/init.d/cron restart >/dev/null 2>&1 + fi + if [ "$1" != remove ]; then + _fk_dnsmasq_clean q + _fk_ip_clean q + fi + echo " ✓ Прежний движок убран" +} + +do_fk_install() { + local mode="$1" tag tmp="$JOBS_DIR/netshift-src" tgz src rel major b need="" p free was_run=0 was_en=0 fresh=1 sbfree=0 restored=0 + local legacy=0 lconf="$JOBS_DIR/forkop-import" notes keep="$JOBS_DIR/netshift.conf.keep" + _fk_say "Проверяем роутер: версию OpenWrt, свободную память и конфликты" + rel="$(awk -F\' '/DISTRIB_RELEASE/ {print $2}' /etc/openwrt_release)" + major="${rel%%.*}" + case "$major" in ''|*[!0-9]*) ;; *) [ "$major" -ge 24 ] || { echo "ОШИБКА: Forkozz работает на OpenWrt 24.10 и новее, а здесь $rel"; return 1; } ;; esac + b="$(_fk_blocker)" + [ -n "$b" ] && { echo "ОШИБКА: $(_fk_blocker_text "$b")"; return 1; } + _fk_installed && fresh=0 + _fk_installed || ! _fk_legacy || legacy=1 + [ -z "$(_fk_sb_ver)" ] && sbfree=12288 + free="$(df -k /overlay 2>/dev/null | awk 'NR==2 {print $4}')" + [ -n "$free" ] || free="$(df -k / 2>/dev/null | awk 'NR==2 {print $4}')" + if [ -n "$free" ] && [ "$free" -lt $((4096 + sbfree)) ]; then + echo "ОШИБКА: мало памяти: свободно $((free / 1024)) МБ, нужно около $(((4096 + sbfree) / 1024)) МБ" + return 1 + fi + echo " ✓ OpenWrt $rel, свободно ${free:+$((free / 1024)) МБ}, конфликтов нет" + [ "$legacy" = 1 ] && _fk_say "Найден прежний движок Forkop — переводим Forkozz на новый движок и переносим настройки, секции и исключения" + + _fk_feeds_official || true + _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 + 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 [ "$legacy" = 1 ]; then + [ -x /etc/init.d/forkop ] && /etc/init.d/forkop enabled 2>/dev/null && was_en=1 + ls /etc/rc.d/S*forkop >/dev/null 2>&1 && was_en=1 + nft list table inet ForkopTable >/dev/null 2>&1 && pidof sing-box >/dev/null 2>&1 && was_run=1 + rm -rf "$lconf"; mkdir -p "$lconf" + if [ -s /etc/config/forkop ]; then cp /etc/config/forkop "$lconf/forkop" + elif [ -s "$FK_SAVE" ]; then cp "$FK_SAVE" "$lconf/forkop"; fi + [ -s "$lconf/forkop" ] && _fk_legacy_conf_fix "$lconf/forkop" + [ "$was_run" = 1 ] && { _fk_say "Останавливаем прежний движок на время перехода"; _zm_run 60 /etc/init.d/forkop stop >/dev/null 2>&1; } + elif _fk_foreign; then + _fk_say "Убираем отдельный пакет движка с LuCI, настройки оставляем" + _fk_up && was_run=1 + _fk_enabled && was_en=1 + [ -s /etc/config/netshift ] && cp /etc/config/netshift "$keep" + [ "$was_run" = 1 ] && _zm_run 60 /etc/init.d/netshift stop >/dev/null 2>&1 + for p in luci-i18n-netshift-ru luci-app-netshift netshift; do _pkg_is_installed "$p" && $DELETE "$p" >&2; done + rm -rf /www/luci-static/resources/view/netshift /usr/share/luci/menu.d/luci-app-netshift.json /usr/share/rpcd/acl.d/luci-app-netshift.json + [ -s "$keep" ] && cp "$keep" /etc/config/netshift + rm -f "$keep" + fresh=0 + elif [ "$fresh" = 0 ]; then + _fk_up && was_run=1 + _fk_enabled && was_en=1 + [ "$was_run" = 1 ] && { _fk_say "Останавливаем Forkozz на время обновления"; _zm_run 60 /etc/init.d/netshift stop >/dev/null 2>&1; } + fi + + _fk_say "Копируем файлы движка 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 + fi + mkdir -p /usr/lib/netshift /etc/netshift + if ! cp -R "$src/usr/lib/." /usr/lib/netshift/ || + ! cp "$src/usr/bin/netshift" /usr/bin/netshift || + ! cp "$src/etc/init.d/netshift" /etc/init.d/netshift || + ! cp "$src/etc/config/netshift" /usr/lib/netshift/default.config; then + _fk_restore_old + rm -rf "$tmp" + if [ "$legacy" = 1 ]; then _fk_legacy_resume "$was_en" "$was_run"; else _fk_resume "$was_en" "$was_run"; fi + echo "ОШИБКА: файлы не записались — похоже, кончилась память" + return 1 + fi + chmod 0755 /usr/bin/netshift /etc/init.d/netshift + chmod -R a+rX /usr/lib/netshift + _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 + [ "$legacy" = 1 ] || _fk_say "DNS по умолчанию: Quad9 9.9.9.9 (обычный UDP). Сменить можно в «Настройках»" + fi + chmod 0644 /etc/config/netshift + _fk_log_default + + if [ "$legacy" = 1 ]; then + if [ -s "$lconf/forkop" ]; then + _fk_say "Переносим настройки Forkop: подключения, секции, списки, свои домены, исключения, устройства, DNS" + notes="$(_fk_uc import "$lconf" 2>&1)" + case "$notes" in + *'"ok": true'*|*'"ok":true'*) + printf '%s' "$notes" | jsonfilter -e '@.notes[*]' 2>/dev/null | sed 's/^/ ! /' + echo " ✓ Настройки перенесены" + restored=1 ;; + *) + b="$(printf '%s' "$notes" | jsonfilter -e '@.error' 2>/dev/null)" + echo "ОШИБКА: настройки Forkop не перенеслись${b:+: $b}. Прежний движок возвращён на место" + rm -rf /usr/lib/netshift /usr/bin/netshift /etc/init.d/netshift /etc/config/netshift + _fk_restore_old + _fk_legacy_resume "$was_en" "$was_run" + return 1 ;; + esac + fi + _fk_legacy_remove + rm -rf "$lconf" + fi + 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 не запустился — нажмите «Проверить»" + fi + if [ "$legacy" = 1 ]; then + _fk_say "Готово: Forkozz переведён на новый движок $tag, настройки перенесены" + elif [ "$mode" = reinstall ] && [ "$fresh" = 0 ]; then + _fk_say "Готово: Forkozz $tag переустановлен, настройки сохранены" + elif [ "$restored" = 1 ]; then + _fk_say "Готово: Forkozz $tag установлен с прежними настройками. Нажмите «Включить»" + elif [ "$fresh" = 1 ]; then + _fk_say "Готово: Forkozz $tag установлен. Настройте подключение и сервисы" + else + _fk_say "Готово: Forkozz обновлён до $tag" + fi +} + +# AmneziaWG-туннели, которые Forkozz использовал как подключение (connection_type=vpn). Читать нужно ДО удаления его конфига. +_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_name":"%s","singbox_users":"%s","awg_installed":%s,"awg_busy":"%s"}\n' "$(esc "$v")" "$(esc "$([ -n "$v" ] && _fk_sb_name "$(_fk_sb_var)")")" "$(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 +} + +# mode: stop — выключить туннель (настройки остаются, auto=0); delete — удалить созданные панелью; purge — удалить все; keep — не трогать +_fk_tunnels_off() { + local mode="$1" i p + shift + for i in "$@"; do + 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 p 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 /etc/init.d/netshift stop >/dev/null 2>&1 + fi + if [ -x /etc/init.d/forkop ]; then + /etc/init.d/forkop disable >/dev/null 2>&1 + _zm_run 60 /etc/init.d/forkop 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 ForkopTable >/dev/null 2>&1 + nft delete table inet ForkopTableDpiGuard >/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_legacy && _fk_legacy_remove remove + _fk_say "Удаляем файлы, настройки и кэш Forkozz" + _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 /usr/lib/netshift /usr/lib/netshift.zm-old /etc/netshift /tmp/netshift* /tmp/sing-box/rulesets /tmp/sing-box/subscription* "$JOBS_DIR/netshift-src" \ + /www/luci-static/resources/view/netshift /usr/share/luci/menu.d/luci-app-netshift.json /usr/share/rpcd/acl.d/luci-app-netshift.json \ + /usr/bin/netshift /etc/init.d/netshift /etc/rc.d/*netshift /etc/config/netshift /etc/config/netshift-opkg /etc/config/netshift-070 /var/run/netshift* \ + "$FK_SAVE" "$FK_MARK" "$ZM_STATE_DIR/latest.netshift" "$ZM_STATE_DIR"/fk.sub.* "$ZM_STATE_DIR"/fk.fell* "$ZM_STATE_DIR"/fk.home.* "$ZM_STATE_DIR/fk.dnsfo" "$ZM_STATE_DIR/fk.watch" "$FK_EXT_FAIL" "$JOBS_DIR/netshift.conf.bak" \ + "$JOBS_DIR"/fk.heal* "$JOBS_DIR/forkop-svc.out" "$JOBS_DIR/forkop-sb.json" && + echo " ✓ Файлы, настройки, кэш и задания расписания удалены" || rc=1 + if [ "$sbmode" = keep ]; then _fk_sb_park; else _fk_sb_purge || rc=1; fi + if [ "$awg" = remove ] && _awg_installed; then + _fk_say "Удаляем AmneziaWG — больше он нигде не используется" + ZM_NO_NETCHECK=1 do_awg_remove all || rc=1 + fi + _fk_feeds_official || true + rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* /var/luci-indexcache* 2>/dev/null + _zm_after_remove "Forkozz" + _zm_rm_done "$rc" "Forkozz" +} + +do_fk_sb_remove() { + _fk_installed && { echo "ОШИБКА: Forkozz установлен — без sing-box он не работает. Сначала удалите Forkozz"; return 1; } + local u + u="$(_fk_sb_users)" + [ -n "$u" ] && { echo "ОШИБКА: sing-box нужен другим программам ($u) — удалять его нельзя"; return 1; } + if [ -z "$(_fk_sb_ver)" ] && [ -z "$(_fk_sb_pkg)" ] && [ ! -e /usr/bin/sing-box ] && [ ! -e /etc/init.d/sing-box ]; then + _fk_say "Готово: sing-box уже не установлен — удалять нечего" + return 0 + fi + _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 +} + +# Ждёт, пока Forkozz не просто поднимется, а закончит запуск: правила и sing-box видны раньше, чем движок +# соберёт конфиг до конца, а запущенный procd движок работает в фоне. Если к концу срока движок ещё занят +# (качает подписку, проверяет конфиг на медленном роутере) — даём ему ещё до 150 с, а не рвём запуск. +_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' | grep -iE 'error|fatal|fail|abort|ошиб|не удалось' | awk '!s[$0]++' | tail -n 8)" + [ -n "$lines" ] && printf '%s\n' "$lines" | sed 's/^/ /' + return 0 +} + +do_fk_service() { + local a="$1" b i=0 + _fk_installed || { echo "ОШИБКА: Forkozz не установлен"; return 1; } + case "$a" in + stop) + _fk_say "Выключаем Forkozz — трафик пойдёт напрямую" + /etc/init.d/netshift disable >/dev/null 2>&1 + echo " ✓ Убран из автозапуска" + _zm_run 90 /etc/init.d/netshift stop >/dev/null 2>&1 + nft delete table inet "$FK_NFT" >/dev/null 2>&1 + if nft list table inet "$FK_NFT" >/dev/null 2>&1; then echo "!! Служба остановлена, но её правила в firewall остались — перезагрузите роутер" + else echo " ✓ Служба остановлена, правила сняты"; fi + # устройства без блокировки рекламы спрашивали DNS у sing-box — теперь у тех же серверов, что и все + _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_log_default + 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 + # после подписки: ей может понадобиться расширенный sing-box — со сменой ядра и перезапуском; + # списки и серверы ниже смотрим уже у того движка, который останется работать + _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_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 + # вывод самой загрузки — только если она не удалась; попытки, за которыми был повтор («[1/3]»), не ошибки + [ "$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 "Готово: списки обновлены" +} + +do_fk_subs() { + _fk_installed || { echo "ОШИБКА: Forkozz не установлен"; return 1; } + _fk_say "Обновляем подписки" + 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 "Готово: подписки обновлены" +} + +# Расширенный sing-box сам, когда он нужен. Облегчённый (sing-box-tiny) и обычный не умеют XHTTP, а +# Forkozz без расширенного пропускает VMess-ссылки; облегчённый ещё и без WireGuard. Смотрим ссылки в +# настройках, кэш подписок и отказы в журнале за этот запуск — и ставим расширенный, не дожидаясь, +# пока человек найдёт переключатель в «Настройках». Не встал (мало места) — не повторяем полсуток. +FK_EXT_FAIL="$ZM_STATE_DIR/fk.ext.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'; } +# Журнал sing-box по умолчанию выключен (уровень panic — только аварии). Один раз: дальше выбор в панели. +_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 +} +# Почему серверам нужен расширенный sing-box. ПОРА: config — только ссылки в настройках, all — ещё +# подписки и (если дан ДО) отказы в журнале. ВАРИАНТ — под какой sing-box проверяем (по умолчанию нынешний). +_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 + # расширенный уже стоит; обновлять его нужно, только если он старше 2.7.2, а серверам нужен ключ X25519MLKEM768 + _fk_mlkem_core_ok && return 1 + _fk_mlkem_why "$1" + return + fi + _fk_ext_why "$1" "$2" +} +# ПОРА [ДО] [restart|norestart]: config — только ссылки в настройках (до запуска), all — ещё подписки и журнал +_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 + _fk_say "${why} — нынешний sing-box их не умеет, ставим расширенный" + ZM_FK_SB_NESTED=1 + do_fk_singbox extended "$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 +} + +# Ставит, переустанавливает или меняет вариант sing-box: tiny — облегчённый, stable — обычный (пакеты OpenWrt), +# extended — расширенный с GitHub. Тот же вариант, что стоит, — переустановка (расширенный при этом +# обновляется до свежей сборки). Не встал новый — остаётся прежний. +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) ;; *) echo "ОШИБКА: неизвестный вариант sing-box"; return 1 ;; esac + old="$(_fk_sb_ver)"; oldvar="$(_fk_sb_var)" + if [ "$want" != extended ] && 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 ;; + *) 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 МБ" + _zm_wait "" /usr/bin/netshift component_action sing_box install_extended > "$out" 2>&1 + 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" + # движок после установки ядра сам перезапускает Forkozz (в фоне); удаление прежнего пакета + # останавливает sing-box и на миг убирает его файл — поэтому сначала даём перезапуску закончиться + _fk_settle 180 || true + _fk_sb_drop_pkgs ;; + *) + _zm_net_prepare "$(_zm_feed_host)" || return 1 + $UPDATE >/dev/null 2>&1 || echo " ! Список пакетов не обновился — пробуем с прежним" + _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 path url code tm res ms="" ok=0 how="" + case "$t" in + udp) + host="${v%:*}"; port="${v##*:}"; [ "$host" = "$v" ] && port=53 + res="$(dig @"$host" -p "$port" example.com A +time=3 +tries=1 2>&1)" + echo "$res" | grep -q 'status: NOERROR' && { ok=1; ms="$(echo "$res" | sed -n 's/.*Query time: \([0-9]*\) msec.*/\1/p' | head -n1)"; } + how=udp ;; + dot) + host="${v%:*}"; port="${v##*:}"; [ "$host" = "$v" ] && port=853 + res="$(dig +tls @"$host" -p "$port" example.com A +time=4 +tries=1 2>&1)" + if echo "$res" | grep -q 'status: NOERROR'; then + ok=1; ms="$(echo "$res" | sed -n 's/.*Query time: \([0-9]*\) msec.*/\1/p' | head -n1)"; how=dot + else + res="$(curl -sk -o /dev/null -w '%{time_appconnect}' --connect-timeout 4 --max-time 5 "https://$host:$port/" 2>/dev/null)" + tm="$(_fk_dns_ms "$res")" + [ "$tm" -gt 0 ] 2>/dev/null && { ok=1; ms="$tm"; how=tls; } + fi ;; + doh) + case "$v" in + https://*) url="$v"; case "${url#https://}" in */*) ;; *) url="$url/dns-query" ;; esac ;; + */*) url="https://$v" ;; + *) url="https://$v/dns-query" ;; + esac + case "$url" in *\?*) url="$url&dns=$FK_DNS_Q" ;; *) url="$url?dns=$FK_DNS_Q" ;; esac + res="$(curl -s -o "$f" -w '%{http_code} %{time_total}' -H 'accept: application/dns-message' --connect-timeout 4 --max-time 6 "$url" 2>/dev/null)" + code="${res%% *}"; tm="${res##* }" + if [ "$code" = 200 ] && [ "$(wc -c < "$f" 2>/dev/null || echo 0)" -ge 29 ]; then ok=1; ms="$(_fk_dns_ms "$tm")"; fi + rm -f "$f"; how=doh ;; + esac + [ "$ok" = 1 ] && [ "${ms:-0}" -lt 1 ] 2>/dev/null && ms=1 + printf '%s|%s|%s|%s\n' "$v" "$ok" "${ms:-0}" "$how" > "$out" +} + +fk_dns_test() { + local t="${1%%|*}" list="${1#*|}" d="$JOBS_DIR/fkdns.$$" v i=0 sep="" line ok ms how + case "$t" in udp|dot|doh) ;; *) echo '{"error":"неизвестный тип DNS"}'; return 1 ;; esac + command -v dig >/dev/null 2>&1 || [ "$t" = doh ] || { echo '{"error":"нет программы dig — нажмите «Переустановить» Forkozz"}'; return 1; } + mkdir -p "$d" + for v in $list; do + case "$v" in *[!A-Za-z0-9.:/_?=%-]*|'') continue ;; esac + i=$((i + 1)) + [ "$i" -gt 16 ] && break + _fk_dns_probe "$t" "$v" "$d/$i" & + done + wait + printf '{"type":"%s","results":[' "$t" + for v in $(ls "$d" 2>/dev/null | sort -n); do + IFS='|' read -r line ok ms how < "$d/$v" + printf '%s{"server":"%s","ok":%s,"ms":%s,"how":"%s"}' "$sep" "$(esc "$line")" "$([ "$ok" = 1 ] && echo true || echo false)" "${ms:-0}" "$how" + sep="," + done + printf ']}\n' + rm -rf "$d" +} + +forkop_config_get() { + _fk_installed || { echo '{"error":"Forkozz не установлен"}'; return 1; } + if [ ! -f "$ST_CAT_OFF" ]; then + if [ -s "$ST_CAT_IDX" ] && ! head -n1 "$ST_CAT_IDX" | grep -q '^[^|]*|[^|]*|[^|]*|[sd]$'; then + [ -d "$ST_RUN/cat.lock" ] || ( _st_cat_refresh force >/dev/null 2>&1 & ) + else + _st_cat_bg + fi + fi + ZM_FK_CAT="$(_st_cat_json)" _fk_ucj get "$1" +} + +forkop_config_set() { + local res + _fk_installed || { echo '{"error":"Forkozz не установлен"}'; return 1; } + _job_running forkop && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } + res="$(printf '%s' "$1" | _fk_ucj set)" || { echo "$res"; return 1; } + rm -f "$ZM_STATE_DIR"/fk.fell "$ZM_STATE_DIR"/fk.fell.* "$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" +} + +# Где лежит домен или адрес: в каких списках Steer он есть — чтобы «Куда пойдёт запрос» называл не только +# выход, но и список со строкой, которая сработала. Ядро (steer explain) называет только канал, поэтому +# списки проверяем сами, теми же правилами, что и ядро: домен — с поддоменами, «=имя» — только это имя, +# «*.имя» — только поддомены, адрес — по подсетям и диапазонам. Наборы .srs проверяет sing-box, если он есть. +# Порядок: исключения, выбранные сервисы (как их ведёт ядро), затем невыбранные сервисы с уже скачанными +# списками (ничего не качаем — ответ должен быть быстрым). Печатает JSON-массив. +_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 + awk -F'\t' '$1 == "svc" { id = $2; nm = $3; 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" + 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" + # наборы .srs (каталог списков) ядро steer само раскладывает в текст: steer srs-read. Раскладку кладём + # рядом и берём заново, только когда набор обновился. Пятое поле строки — исходный файл, для подписи. + 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.*) 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" +} + +# Журнал имён («Недавние сайты»): ядро ставит у имени имя КАНАЛА, а не правила. Правила с одним выходом +# (у Steer это все выбранные сервисы — они идут в один туннель) ядро сливает в общий набор и зовёт его по +# первому доменному правилу. Поэтому forum.ru-board.com из «Заблокированных сайтов» показывался как +# «WhatsApp», если WhatsApp выбран раньше. Настоящий сервис находим сами — по спискам выбранных сервисов +# (model.svc) теми же правилами, что ядро и _st_where: домен — с поддоменами, «=имя» — только имя, «*.имя» — +# только поддомены. Из больших списков берём лишь строки, которые вообще могут подойти к именам журнала, — +# памяти почти не нужно. Имя есть в нескольких выбранных сервисах — называем все по порядку правил. Имя не +# нашлось в текстовых списках (попало по подсети, по ключевому слову .srs) — сервис не называем, лишь выход. +_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) + # та же раскладка .srs в текст, что у «Куда пойдёт запрос» (_st_where), — общий кэш + 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" + # переписываем поле channel у имён журнала; объекты upstreams/other с «name» без channel не трогаем + 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" +} + +steer_explain() { + local q host="" ip="" fd where + q="$(_zm_route_target "$1")" || { echo '{"error":"введите домен (например, youtube.com) или IP-адрес"}'; return 1; } + stl_present || { echo '{"error":"Steer не установлен"}'; return 1; } + 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 + node="$(cat "$ST_SUB_NODE" 2>/dev/null)" + [ "$STL_X_VERDICT" = vpn ] && stl_state vpn && [ "$STL_UP" = true ] && [ -n "$STL_NODE" ] && node="$(_st_active_key)" && node="${node:-$STL_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")" + # выход «напрямую» из-за своих исключений — отдельный ответ, а не «Steer этот адрес не трогает» + [ "$STL_X_VERDICT" = direct ] && [ "$STL_X_OUT" = "$ST_DIRECT_OUT" ] && case "$where" in *'"state":"x"'*) STL_X_VERDICT=exclude ;; esac + printf '{"target":"%s","verdict":"%s","out":"%s","dev":"%s","channel":"%s","addr":"%s","fake":%s,"node":"%s","probe":"%s","where":%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_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 + # у самого имени нет адреса, а DNS роутера жив: путь показываем по поддомену с адресом (правило списка + # действует на все поддомены), иначе честно отвечаем «у имени нет адреса», а не «DNS не работает» + 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.*|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)" + # в каких списках секций нашёлся адрес — массив JSON, последний ключ ответа ucode + 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 + 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) _fk_ucj diag "$arg" ;; + 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 +} + +SI_OUT="$JOBS_DIR/sysinfo.json" + +_si_clean() { printf '%s' "$1" | tr '\t\r' ' ' | tr -d '\000-\010\013\014\016-\037'; } + +_si_row() { + printf '%s["%s","%s","%s"]' "$SI_SEP" "$(esc "$(_si_clean "$1")")" "$(esc "$(_si_clean "$2")")" "$3" + SI_SEP="," +} + +_si_ping() { + local t + t="$(ping "$1" -c 1 -W 2 google.com 2>/dev/null | sed -n 's/.*time=\([0-9.]*\).*/\1/p' | head -n1)" + 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 line i name v list="$JOBS_DIR/sysinfo.urls" res="$JOBS_DIR/sysinfo.res" out="$SI_OUT.tmp" + echo "==> Собираем данные о роутере" + rel="$(. /etc/openwrt_release 2>/dev/null; echo "$DISTRIB_RELEASE")" + arch="$(. /etc/openwrt_release 2>/dev/null; echo "$DISTRIB_ARCH")" + model="$(cat /tmp/sysinfo/model 2>/dev/null)" + [ -n "$model" ] || model="$(jsonfilter -s "$(ubus call system board 2>/dev/null)" -e '@.model' 2>/dev/null)" + kern="$(uname -r)" + up="$(cut -d. -f1 /proc/uptime 2>/dev/null)"; up="${up:-0}" + d=$((up / 86400)); h=$((up % 86400 / 3600)); m=$((up % 3600 / 60)) + mt="$(awk '/^MemTotal:/ {print int($2 / 1024)}' /proc/meminfo)" + mf="$(awk '/^MemAvailable:/ {print int($2 / 1024)}' /proc/meminfo)" + fa="$(df -k /overlay 2>/dev/null | awk 'NR==2 {print $2, $3, $4}')" + [ -n "$fa" ] || fa="$(df -k / 2>/dev/null | awk 'NR==2 {print $2, $3, $4}')" + set -- $fa; ft="$1"; fu="$2" + set -- $(df -k /tmp 2>/dev/null | awk 'NR==2 {print $2, $3}'); tt="$1"; tu="$2" + sw="$(uci -q get firewall.@defaults[0].flow_offloading)" + hw="$(uci -q get firewall.@defaults[0].flow_offloading_hw)" + fix="$(_flow_offloading_fix_applied)" + + { + printf '{"ts":"%s","sections":[' "$(date '+%d.%m.%Y %H:%M')" + SI_SEP="" + printf '{"title":"Роутер","rows":[' + _si_row "Модель" "${model:-—}" "" + _si_row "OpenWrt" "${rel:-—}" "" + _si_row "Архитектура" "${arch:-—}" "" + _si_row "Ядро" "$kern" "" + _si_row "Работает" "$([ "$d" -gt 0 ] && printf '%s д ' "$d")$(printf '%d ч %02d мин' "$h" "$m")" "" + [ -n "$ft" ] && _si_row "Флеш" "занято $((fu / 1024)) МБ из $((ft / 1024)) МБ" "" + [ -n "$tt" ] && _si_row "/tmp" "занято $((tu / 1024)) МБ из $((tt / 1024)) МБ" "" + printf ']}' + } > "$out" + + echo "==> Проверяем сеть" + prov="$(curl -fsSL --connect-timeout 3 --max-time 5 https://ipinfo.io/org 2>/dev/null | sed -E 's/^AS[0-9]+ ?//; s/\b(OJSC|PJSC|JSC|LLC|Inc\.?|Ltd\.?)\b//g; s/[.,]//g; s/ +/ /g; s/^ +| +$//g')" + rate="$(curl -fsS --connect-timeout 3 --max-time 5 https://api.github.com/rate_limit 2>/dev/null | grep -o '"remaining":[[:space:]]*[0-9]*' | head -n1 | grep -o '[0-9]*$')" + { + SI_SEP="" + printf ',{"title":"Сеть","rows":[' + [ -n "$prov" ] && _si_row "Провайдер" "$prov" "" + _si_ping -4 "Google по IPv4" + _si_ping -6 "Google по IPv6" + if [ -n "$rate" ]; then + [ "$rate" -gt 0 ] && _si_row "GitHub API" "работает, лимит $rate" ok || _si_row "GitHub API" "лимит исчерпан" bad + else + _si_row "GitHub API" "недоступен" bad + fi + if [ "$hw" = 1 ]; then _si_row "Flow Offloading" "аппаратный" warn + elif [ "$sw" = 1 ]; then _si_row "Flow Offloading" "программный" warn + else _si_row "Flow Offloading" "выключен" ok; fi + [ "$fix" = true ] && _si_row "FIX для Flow Offloading" "применён" ok || _si_row "FIX для Flow Offloading" "не применён" "$([ "$sw" = 1 ] || [ "$hw" = 1 ] && echo bad)" + if /etc/init.d/https-dns-proxy running >/dev/null 2>&1 && [ -f /etc/config/https-dns-proxy ]; then + sed -n "s/^[[:space:]]*option resolver_url '\([^']*\)'.*/\1/p" /etc/config/https-dns-proxy | while IFS= read -r v; do _si_row "DNS over HTTPS" "$v" ""; SI_SEP=","; done + fi + _fk_installed && _si_row "Forkozz" "$(_fk_up && echo работает || echo выключен)" "$(_fk_up && echo ok)" + printf ']}' + } >> "$out" + + echo "==> Смотрим Zapret" + st="$(status)" + zv="$(jsonfilter -s "$st" -e '@.zapret_version' 2>/dev/null)" + zr="$(jsonfilter -s "$st" -e '@.zapret_running' 2>/dev/null)" + zs="$(jsonfilter -s "$st" -e '@.strategy' 2>/dev/null)" + { + SI_SEP="" + printf ',{"title":"Zapret","rows":[' + if [ -f /etc/init.d/zapret ]; then + _si_row "Состояние" "$([ "$zr" = true ] && echo запущен || echo остановлен)" "$([ "$zr" = true ] && echo ok || echo bad)" + _si_row "Версия" "${zv:-—}" "" + _si_row "Стратегия" "${zs:-—}" "" + tcp="$(sed -n "s/^[[:space:]]*option NFQWS_PORTS_TCP[[:space:]]*'\(.*\)'.*/\1/p" "$CONF" | head -n1)" + udp="$(sed -n "s/^[[:space:]]*option NFQWS_PORTS_UDP[[:space:]]*'\(.*\)'.*/\1/p" "$CONF" | head -n1)" + _si_row "Порты TCP" "${tcp:-—}" "" + _si_row "Порты UDP" "${udp:-—}" "" + _si_row "IPv6 в Zapret" "$([ "$(_ipv6_enabled_in_zapret)" = true ] && echo включён || echo выключен)" "" + else + _si_row "Состояние" "не установлен" "" + fi + printf ']}]' + opt="" + [ -f "$CONF" ] && opt="$(uci -q get zapret.config.NFQWS_OPT 2>/dev/null)" + printf ',"strategy":"%s"' "$(esc_ml "$(_si_clean "$opt")")" + hosts="" + if [ -f "$HOSTS_FILE" ] && ! grep -qE 'geohide\.ru|mafioznik|dns\.malw\.link' "$HOSTS_FILE"; then + hosts="$(grep '^#' "$HOSTS_FILE" | cut -c2- | sed 's/^[[:space:]]*//' | grep -v '^$' | while IFS= read -r v; do printf '"%s",' "$(esc "$(_si_clean "$v")")"; done)" + fi + printf ',"hosts":[%s]' "${hosts%,}" + pk="$(_si_user_pkgs)" + printf ',"packages":[%s]' "$(printf '%s\n' "$pk" | grep -v '^$' | while IFS= read -r v; do printf '"%s",' "$(esc "$v")"; done | sed 's/,$//')" + } >> "$out" + + _test_prepare_urls "$list" + all="$(grep -c '|' "$list")" + echo "==> Проверяем доступность сайтов: $all" + _zm_wait "" _si_sites "$list" "$res" + ok="$(grep -c '|1$' "$res")" + { + printf ',"sites_ok":%s,"sites_total":%s,"sites":[' "${ok:-0}" "${all:-0}" + sort -t'|' -k1,1n "$res" | awk -F'|' "$ZM_JESC_AWK"'{ printf "%s{\"name\":\"%s\",\"ok\":%s}", (n++ ? "," : ""), jesc($2), ($3 == 1 ? "true" : "false") }' + printf ']}\n' + } >> "$out" + rm -f "$list" "$res" + mv "$out" "$SI_OUT" + echo "==> Готово: сайты открываются $ok из $all" +} + +_si_user_pkgs() { + local t0 + if [ "$PKG" = apk ]; then + if [ -s /rom/etc/apk/world ]; then + sed 's/[<>=~].*//' /rom/etc/apk/world | sort -u > "$JOBS_DIR/si.rom" + sed 's/[<>=~].*//' /etc/apk/world 2>/dev/null | sort -u | grep -vxF -f "$JOBS_DIR/si.rom" + else + sed 's/[<>=~].*//' /etc/apk/world 2>/dev/null | sort -u + fi + elif [ -s /rom/usr/lib/opkg/status ]; then + awk '/^Package:/ {p = $2} /^Status: install user/ {print p}' /rom/usr/lib/opkg/status | sort -u > "$JOBS_DIR/si.rom" + awk '/^Package:/ {p = $2} /^Status: install user/ {print p}' /usr/lib/opkg/status 2>/dev/null | sort -u | grep -vxF -f "$JOBS_DIR/si.rom" + else + t0="$(awk '/^Installed-Time:/ {print $2}' /usr/lib/opkg/status 2>/dev/null | sort -n | head -n1)" + awk -v t0="${t0:-0}" '/^Package:/ {p = $2; u = 0} /^Status: install user/ {u = 1} /^Installed-Time:/ { if (u && $2 > t0 + 600) print p }' /usr/lib/opkg/status 2>/dev/null | sort -u + fi + rm -f "$JOBS_DIR/si.rom" +} + +sysinfo_run() { + job_start sysinfo do_sysinfo +} + +sysinfo_get() { + [ -s "$SI_OUT" ] && cat "$SI_OUT" || echo '{"sections":[]}' +} + +redbtn_panel_gone() { + local f r=0 line dir=/etc/zm-redbtn run="$JOBS_DIR/redbtn" + _kill_match "redbtn_geo_watch" + if [ -x /etc/init.d/zm-geodns ]; then + /etc/init.d/zm-geodns stop >/dev/null 2>&1 + /etc/init.d/zm-geodns disable >/dev/null 2>&1 + fi + rm -f /etc/init.d/zm-geodns /etc/rc.d/*zm-geodns + for f in /tmp/dnsmasq.d/zm-geo.conf /tmp/dnsmasq.cfg*.d/zm-geo.conf; do + [ -f "$f" ] && { rm -f "$f"; r=1; } + done + if grep -q redbtn_geo_watch "$CRON_FILE" 2>/dev/null; then + sed -i '/redbtn_geo_watch/d' "$CRON_FILE" + /etc/init.d/cron restart >/dev/null 2>&1 + fi + if [ -s "$dir/hosts.ai" ]; then + while IFS= read -r line; do + [ -n "$line" ] || continue + _hosts_has_line "$line" || { _hosts_append "$line"; r=1; } + done < "$dir/hosts.ai" + _hosts_ai_migrate && r=1 + fi + if grep -qx 'pkg https-dns-proxy' "$dir/owned" 2>/dev/null; then + if [ -f "$DOH_OFF_FLAG" ] || ! /etc/init.d/https-dns-proxy enabled 2>/dev/null; then + /etc/init.d/https-dns-proxy stop >/dev/null 2>&1 + $DELETE https-dns-proxy >&2 + rm -f /etc/config/https-dns-proxy + r=1 + fi + fi + _doh_pkg || rm -f "$DOH_OFF_FLAG" + [ -f "$DOH_OFF_FLAG" ] && { /etc/init.d/https-dns-proxy stop >/dev/null 2>&1; /etc/init.d/https-dns-proxy disable >/dev/null 2>&1; } + [ "$r" = 1 ] && /etc/init.d/dnsmasq restart >/dev/null 2>&1 + [ -d "$dir" ] && _st_migrate + rm -rf "$dir" "$run" /opt/zapret-manager-luci/autobypass_video /usr/share/zm-redbtn/doh.conf + return 0 +} + +# ---------- Блокировка рекламы: списки HaGeZi в dnsmasq ---------- +# Списки качаются в $AB_DIR/src (домены по одному в строке, сжатые), из них собирается +# zm-adblock.conf со строками local=/домен/ и кладётся в папку настроек каждого dnsmasq. +# Папки dnsmasq живут в ОЗУ: после перезагрузки сторож (zm_watch) собирает файл заново из копии на флеше. +AB_DIR="$ZM_STATE_DIR/adblock" +AB_SET="$AB_DIR/settings" +AB_ALLOW="$AB_DIR/allow.txt" +AB_NAME="zm-adblock.conf" +AB_CRON_TAG="# zm-adblock" +# Устройства без блокировки: их DNS-запросы уходят во второй, маленький dnsmasq без списка, а он спрашивает +# те же серверы, что и основной (Forkozz и Steer для этих устройств работают как раньше) +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" +# Статистика (по желанию): dnsmasq пишет журнал запросов в ОЗУ — в /var/run/dnsmasq/, единственную папку, куда +# ему можно писать из своей «тюрьмы» procd. Раз в 2 минуты панель сворачивает журнал в почасовые счётчики +# (запросы, заблокировано, устройства, что блокируется чаще) и очищает его. Прочие сообщения dnsmasq (DHCP), +# которые на это время тоже идут в файл, панель возвращает в системный журнал. +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_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="$(sed -n "s/^$1=//p" "$AB_SET" 2>/dev/null | tail -n1)" + 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_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}" +} +# Список подключён ко всем запущенным dnsmasq +_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; }; } + +# Адреса DoH-серверов, которыми пользуется сам роутер (https-dns-proxy), никогда не блокируем +_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 +} +# Серверы, через которые ходят TG WS Proxy (Go, MTProto, Rust): Telegram и домены Cloudflare-прокси вида +# kws2.<домен>.co.uk. Списки блокировки закрывают их как подозрительные — тогда Telegram через прокси не +# работает. Свежий список доменов берём у Flowseal/tg-ws-proxy (его используют все три прокси) при обновлении +# списков блокировки; если не скачался — встроенный. Плюс домен sTGWS, если он стоит. +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 +} +# Список в репозитории зашифрован: у каждого домена буквы сдвинуты вперёд на их число, а .co.uk заменён на .com +_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 +} +# Серверы шифрованного DNS, которые предлагает сама панель (Forkozz — DoH и DoT, вкладка DNS over HTTPS), +# и те, что выбраны в Forkozz сейчас (в том числе свои). Списки блокировки (особенно «запрет чужих DoH») +# их закрывают — тогда Forkozz не может узнать адреса серверов и не отвечает. Поэтому они не блокируются. +_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 +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 +geohide.ru +eu.geohide.ru +us.geohide.ru +dns.malw.link +dns.zapret.moe +ZM_AB_DNS_EOF +} +# Для страницы: исключения, которые панель добавляет сама (группами). dns_own — серверы DNS, выбранные сейчас +# (Forkozz, сам роутер), которых нет во встроенном наборе. +_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" +} +# Набор серверов DNS поменялся (выбрали другой в Forkozz или на вкладке DoH, обновили панель) — пересобираем +# список блокировки из уже скачанного, без загрузки. Ждём, пока закончатся операции панели. +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 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_num "${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_num "$n") доменов${ver:+, версия $ver}" + return 0 +} + +# Собирает conf из скачанных списков и белого списка. Печатает число заблокированных доменов +_ab_build() { + local out="$1" ids id allow="$JOBS_DIR/adblock.allow" 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_dns_now > "$AB_DNS_STAMP" 2>/dev/null + { for id in $ids; do _ab_cat "$id"; done; } | awk -v af="$allow" -v dedup="$dedup" -v cf="$out.count" ' + BEGIN { while ((getline l < af) > 0) if (l != "") A[l] = 1; close(af) } + { + d = $0 + if (d == "") 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) print "server=/" a "/#"; print n + 0 > cf }' > "$out" || return 1 + rm -f "$allow" + n="$(cat "$out.count" 2>/dev/null)"; rm -f "$out.count" + [ "${n:-0}" -gt 0 ] || { echo "список получился пустым" >&2; return 1; } + echo "$n" +} + +# Подключает собранный conf ко всем dnsmasq. Если dnsmasq с ним не поднялся — возвращает как было +_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 + [ "$had" = 0 ] && [ -s "$d/$AB_NAME" ] && { cp -f "$d/$AB_NAME" "$bak" 2>/dev/null && had=1; } + 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 rm -f "$d/$AB_NAME"; done + 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 +} + +# Убирает список отовсюду, откуда dnsmasq может его взять: из папок, объявленных в его конфиге сейчас, и из +# всех папок экземпляров (/tmp/dnsmasq.<имя>.d). Раньше брались только объявленные — а если dnsmasq в этот +# момент перезапускали (Forkozz, Steer), его конфига не было, копия в папке экземпляра оставалась и +# подхватывалась снова: блокировка «выключалась» только перезагрузкой роутера. +_ab_unplug() { + local d + for d in $(_ab_dirs) /tmp/dnsmasq.d /tmp/dnsmasq.*.d $(uci -q show dhcp 2>/dev/null | sed -n "s/^dhcp\.[^.]*\.confdir='\(.*\)'\$/\1/p"); 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_dirs) /tmp/dnsmasq.d /tmp/dnsmasq.*.d; do [ -d "$d" ] && rm -f "${d:?}/zm-adblock-stats.conf"; done +} +_ab_stats_wipe() { + rm -f /tmp/zm-adblock-stats/h.* /tmp/zm-adblock-stats/chunk.* /tmp/zm-adblock-stats/folded /var/run/dnsmasq/zm-queries.log "$AB_STATS_ALL" "$AB_STATS_ALL.zm" + rmdir /tmp/zm-adblock-stats/.lock /tmp/zm-adblock-stats 2>/dev/null + 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 +} +# Свернуть накопившийся журнал в счётчик текущего часа. Замок: сторож cron и страница могут прийти разом. +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" + # прочие сообщения dnsmasq (DHCP и т. п.) — обратно в системный журнал + 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 +} +# Закрывшиеся часы (не текущий) прибавляем к итогам за всё время — один раз каждый: что уже сложено, +# помнит список folded в памяти (после перезагрузки почасовых файлов нет, значит и складывать нечего). +# Флеш пишется не чаще раза в час и только если был хоть один запрос. +_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")" + # S — с какого времени идёт счёт: первый сложенный час (у старых версий итогов за всё время не было) + ( cd "$AB_STATS_DIR" && awk -v s0="$(( $(for h in $todo; do echo "$h"; done | sort -n | head -n1) * 3600 ))" ' + $1 == "S" { S = $2 } + $1 == "T" { T += $2 } $1 == "B" { B += $2 } $1 == "C" { C[$2] += $3 } $1 == "X" { X[$2] += $3 } $1 == "D" { D[$2] += $3 } + 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] + }' $([ -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; } + # размер на флеше ограничен: 1000 самых частых доменов и 256 самых активных устройств + { 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" && ($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" + 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" +} +# Сводка за последние ЧАСЫ (1 или 24) или за всё время (all): запросы, заблокировано, устройства, +# что блокируется чаще и кто спрашивает больше. Всё время = итоги на флеше + ещё не сложенные часы. +_ab_stats_json() { + local per="${1:-24}" from f h list="" since s1 + 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 + awk -v per="$([ "$per" = all ] && echo 0 || echo "$per")" -v since="${since:-0}" -v lf="/tmp/dhcp.leases" ' + function js(v) { gsub(/[\\"]/, "", v); gsub(/[\001-\037]/, "", v); return v } + BEGIN { while ((getline l < lf) > 0) { split(l, a, " "); if (a[4] != "" && a[4] != "*") N[a[3]] = a[4] } close(lf) } + $1 == "T" { T += $2 } $1 == "B" { B += $2 } $1 == "C" { C[$2] += $3 } $1 == "X" { X[$2] += $3 } $1 == "D" { D[$2] += $3 } + END { + nc = 0; for (k in C) nc++ + printf "{\"on\":true,\"period\":%d,\"since\":%d,\"total\":%d,\"blocked\":%d,\"clients\":%d,\"top\":[", per, since, T, B, nc + 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}", (j ? "," : ""), js(best), js(N[best]), bn, X[best] + 0; delete C[best] } + printf "]}\n" + }' $list /dev/null +} + +# Остался ли где-нибудь список после выключения +_ab_leftover() { + local d + for d in $(_ab_dirs) /tmp/dnsmasq.d /tmp/dnsmasq.*.d; do [ -e "$d/$AB_NAME" ] && 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; } +# Конфиг второго dnsmasq: те же серверы и правила пересылки, что у основного (с Forkozz — его sing-box, +# поэтому выбранные сервисы идут через VPN), имена локальной сети спрашиваем у основного dnsmasq +_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 + rm -f "$AB_FREE_PID" "$AB_FREE_CONF" "$AB_FREE_CONF.zm-new" "$AB_FREE_CONF.macs" + return 0 +} +# Приводит всё к выбору: нет устройств или блокировка выключена — второго dnsmasq и правил нет; иначе он +# запущен с пересылкой, как у основного сейчас (Forkozz включили или выключили — перезапускается), и DNS +# выбранных устройств идёт в него. Не поднялся — правила снимаются: устройства ходят в обычный DNS. +_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_num "$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 ver="" vs="" 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"}\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")" +} + +# Есть ли домен (или домен уровнем выше) в скачанных списках и в белом списке +_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 + 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" + _ab_cron_sync + /etc/init.d/dnsmasq restart >/dev/null 2>&1 + # dnsmasq мог в эту секунду сам перезапускаться и вернуть копию — проверяем и добиваем + 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 ' ')" ;; + check) _ab_check "$m" ;; + *) echo '{"error":"неизвестное действие"}'; return 1 ;; + esac +} + +# Ежедневное обновление из cron +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 +} + +# Сторож: после перезагрузки или чужого перезапуска dnsmasq файл со списком мог пропасть +_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 ($r доменов)" + return 0 + fi + _zm_busy_job >/dev/null && return 0 + logger -t zapret-manager "Блокировка рекламы: нет готового списка ($r) — скачиваем заново" + job_start adblock do_ab_apply >/dev/null +} + +# Несколько запросов страницы за один запуск скрипта (метод multi). Каждый запуск — это разбор всех ~16 тысяч +# строк, и на слабом роутере он дороже самих ответов: страница при открытии спрашивает 4–8 вещей сразу. Здесь +# скрипт разбирается один раз, а запросы выполняются параллельно в подоболочках — каждый ровно так же, как +# отдельный вызов. Только чтение: состояния, списки, настройки; всё, что меняет роутер, идёт отдельными вызовами. +# Строка запроса: «методаргумент». Ответ: {"r":[ответ1,ответ2,…]}; не вышло — {"__fail":true} (страница +# тогда спросит этот метод отдельно). +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" + # ответ — один объект JSON; пусто или не похоже на объект — пусть страница спросит отдельно + if [ -s "$f" ] && [ "$(head -c 1 "$f")" = "{" ] && [ "$(tail -c 2 "$f" | tr -d '\n\r ')" = "}" ]; 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:*) [ -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" ;; + zapret_latest_version) zapret_latest_version ;; + zapret2_action) zapret2_action "$1" ;; + strategy_list_v) strategy_list_v ;; + strategy_set_v) strategy_set_v "$1" ;; + strategy_list_flowseal) strategy_list_flowseal "$1" ;; + strategy_set_flowseal) strategy_set_flowseal "$1" ;; + strategy_list_youtube) strategy_list_youtube "$1" ;; + strategy_set_youtube) strategy_set_youtube "$1" ;; + youtube_quic_set) youtube_quic_set "$1" ;; + discord_status) discord_status ;; + discord_set_dv) discord_set_dv "$1" ;; + discord_set_fake) discord_set_fake "$1" ;; + game_status) game_status ;; + game_set) game_set "$1" ;; + game_set_fake) game_set_fake "$1" ;; + game_toggle_xtreme) game_toggle_xtreme ;; + system_status) system_status ;; + system_check_connectivity) system_check_connectivity ;; + system_toggle_quic) system_toggle_quic ;; + system_toggle_ipv6) system_toggle_ipv6 ;; + system_toggle_flow_offloading_fix) system_toggle_flow_offloading_fix ;; + system_uninstall_panel) system_uninstall_panel "$1" ;; + zm_cleanup_status) zm_cleanup_status ;; + 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_ai_migrate) hosts_ai_migrate ;; + 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_watch) doh_watch ;; + 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 ;; + 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 ;; + redbtn_panel_gone) redbtn_panel_gone ;; + awg_status) awg_status ;; + awg_action) awg_action "$1" "$2" ;; + term_status) term_status ;; + term_action) term_action "$1" "$2" ;; + 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" ;; + 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 ;; + rpcd_watch) rpcd_watch ;; + zm_watch) zm_watch ;; + ui_theme_get) ui_theme_get ;; + ui_theme_set) ui_theme_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=$? +# свой блок стратегии: операция кончилась без перезапуска Zapret (ошибка или ничего не поменялось) — вернуть +[ -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 }; +/* Списки пакета без набора в каталоге — те же ссылки, что качает Steer (_rb_list_src): Meta, Instagram, Facebook, WhatsApp. + * *_extra — встроенные файлы панели: идут в local_domain_lists. svc_meta, svc_facebook_dev и meta.lst — только чтобы узнать старые настройки. */ +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_meta.lst": ITD_RAW + "/Services/meta.lst", + "svc_v2meta.lst": V2F_RAW + "/meta", + "svc_threads.lst": V2F_RAW + "/threads", + "svc_oculus.lst": V2F_RAW + "/oculus", + "svc_facebook_dev.lst": V2F_RAW + "/facebook-dev", + "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", + "meta.lst": ITD_RAW + "/Subnets/IPv4/meta.lst" +}; +/* Пункты Meta-семьи — отдельные, без общих списков */ +const META_FAM = { + meta: [ "svc_v2meta.lst", "svc_threads.lst", "svc_oculus.lst" ], + instagram: [ "svc_instagram.lst", "svc_instagram_extra.lst" ], + facebook: [ "svc_facebook.lst", "svc_messenger.lst" ], + whatsapp: [ "svc_whatsapp.lst", "svc_whatsapp_extra.lst" ] +}; +const SCHEMES = /^(vless|vmess|trojan|ss|socks4|socks4a|socks5|hysteria2|hy2):\/\/[^ \t\r\n]+$/i; +const BYPASS = "zm_bypass"; +const SUB_IVS = [ "30m", "1h", "3h", "6h", "12h", "1d" ]; +const LIST_IVS = [ "1h", "3h", "12h", "1d", "3d" ]; +const LINK_KEYS = [ "proxy_string", "selector_proxy_links", "urltest_proxy_links", "selector_proxy_links_text", "urltest_proxy_links_text" ]; + +function s(v) { return v == null ? "" : "" + v; } + +/* Встроенный список панели (локальный файл), а не ссылка. */ +function 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; +} + +/* ---------- catalog of lists (shared with Steer) ---------- */ + +function latest_url(u) { + return replace(s(u), /^(https:\/\/github\.com\/[^\/]+\/[^\/]+\/releases\/)download\/[^\/]+\//, "$1latest/download/"); +} + +function kind_hint(u) { + u = lc(s(u)); + for (let w in [ "geosite", "domain", "adguard", "filter" ]) if (index(u, w) >= 0) return "domains"; + for (let w in [ "geoip", "subnet", "cidr" ]) if (index(u, w) >= 0) return "subnets"; + return "unknown"; +} + +function itd_name(u) { + let m = match(s(u), /allow-domains\/.*\/([A-Za-z0-9_]+)\.srs$/); + return m ? lc(m[1]) : null; +} + +function itd_plain(n) { + let t = [ "l:" + ITD_RAW + "/Services/" + n + ".lst" ]; + if (ITD_SUBNETS[n]) push(t, "l:" + ITD_RAW + "/Subnets/IPv4/" + n + ".lst"); + return t; +} + +function set_tokens(set, known) { + if (known[set]) return [ "c:" + set ]; + if (EXTRA_SRS[set]) return [ "s:" + EXTRA_SRS[set] ]; + return itd_plain(set); +} + +function idx_tokens(e, known) { + let n = itd_name(e.url); + if (n && known[n]) return [ "c:" + n ]; + if (e.fmt == "lst") return [ "l:" + e.url ]; + if (n) return itd_plain(n); + let u = latest_url(e.url); + if (e.kind == "d") return [ "s:" + u ]; + if (kind_hint(u) == "domains") return []; + return [ "r:" + u ]; +} + +function catalog_items(known_list) { + let known = {}, items = [], seen = {}, idx = {}; + for (let k in known_list) known[k] = true; + for (let l in split(s(fs.readfile(RB_CONF)), "\n")) { + if (l == "" || substr(l, 0, 1) == "#") continue; + let f = split(l, "|"), id = f[0], t = []; + if (!id || id == "custom" || seen[id]) continue; + if (s(f[2]) + s(f[3]) + s(f[7]) == "") continue; + if (id == "github") t = set_tokens("github", known); + else 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; } +} + +/* ---------- links ---------- */ + +function link_xhttp(x) { + let h = split(x, "#"); + if (match(x, /^vmess:\/\//i)) { + let d = null; + let b = replace(replace(trim(substr(h[0], 8)), /-/g, "+"), /_/g, "/"); + while (length(b) % 4) b += "="; + try { d = json(b64dec(b)); } catch (e) { d = null; } + return (type(d) == "object" && lc(s(d.net)) == "xhttp") ? (s(d.ps) || s(d.add) || "vmess") : null; + } + if (!match(h[0], /[?&]type=xhttp(&|$)/i)) return null; + return length(h) > 1 && h[1] != "" ? replace(h[1], /%20/g, " ") : split(split(h[0], "@")[1] || h[0], "?")[0]; +} + +function sb_extended() { + return index(sh("sing-box version"), "extended") >= 0; +} + +function link_name(l) { + if (match(l, /^vmess:\/\//i)) { + let d = null, b = replace(replace(trim(substr(split(l, "#")[0], 8)), /-/g, "+"), /_/g, "/"); + while (length(b) % 4) b += "="; + try { d = json(b64dec(b)); } catch (e) { d = null; } + if (type(d) == "object" && s(d.ps) != "") return s(d.ps); + } + let i = index(l, "#"); + if (i < 0) return ""; + let t = replace(substr(l, i + 1), /\+/g, " "); + return replace(t, /%([0-9A-Fa-f]{2})/g, (all, h) => chr(hex(h))); +} + +function link_host(l) { + let m = match(split(l, "#")[0], /^[a-z0-9]+:\/\/([^@\/?#]*@)?(\[[^\]]+\]|[^:\/?#]+)(:([0-9]+))?/i); + return m ? m[2] + (m[4] ? ":" + m[4] : "") : ""; +} + +/* ---------- server filter (markers and hidden names) ---------- */ + +const CYR_UP = split("А Б В Г Д Е Ё Ж З И Й К Л М Н О П Р С Т У Ф Х Ц Ч Ш Щ Ъ Ы Ь Э Ю Я", " "); +const CYR_LO = split("а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я", " "); + +function fold(v) { + v = lc(s(v)); + for (let k = 0; k < length(CYR_UP); k++) v = replace(v, CYR_UP[k], CYR_LO[k]); + return v; +} + +function unfold_up(v) { + v = uc(s(v)); + for (let k = 0; k < length(CYR_LO); k++) v = replace(v, CYR_LO[k], CYR_UP[k]); + return v; +} + +function cap_first(v) { + v = s(v); + if (v == "") return v; + let b = ord(v, 0), n = b >= 0xf0 ? 4 : b >= 0xe0 ? 3 : b >= 0xc0 ? 2 : 1; + return unfold_up(substr(v, 0, n)) + substr(v, n); +} + +function excl_list(v) { + let res = [], seen = {}; + for (let x in split(s(v), /[|,]/)) { + x = fold(trim(x)); + if (x == "" || seen[x]) continue; + seen[x] = true; + push(res, x); + } + return res; +} + +function excl_vars(m) { return { m: excl_list(m.zm_exclude), h: rawlist(m.zm_hide) }; } + +function excl_any(f) { return length(f.m) > 0 || length(f.h) > 0; } + +function excl_hit(name, f) { + name = s(name); + if (index(f.h, name) >= 0) return true; + name = fold(name); + for (let x in f.m) if (x != "" && index(name, x) >= 0) return true; + return false; +} + +function filter_keywords(f) { + let r = []; + for (let x in f.m) r = [ ...r, x, cap_first(x), unfold_up(x) ]; + return uniq([ ...r, ...f.h ]); +} + +/* ---------- reading sections ---------- */ + +function is_conn(x) { + let t = s(x.connection_type); + return x[".name"] != BYPASS && (t == "proxy" || t == "vpn"); +} + +function pick(c) { + let all = sections(c, "section"), sec = null; + for (let x in all) + if (x[".name"] == "main" && is_conn(x)) sec = "main"; + if (!sec) + for (let x in all) + if (is_conn(x)) { sec = x[".name"]; break; } + if (!sec) sec = "main"; + let extra = [], conns = []; + for (let x in all) { + if (x[".name"] == BYPASS) continue; + if (is_conn(x)) { if (x[".name"] != sec) push(conns, x[".name"]); continue; } + push(extra, { name: x[".name"], label: s(x.zm_label || x[".name"]), action: s(x.connection_type), enabled: s(x.disabled) != "1" }); + } + return { sec, extra, conns }; +} + +function sec_links(m) { + let l = rawlist(m.zm_links); + if (length(l)) return l; + for (let k in LINK_KEYS) { + let v = k == "proxy_string" ? arr(m[k]) : (index(k, "_text") >= 0 ? lines(m[k]) : words(m[k])); + if (length(v)) return v; + } + return []; +} + +function visible_links(m, f) { + return filter(sec_links(m), (l) => !excl_hit(link_name(l), f)); +} + +function sec_mode(m) { + if (s(m.connection_type) == "vpn") return "iface"; + let t = s(m.proxy_config_type || "url"); + if (t == "subscription") return "sub"; + if (t == "outbound") return "json"; + return "links"; +} + +function sub_urls(m) { + return uniq(words(m.subscription_url)); +} + +function domain_lines(m) { + let t = s(m.user_domain_list_type || "disabled"); + if (t == "text") return uniq(tokens(m.user_domains_text)); + if (t == "dynamic") return uniq(words(m.user_domains)); + return []; +} + +function subnet_lines(m) { + let t = s(m.user_subnet_list_type || "disabled"); + if (t == "text") return uniq(tokens(m.user_subnets_text)); + if (t == "dynamic") return uniq(words(m.user_subnets)); + return []; +} + +function 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 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 }; +} + +/* ---------- validation ---------- */ + +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]+)?(\/[^ ]*)?$/); +} + +/* DoH: голый адрес сервера (dns.example.com) — дописываем стандартный /dns-query; адрес со своим путём + * (dns.example.com/abc или dns.example.com/) и ссылку https://… берём как есть, путь не трогаем. */ +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; +} + +/* NetShift accepts plain domains only: lowercase letters, digits, hyphens and dots. */ +function norm_domain(v) { + v = lc(trim(s(v))); + let m = match(v, /^full:(.+)$/); + if (m) v = m[1]; + v = replace(v, /^\*\./, ""); + return v; +} + +function domain_err(raw) { + let v = norm_domain(raw); + if (match(lc(s(raw)), /^(keyword|regex|domain_keyword|domain_regex):/)) + return "«" + raw + "»: keyword: и regex: 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"); + c.set(CFG, "settings", "zm_dev_v2", "1"); + set_list(c, "settings", "routing_excluded_ips", excl); +} + +/* Persist the source condition in the engine so reloads and reboots keep it. */ +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_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'); + } + /* Upgrade the existing hook when the local panel already added direct fallback. */ + 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'); + 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; +} + +/* Прежний перенос устройств (до этой версии) клал fully_routed_ips — «весь трафик устройства через VPN», + * как их всегда понимал движок и как их приносит перенос из Forkop, — в «Только для этих устройств». Итог: + * эти устройства теряли «весь трафик», а сервисы секции у ВСЕХ остальных устройств шли напрямую. Возвращаем + * как было по копии настроек, снятой перед тем переносом, — только там, где список «Только для этих + * устройств» с тех пор не меняли (значит, его сделал перенос, а не человек). Один раз. */ +function repair_device_migration(c) { + if (s(c.get(CFG, "settings", "zm_dev_fix")) == "1") return false; + let backup = STATE + "/netshift.before-device-rules.conf", fixed = false, text = fs.access(backup) ? fs.readfile(backup) : null; + if (text != null) { + let tmpd = "/tmp/zm-fk-devfix." + s(fs.readlink("/proc/self")); + system("rm -rf " + q(tmpd)); + if (fs.mkdir(tmpd) != null && fs.writefile(tmpd + "/" + CFG, text) != null) { + let oc = uci.cursor(tmpd); + oc.load(CFG); + let srt = (a) => join(" ", sort(slice(a))); + oc.foreach(CFG, "section", (x) => { + let n = x[".name"], was = all_ips(x); + if (!length(was) || c.get(CFG, n) != "section") return; + let cur = c.get_all(CFG, n) || {}; + if (srt(device_ips(cur)) != srt(uniq([ ...device_ips(x), ...was ]))) return; + /* устройство целиком может идти только через одну секцию */ + let taken = {}; + for (let o in sections(c, "section")) if (o[".name"] != n) for (let ip in all_ips(o)) taken[ip] = true; + set_list(c, n, "zm_rule_source_ips", device_ips(x)); + set_list(c, n, "fully_routed_ips", uniq([ ...all_ips(cur), ...filter(was, (ip) => !taken[ip]) ])); + fixed = true; + }); + } + system("rm -rf " + q(tmpd)); + } + if (!c.get(CFG, "settings")) c.set(CFG, "settings", "settings"); + c.set(CFG, "settings", "zm_dev_fix", "1"); + return fixed; +} + +function cmd_device_setup() { + let patched = patch_device_engine(), c = cursor(), migrated = false; + let v2 = s(c.get(CFG, "settings", "zm_dev_v2")) == "1"; + /* fully_routed_ips в старых настройках — это уже «весь трафик устройства» в новой схеме: переносить нечего */ + let fixfirst = s(c.get(CFG, "settings", "zm_dev_fix")) != "1"; + migrated = repair_device_migration(c); + /* Старый список meta (вся Meta) — это четыре пункта: Meta, Instagram, Facebook и WhatsApp. */ + let split_meta = false; + for (let x in sections(c, "section")) { + let cl = uniq(words(x.community_lists)); + if (index(cl, "meta") < 0) continue; + /* только дописываем: остальные списки секции и их порядок не трогаем */ + let n = x[".name"], all = [], zr = rawlist(x.zm_refs); + for (let id in [ "meta", "instagram", "facebook", "whatsapp" ]) for (let k in META_FAM[id]) push(all, PKG_LISTS[k]); + let urls = filter(all, (u) => !pkg_local(u)), locs = filter(all, (u) => pkg_local(u)); + set_list(c, n, "community_lists", filter(cl, (v) => v != "meta")); + if (length(zr)) set_list(c, n, "zm_refs", uniq([ ...zr, ...map(all, (u) => "l:" + u) ])); + set_list(c, n, "remote_domain_lists", uniq([ ...words(x.remote_domain_lists), ...urls ])); + set_list(c, n, "remote_subnet_lists", uniq([ ...words(x.remote_subnet_lists), ...urls ])); + set_list(c, n, "local_domain_lists", uniq([ ...words(x.local_domain_lists), ...locs ])); + split_meta = true; + } + /* Meta, Instagram, Facebook и WhatsApp стали отдельными пунктами без общих списков. Один раз переводим секции: + * по старым спискам узнаём, какие пункты были выбраны (старая Meta — это все четыре), убираем все прежние списки + * Meta-семьи (и подсети Meta) и ставим новые списки этих пунктов. Остальные списки секции не трогаем. */ + let split_ig = false; + if (s(c.get(CFG, "settings", "zm_meta_v4")) != "1") { + let P = (k) => PKG_LISTS[k], fam = map(keys(PKG_LISTS), (k) => PKG_LISTS[k]); + for (let x in sections(c, "section")) { + let rf = sec_refs(x), has = (k) => index(rf.l, P(k)) >= 0; + if (!length(filter(rf.l, (u) => index(fam, u) >= 0))) continue; + let all = has("svc_meta.lst") || (has("svc_v2meta.lst") && has("svc_instagram.lst") && has("svc_whatsapp.lst")); + let pick = { + meta: all || has("svc_v2meta.lst") || has("svc_threads.lst") || has("svc_oculus.lst"), + instagram: all || has("svc_instagram.lst"), + facebook: all || (has("svc_facebook.lst") && has("svc_messenger.lst")), + whatsapp: all || has("svc_whatsapp.lst") + }; + let l = filter(rf.l, (u) => index(fam, u) < 0); + for (let id in [ "meta", "instagram", "facebook", "whatsapp" ]) if (pick[id]) for (let k in META_FAM[id]) push(l, P(k)); + l = uniq(l); + if (join("\n", l) == join("\n", rf.l)) continue; + /* пишем только списки, остальное в секции не трогаем (как set_lists) */ + let n = x[".name"], zr = rawlist(x.zm_refs), rl = filter(l, (u) => !pkg_local(u)), ll = filter(l, (u) => pkg_local(u)); + if (length(zr)) set_list(c, n, "zm_refs", [ ...filter(zr, (t) => substr(t, 0, 2) != "l:"), ...map(l, (u) => "l:" + u) ]); + set_list(c, n, "remote_domain_lists", uniq([ ...rf.s, ...rf.r, ...rl ])); + set_list(c, n, "remote_subnet_lists", uniq([ ...rf.r, ...rl ])); + c.delete(CFG, n, "local_subnet_lists"); + set_list(c, n, "local_domain_lists", uniq(ll)); + } + if (!c.get(CFG, "settings")) c.set(CFG, "settings", "settings"); + c.delete(CFG, "settings", "zm_meta_v3"); + c.set(CFG, "settings", "zm_meta_v4", "1"); + split_ig = true; + } + if (!v2 || split_meta || split_ig || fixfirst) { + if (!c.get(CFG, "settings")) c.set(CFG, "settings", "settings"); + c.set(CFG, "settings", "zm_dev_v2", "1"); + c.save(CFG); + c.commit(CFG); + } + out({ ok: true, changed: patched || migrated || split_meta || split_ig, migrated }); +} + +function set_text(c, sec, key, list) { + if (length(list)) c.set(CFG, sec, key, join("\n", list)); + else c.delete(CFG, sec, key); +} + +function del_keys(c, sec, keys) { for (let k in keys) c.delete(CFG, sec, k); } + +/* Writes what NetShift should use for a section's servers, applying the server filter. */ +function apply_servers(c, sec) { + let m = c.get_all(CFG, sec) || {}, mode = sec_mode(m), f = excl_vars(m); + if (mode == "links") { + let vis = visible_links(m, f); + del_keys(c, sec, LINK_KEYS); + if (length(vis) == 1) { + c.set(CFG, sec, "proxy_config_type", "selector"); + c.set(CFG, sec, "selector_proxy_links", vis); + } else if (length(vis) > 1) { + c.set(CFG, sec, "proxy_config_type", "urltest"); + c.set(CFG, sec, "urltest_proxy_links", vis); + } + c.delete(CFG, sec, "subscription_filter_exclude_keywords"); + return length(vis); + } + if (mode == "sub") { + let kw = filter_keywords(f); + set_list(c, sec, "subscription_filter_exclude_keywords", kw); + } + return -1; +} + +function set_lists(c, sec, rf, services, domains, subnets) { + set_list(c, sec, "community_lists", services); + let zr = []; + for (let k in [ "s", "r", "l" ]) for (let v in rf[k]) push(zr, k + ":" + v); + set_list(c, sec, "zm_refs", zr); + 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); +} + +/* ---------- extra options: section (подписка, автовыбор, локальный прокси) and settings (сеть, панель) ---------- */ + +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 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), + block_doh: flag(st.block_doh), + /* журнал sing-box по умолчанию выключен (panic — пишет только аварии), пока его не выбрали в панели */ + 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 ""; +} + +/* Сеть роутера: логические сети (lan, wan…) и устройства (br-lan, eth0, pppoe-wan…). */ +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); +} + +/* Дополнительные опции секции. x — объект из панели (d.x), mode — способ подключения. */ +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); +} + +/* Общие настройки: DNS (TTL, ECS), сеть, журнал, панель sing-box. g — объект из панели (d.g). */ +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 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), + 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: s(st.disable_quic) == "1", + list_interval: s(st.update_interval || "1d"), + list_update: true, + lists_via: s(st.download_lists_via_proxy) == "1", + devices: devices(), + tunnels: tunnels(), + 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); + for (let x in links) + if (!match(x, SCHEMES)) + fail(match(x, /^(tuic|http|https|anytls|wireguard|hysteria):\/\//i) ? "Forkozz не поддерживает такие ссылки: " + substr(x, 0, 40) + " — подойдут vless, vmess, trojan, ss, socks5, hysteria2" : "ссылка не поддерживается: " + 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 }); +} + +/* ---------- servers ---------- */ + +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); } + +/* Only list routes use this selector; direct never competes in a URLTest. */ +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; + } + /* «Авто по пингу»: отдельный переключатель по всем серверам секции. Какой из них включён, решает панель + * (ping_pick): самый быстрый по пингу, а при обрыве — самый быстрый из оставшихся рабочих. */ + 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); + } + /* Leaf choices let recovery use a live node before nested URLTests refresh. */ + 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 }); + /* Keep DNS resolution available if its chosen section falls back. */ + for (let dns in (conf.dns && type(conf.dns.servers) == "array" ? conf.dns.servers : [])) + if (s(dns.detour) == original) dns.detour = tag; + } + 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 : "" }; +} + +/* Prefer the section-wide URLTest over per-subscription groups. */ +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; + /* Grouped subscriptions use the selector's configured default. */ + 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"; } + +/* Reads traffic, expiry and title from the provider's headers (Subscription-Userinfo, Profile-Title). */ +function cmd_subinfo(sec) { + let c = cursor(), urls = sub_urls(c.get_all(CFG, sec) || {}); + if (!length(urls)) { out({ ok: false }); return; } + let hdr = sh("curl -sS -m 12 -A 'v2rayN/7.0.0' -D - -o /dev/null " + q(urls[0])); + let info = { title: "", used: null, total: null, unlimited: false, expire: null, at: time() }; + for (let l in split(hdr, /\r?\n/)) { + let m = match(l, /^([A-Za-z-]+):[ \t]*(.*)$/); + if (!m) continue; + let k = lc(m[1]), v = trim(m[2]); + if (k == "subscription-userinfo") { + let up = 0, down = 0; + for (let kv in split(v, ";")) { + let p = match(trim(kv), /^([a-z]+)=([0-9]+)/); + if (!p) continue; + if (p[1] == "upload") up = int(p[2]); + else if (p[1] == "download") down = int(p[2]); + else if (p[1] == "total") info.total = int(p[2]); + else if (p[1] == "expire") info.expire = int(p[2]) > 0 ? int(p[2]) : null; + } + info.used = up + down; + if (info.total == 0) { info.total = null; info.unlimited = true; } + } + else if (k == "profile-title") { + let bm = match(v, /^base64:(.*)$/); + info.title = bm ? s(b64any(bm[1])) : v; + } + } + try { fs.mkdir(STATE); } catch (e) {} + fs.writefile(sub_cache_path(sec), sprintf("%J", info)); + out({ ok: true }); +} + +function sub_info(sec) { + let j = null; + try { j = json(s(fs.readfile(sub_cache_path(sec)))); } catch (e) { j = null; } + if (type(j) != "object" || time() - int(j.at || 0) > 1800) + system("(ucode " + q(sourcepath()) + " subinfo " + q(sec) + " >/dev/null 2>&1 &)"); + if (type(j) != "object" || (j.title == "" && j.used == null && j.expire == null)) return null; + return { title: s(j.title), used: j.used, total: j.total, unlimited: !!j.unlimited, expire: j.expire }; +} + +const FELL_FILE = getenv("ZM_FK_FELL") || STATE + "/fk.fell"; + +function fell_file(sec) { + let c = cursor(); + return sec == "" || sec == pick(c).sec ? FELL_FILE : FELL_FILE + "." + sec; +} + +function lat_path(sec) { return STATE + "/fk.lat." + sec + ".json"; } +/* Как проверять серверы. «fast» (по умолчанию): только TCP — открывается ли порт сервера. «real»: сначала + * быстрый TCP, затем настоящий запрос ЧЕРЕЗ сервер. Так видно серверы, у которых порт открыт, а трафик + * провайдер не пускает. Хранится в файле, без правки настроек Forkozz (и без перезапуска). */ +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() }); +} +/* Если выбранный вручную сервер не отвечает: «back» (по умолчанию) — перейти на рабочий и вернуться на + * выбранный, когда он снова ответит; «switch» — перейти на рабочий; «hold» — держать выбранный. + * Сервер для возврата — fk.home.<секция>: {tag, name, next} (next — когда проверять его снова). */ +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("не удалось сохранить настройку"); + /* возвращаться — только в «back»: запомненные серверы для возврата больше не нужны */ + 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); + /* результаты последнего TCP-пинга (и промежуточные, пока он идёт): -2 — сервер ещё в очереди. + * Проверка «через сервер»: delay — задержка настоящего запроса; tcp — время TCP; blocked — порт + * открыт, а запрос через сервер не прошёл (скорее всего, его блокирует провайдер); rpend — TCP прошёл, + * запрос через сервер ещё в очереди (показываем время TCP, пока ждём) */ + 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() }); +} + +/* ---------- точная проверка сервера (как делают клиенты вроде Happ) ---------- + * Настоящий HTTP-запрос ЧЕРЕЗ сервер к лёгкому тестовому адресу (generate_204), а не общий замер всей + * группы. Сервер считается «не отвечает», только если не ответил ни на один из тестовых адресов + * с запасом по времени. Проверка идёт напрямую через Clash API sing-box: curl на роутере. */ +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; +} + +/* пачка серверов параллельно (не больше LAT_PAR за раз — слабый роутер иначе сам даёт ложные таймауты) */ +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; +} + +/* ---------- TCP-пинг (как «TCP» в Happ): время установки соединения с сервером, без туннеля ---------- */ +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; +} + +/* Flatten every subscription/country group, including nodes outside the current choice. */ +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; +} + +/* «Авто по пингу». Пока выбранный сервер отвечает — остаёмся на нём и не прыгаем туда-сюда. + * При обрыве меряем пинг всех серверов секции (TCP, как в Happ; серверы на UDP — настоящим запросом), + * идём от самого быстрого к медленному и берём первый, который реально пропускает трафик. + * Раз в 15 минут сравниваем и живой сервер: уходим с него, только если другой быстрее больше чем на допуск. */ +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]; + } + /* «через сервер»: двенадцать самых быстрых по TCP (и текущий) проверяем настоящим запросом через сервер + * и сортируем по нему; не пропустившие трафик (порт открыт, а запрос не прошёл) уходят в конец */ + 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 }; +} + +/* Exhaust higher-priority feeds before probing a lower-priority feed. */ +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: "" }; +} + +/* Probe actual tunnel requests, not TCP reachability or stale latency history. */ +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; } + /* дальше — общая проверка «напрямую при отказе VPN» */ + 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; + } + /* Do not apply results to a reloaded engine or a section edited during probing. */ + 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; + } + /* Preserve the selected server or Auto when it works; otherwise pick a live VPN. */ + 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) {} + /* «вернуться на выбранный»: запоминаем его — проверим через 10 минут */ + 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) }); +} + +/* Задержка серверов. Замер всей группы — один запрос к sing-box, который проверяет узлы по десять: + * на подписке в десятки узлов с мёртвыми среди них он идёт дольше, чем ждёт панель (rpcd), и пинг + * «не определялся». Поэтому замер идёт в фоне, а панель опрашивает servers. Узел, который замер + * проверил и который не ответил, sing-box в истории не хранит — его отмечаем сами (задержка 0). */ +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 }); +} + +/* Задержка серверов — TCP-пинг, как «TCP» в Happ: время установки соединения с сервером, без туннеля. + * Пачками по TCP_PAR параллельно, по две попытки на сервер; после каждой пачки результат сразу пишется + * в файл — панель показывает задержки по мере проверки. Кто не ответил за 3 с, проверяется ещё раз + * с запасом в 6 с: «не отвечает» ставится только после обоих проходов. Серверы на UDP (Hysteria2, + * TUIC) по TCP не проверить — для них один настоящий запрос через sing-box. Группа (по странам, + * автовыбор подписки) получает лучшую задержку своих серверов. + * Способ «через сервер» (lat_mode real): после TCP серверы, у которых порт открылся, проверяются ещё и + * настоящим запросом через сам сервер (по LAT_PAR за раз). Не прошёл с двух тестовых адресов — сервер + * «заблокирован»: порт открыт, а трафик через него не идёт. Их задержка — 0, как у неотвечающих. */ +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 = []; + /* итоговая задержка сервера: проверенный через сервер — по ней, иначе — по TCP */ + 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 }); +} + +/* Проверка одного сервера перед выбором: настоящий запрос через него (два тестовых адреса) и, если у + * сервера TCP-адрес, — открывается ли порт. Порт открыт, а запрос не прошёл — похоже, его блокирует провайдер. */ +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 }); +} + +/* «Вернуться на выбранный»: сервер, с которого ушли из-за отказа, проверяем раз в 10 минут; ответил — + * выбираем его снова. Сервера нет в секции или его уже выбрали — возвращаться не на что. */ +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 cmd_fixsel(sec) { out({ ok: true, changed: false }); } + +function nn_ru(n, one, few, many) { + let a = n % 10, h = n % 100; + return n + " " + (a == 1 && h != 11 ? one : a >= 2 && a <= 4 && (h < 12 || h > 14) ? few : many); +} + +function cmd_secdel(sec) { + let c = cursor(), p = pick(c); + if (sec == "" || sec == p.sec) fail("основную секцию удалить нельзя"); + if (index(p.conns, sec) < 0) fail("такой секции нет"); + c.delete(CFG, sec); + for (let k in [ "dns_outbound_section", "download_lists_via_proxy_section" ]) + if (s(c.get(CFG, "settings", k)) == sec) c.set(CFG, "settings", k, p.sec); + c.save(CFG); + c.commit(CFG); + try { fs.unlink(sub_cache_path(sec)); } catch (e) {} + out({ ok: true }); +} + +function cmd_secnames() { + let p = pick(cursor()); + print(join("\n", [ p.sec, ...p.conns ]), "\n"); +} + +/* Ссылка на сервер REALITY с Xray 26.9.8+: такой сервер пускает только клиента с ключом X25519MLKEM768 */ +function mlkem_link(l) { + return !!match(s(l), /[?&]security=reality([&#]|$)/) && !!match(s(l), /[?&]support-x25519mlkem768=(true|1)([&#]|$)/); +} +/* ключ X25519MLKEM768 отправляет только расширенный sing-box 2.7.2 и новее */ +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, "строка", "строки", "строк") + " — идут напрямую, мимо всех секций"); + } + out({ checks }); +} + +/* ---------- per-section state for the Forkozz overview (cheap: no traffic unless probe) ---------- */ + +function hs_age(dev) { + for (let tool in [ "awg", "wg" ]) { + let o = sh(tool + " show " + q(dev) + " latest-handshakes 2>/dev/null"); + if (o == "") continue; + let mx = 0; + for (let l in split(o, "\n")) { + let f = split(trim(l), /[ \t]+/); + if (length(f) >= 2 && int(f[1]) > mx) mx = int(f[1]); + } + return mx > 0 ? time() - mx : -1; + } + return null; +} + +function cmd_secstate(probe) { + let c = cursor(), p = pick(c), px = proxies(), r = []; + for (let x in sec_list(c, p)) { + let e = { name: x.name, label: x.label, main: x.main, enabled: x.enabled, mode: x.mode, iface: x.iface, + state: "", server: "", delay: -1, auto: false, count: 0, hs: null, up: null, sub: "" }; + if (x.mode == "iface") { + let ex = !!fs.access(NETDIR + "/" + x.iface); + e.up = ex && trim(s(fs.readfile(NETDIR + "/" + x.iface + "/operstate"))) != "down"; + e.hs = ex ? hs_age(x.iface) : null; + } + if (!x.enabled) e.state = "off"; + else if (!px) e.state = "stopped"; + else if (x.mode == "iface") e.state = !e.up ? "bad" : e.hs == null || (e.hs >= 0 && e.hs < 180) ? "ok" : "bad"; + else { + let g = px[group_tag(x.name)]; + if (!g || type(g.all) != "array") e.state = "wait"; + else { + let names = sec_names(c, x.name), now = 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 = si ? 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 }); +} + +/* ---------- route check: replays sing-box route rules for a domain or an IP ---------- */ + +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; +} + +/* ---------- where: in which lists of the sections the domain or the IP is ---------- + * sing-box names only the rule; netshift glues all plain lists of a section into one rule set, so the + * list itself is found here: each source of each section is checked on its own, the same way the + * engine matches (domain with subdomains, full:/keyword:/regexp:, IPv4 subnets; .srs/.json by + * sing-box itself). Missing files are downloaded for working sections while there is time; the + * catalog items no section uses are checked only from what is already on the router. */ +let WHERE_T0 = 0; +/* время считается от начала проверки маршрута: докачка идёт, только пока с начала прошло меньше 15 с */ +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) { + /* тот же кеш, что у rs_file, но качаем коротко (8 с) и только пока есть время: проверка маршрута + * в оболочке ограничена 40 секундами, и долгая докачка оставила бы человека вовсе без ответа */ + 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", ""); + } + 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: "" }); +} + +/* ---------- migration from a Forkop configuration ---------- */ + +function cmd_import(dir) { + let oc = uci.cursor(dir); + oc.load("forkop"); + let c = cursor(), known = services_list(), notes = [], all = []; + oc.foreach("forkop", "section", (x) => { push(all, x); }); + let kids = (t, n) => { let r = []; oc.foreach("forkop", t, (x) => { if (s(x.section) == n) push(r, x); }); return r; }; + let ost = oc.get_all("forkop", "settings") || {}; + let main = null; + for (let x in all) if (x[".name"] == "main") main = "main"; + if (!main) for (let x in all) { let a = s(x.action || "connection"); if (index([ "connection", "proxy", "vpn", "outbound" ], a) >= 0) { main = x[".name"]; break; } } + for (let x in sections(c, "section")) c.delete(CFG, x[".name"]); + if (!c.get(CFG, "settings")) c.set(CFG, "settings", "settings"); + let olddom = (m) => { + let r = []; + for (let k in [ "domain", "domain_suffix_text", "domain_text" ]) r = [ ...r, ...lines(m[k]) ]; + for (let k in [ "domain_suffix" ]) r = [ ...r, ...words(m[k]) ]; + let ok = []; + for (let x in uniq(r)) { + if (domain_err(x)) { push(notes, "пропущен домен «" + x + "» (Forkozz понимает только обычные домены)"); continue; } + push(ok, norm_domain(x)); + } + if (length(words(m.domain_keyword)) || length(words(m.domain_regex))) push(notes, "keyword: и regex: из секции «" + s(m.label || m[".name"]) + "» не перенесены — Forkozz их не поддерживает"); + return uniq(ok); + }; + let oldsub = (m) => uniq([ ...lines(m.ip_cidr), ...lines(m.ip_cidr_text) ]); + let oldrefs = (m) => { + let rf = { s: uniq(words(m.rule_set)), r: uniq(words(m.rule_set_with_subnets)), l: [] }, svc = []; + for (let v in uniq(words(m.community_lists))) { + if (index(known, v) >= 0) push(svc, v); + else if (EXTRA_SRS[v]) push(rf.s, EXTRA_SRS[v]); + else push(notes, "список «" + v + "» Forkozz не знает — пропущен"); + } + for (let u in uniq([ ...words(m.domain_ip_lists), ...words(m.remote_domain_lists), ...words(m.remote_subnet_lists) ])) { + if (!valid_url(u)) { push(notes, "локальный список " + u + " не перенесён — укажите ссылку https://"); continue; } + push(is_rs(u) ? (index(words(m.remote_subnet_lists), u) >= 0 ? rf.r : rf.s) : rf.l, u); + } + rf.s = uniq(rf.s); rf.r = uniq(rf.r); rf.l = uniq(rf.l); + return { rf, svc }; + }; + let order = []; + for (let x in all) if (x[".name"] == "zm_bypass" || s(x.action) == "bypass") push(order, x); + for (let x in all) if (x[".name"] == main) push(order, x); + for (let x in all) if (index(order, x) < 0) push(order, x); + let bdom = [], bsub = []; + for (let x in order) { + let n = x[".name"], a = s(x.action || "connection"); + if (a == "bypass") { bdom = [ ...bdom, ...olddom(x) ]; bsub = [ ...bsub, ...oldsub(x) ]; continue; } + if (index([ "connection", "proxy", "vpn", "outbound" ], a) < 0) { push(notes, "секция «" + s(x.label || n) + "» (" + a + ") не перенесена — Forkozz так не умеет"); continue; } + if (!match(n, /^[A-Za-z0-9_]+$/) || n == "settings" || n == BYPASS) { + let i = 1; + while (c.get(CFG, "zm_s" + i) != null) i++; + n = "zm_s" + i; + } + c.set(CFG, n, "section"); + let lb = trim(s(x.label)); + if (lb != "" && lb != "Zapret Manager") c.set(CFG, n, "zm_label", lb); + if (s(x.enabled) == "0" && x[".name"] != main) c.set(CFG, n, "disabled", "1"); + let ifs = kids("section_interface", x[".name"]), subs = kids("subscription_url", x[".name"]); + let links = uniq(words(x.selector_proxy_links)); + if (length(ifs)) { + c.set(CFG, n, "connection_type", "vpn"); + c.set(CFG, n, "interface", s(ifs[0].name)); + c.set(CFG, n, "domain_resolver_enabled", "0"); + } else if (length(subs)) { + c.set(CFG, n, "connection_type", "proxy"); + c.set(CFG, n, "proxy_config_type", "subscription"); + c.set(CFG, n, "subscription_url", [ s(subs[0].url) ]); + let iv = s(subs[0].subscription_update_interval || "1h"); + c.set(CFG, n, "subscription_update_interval", index(SUB_IVS, iv) >= 0 ? iv : "1h"); + } else if (length(links)) { + c.set(CFG, n, "connection_type", "proxy"); + c.set(CFG, n, "proxy_config_type", "urltest"); + c.set(CFG, n, "zm_links", links); + } else { + push(notes, "в секции «" + s(x.label || n) + "» нет серверов, подписки или туннеля — настройте её заново"); + c.set(CFG, n, "connection_type", "proxy"); + c.set(CFG, n, "proxy_config_type", "urltest"); + c.set(CFG, n, "disabled", "1"); + } + c.set(CFG, n, "enable_udp_over_tcp", "0"); + if (s(x.zm_exclude) != "") c.set(CFG, n, "zm_exclude", s(x.zm_exclude)); + if (length(rawlist(x.zm_hide))) c.set(CFG, n, "zm_hide", rawlist(x.zm_hide)); + apply_servers(c, n); + let o = oldrefs(x); + set_lists(c, n, o.rf, o.svc, olddom(x), oldsub(x)); + if (x[".name"] == main) { + set_list(c, n, "fully_routed_ips", uniq(words(x.fully_routed_ips))); + set_list(c, "settings", "routing_excluded_ips", uniq(words(x.excluded_source_ip_cidr))); + } + } + bdom = uniq(bdom); bsub = uniq(bsub); + if (length(bdom) || length(bsub)) { + c.set(CFG, BYPASS, "section"); + c.set(CFG, BYPASS, "zm_label", "Исключения"); + c.set(CFG, BYPASS, "connection_type", "exclusion"); + set_lists(c, BYPASS, { s: [], r: [], l: [] }, [], bdom, bsub); + c.delete(CFG, BYPASS, "zm_refs"); + c.reorder(CFG, BYPASS, 1); + } + let t = s(ost.dns_type || "udp"); + if (index([ "udp", "dot", "doh" ], t) < 0) t = "udp"; + let svs = filter(map(arr(ost.dns_server), (v) => dns_norm(t, v)), (v) => dns_value_ok(t, v)); + if (!length(svs)) svs = t == "udp" ? [ "9.9.9.9" ] : t == "dot" ? [ "dns.quad9.net" ] : [ "https://dns.quad9.net/dns-query" ]; + let bss = filter(arr(ost.bootstrap_dns_server), (v) => valid_ip(v) && index(v, "/") < 0); + if (!length(bss)) bss = [ "9.9.9.9" ]; + c.set(CFG, "settings", "dns_type", t); + c.set(CFG, "settings", "dns_server", svs[0]); + c.set(CFG, "settings", "zm_dns_servers", slice(svs, 0, 8)); + c.set(CFG, "settings", "bootstrap_dns_server", bss[0]); + c.set(CFG, "settings", "zm_bootstrap", slice(bss, 0, 4)); + if (s(ost.dns_detour_enabled) == "1" && main) { c.set(CFG, "settings", "dns_via_outbound", "1"); c.set(CFG, "settings", "dns_outbound_section", main); } + c.set(CFG, "settings", "disable_quic", s(ost.disable_quic || "1") == "1" ? "1" : "0"); + let iv = s(ost.update_interval || "1d"); + c.set(CFG, "settings", "update_interval", index(LIST_IVS, iv) >= 0 ? iv : iv == "6h" ? "3h" : "1d"); + if (s(ost.download_lists_via_proxy) == "1" && main) { c.set(CFG, "settings", "download_lists_via_proxy", "1"); c.set(CFG, "settings", "download_lists_via_proxy_section", main); } + /* устройства Forkop перенесены в «весь трафик» (fully_routed_ips) — это уже новая схема, переносить их не нужно */ + c.set(CFG, "settings", "zm_dev_v2", "1"); + c.set(CFG, "settings", "zm_dev_fix", "1"); + c.save(CFG); + c.commit(CFG); + out({ ok: true, main, notes }); +} + +/* Трафик через sing-box: всего, соединения, память. Скорость панель считает сама по разнице. */ +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() }); +} + +/* Соединения sing-box сейчас (Clash API): кто, куда и через какую секцию — для «Проверки», как в Steer. + * Ничего не пишется и не отправляется: список берётся из памяти sing-box в момент запроса. */ +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 != "import" && mode != "route" && sec != "" && (ml(sec) || !match(sec, /^[A-Za-z0-9_]+$/))) fail("неверное имя секции"); +if (sec == "" && index([ "get", "set", "secdel", "import", "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 == "fixsel") cmd_fixsel(sec); +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 if (mode == "import") cmd_import(sec); +else fail("неизвестная команда"); +ZM_INSTALLER_EOF +chmod 0644 '/opt/zapret-manager-luci/netshift.uc' +rm -f '/opt/zapret-manager-luci/forkop.uc' + +mkdir -p /usr/libexec/rpcd +chmod 0755 /usr/libexec/rpcd +cat > '/opt/zapret-manager-luci/rpcd-plugin.zm-new' << 'ZM_INSTALLER_EOF' +#!/bin/sh + +. /usr/share/libubox/jshn.sh + +BACKEND="/opt/zapret-manager-luci/backend.sh" + +list_methods() { + json_init + json_add_object "status"; json_close_object + json_add_object "system_info"; json_close_object + json_add_object "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 "zapret_latest_version"; json_close_object + json_add_object "zapret2_action"; json_add_string "action" "string"; json_close_object + json_add_object "strategy_list_v"; json_close_object + json_add_object "strategy_set_v"; json_add_string "version" "string"; json_close_object + json_add_object "strategy_list_flowseal"; json_add_string "action" "string"; json_close_object + json_add_object "strategy_set_flowseal"; json_add_string "name" "string"; json_close_object + json_add_object "strategy_list_youtube"; json_add_string "action" "string"; json_close_object + json_add_object "strategy_set_youtube"; json_add_string "name" "string"; json_close_object + json_add_object "youtube_quic_set"; json_add_string "mode" "string"; json_close_object + json_add_object "discord_status"; json_close_object + json_add_object "discord_set_dv"; json_add_string "num" "string"; json_close_object + json_add_object "discord_set_fake"; json_add_string "file" "string"; json_close_object + json_add_object "game_status"; json_close_object + json_add_object "game_set"; json_add_string "choice" "string"; json_close_object + json_add_object "game_set_fake"; json_add_string "file" "string"; json_close_object + json_add_object "game_toggle_xtreme"; json_close_object + json_add_object "system_status"; json_close_object + json_add_object "system_check_connectivity"; json_close_object + json_add_object "system_toggle_quic"; json_close_object + json_add_object "system_toggle_ipv6"; json_close_object + json_add_object "system_toggle_flow_offloading_fix"; json_close_object + json_add_object "system_uninstall_panel"; json_add_string "mode" "string"; json_close_object + json_add_object "zm_cleanup_status"; 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 "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 "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 "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_get"; json_close_object + json_add_object "ui_theme_set"; json_add_string "theme" "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_dump +} + +call_method() { + local method="$1" + local input + input="$(cat)" + json_load "$input" 2>/dev/null + + case "$method" in + status) "$BACKEND" status ;; + system_info) "$BACKEND" system_info ;; + 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" ;; + zapret_latest_version) "$BACKEND" zapret_latest_version ;; + zapret2_action) json_get_var action action; "$BACKEND" zapret2_action "$action" ;; + strategy_list_v) "$BACKEND" strategy_list_v ;; + strategy_set_v) json_get_var version version; "$BACKEND" strategy_set_v "$version" ;; + strategy_list_flowseal) json_get_var action action; "$BACKEND" strategy_list_flowseal "$action" ;; + strategy_set_flowseal) json_get_var name name; "$BACKEND" strategy_set_flowseal "$name" ;; + strategy_list_youtube) json_get_var action action; "$BACKEND" strategy_list_youtube "$action" ;; + strategy_set_youtube) json_get_var name name; "$BACKEND" strategy_set_youtube "$name" ;; + youtube_quic_set) json_get_var mode mode; "$BACKEND" youtube_quic_set "$mode" ;; + discord_status) "$BACKEND" discord_status ;; + discord_set_dv) json_get_var num num; "$BACKEND" discord_set_dv "$num" ;; + discord_set_fake) json_get_var file file; "$BACKEND" discord_set_fake "$file" ;; + game_status) "$BACKEND" game_status ;; + game_set) json_get_var choice choice; "$BACKEND" game_set "$choice" ;; + game_set_fake) json_get_var file file; "$BACKEND" game_set_fake "$file" ;; + game_toggle_xtreme) "$BACKEND" game_toggle_xtreme ;; + system_status) "$BACKEND" system_status ;; + system_check_connectivity) "$BACKEND" system_check_connectivity ;; + system_toggle_quic) "$BACKEND" system_toggle_quic ;; + system_toggle_ipv6) "$BACKEND" system_toggle_ipv6 ;; + system_toggle_flow_offloading_fix) "$BACKEND" system_toggle_flow_offloading_fix ;; + system_uninstall_panel) json_get_var mode mode; "$BACKEND" system_uninstall_panel "$mode" ;; + zm_cleanup_status) "$BACKEND" zm_cleanup_status ;; + 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" ;; + 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 ;; + 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 ;; + 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_get) "$BACKEND" ui_theme_get ;; + ui_theme_set) json_get_var theme theme; "$BACKEND" ui_theme_set "$theme" ;; + 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_cleanup_status", + "mixomo_status", + "mixomo_proxies", + "mixomo_config_get", + "mixomo_warp_status", + "zapret_latest_version", + "bytetube_installed", + "health", + "versions", + "awg_status", + "term_status", + "adblock_status", + "steer_status", + "forkop_status", + "forkop_config_get", + "sysinfo_get", + "ui_theme_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", + "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", + "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", + "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", + "term_action", + "adblock_action", + "steer_action", + "forkop_config_set", + "forkop_action", + "sysinfo_run", + "jobs_cancel", + "ui_theme_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/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" } + } +} +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: 'нет соединения' }; + +/* Запросы на чтение, сделанные страницей в один момент (обычно при открытии — 4–8 сразу), уходят на роутер + * одним вызовом multi: скрипт панели разбирается один раз, а не на каждый запрос. Ответ каждого — такой же, + * как при отдельном вызове. Роутер не знает multi (старая версия) или ответ не разобрался — те же запросы + * уходят по отдельности, как раньше. */ +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) { + /* число вместо ответа — ошибка ubus: на роутере старый плагин без multi */ + 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 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 callZapretLatestVersion = zmDeclare({ object: 'zapret-manager', method: 'zapret_latest_version', expect: {} }); +var callZapret2Action = zmDeclare({ object: 'zapret-manager', method: 'zapret2_action', params: ['action'], expect: {} }); +var callStrategyListV = zmDeclare({ object: 'zapret-manager', method: 'strategy_list_v', expect: {} }); +var callStrategySetV = zmDeclare({ object: 'zapret-manager', method: 'strategy_set_v', params: ['version'], expect: {} }); +var callStrategyListFlowseal = zmDeclare({ object: 'zapret-manager', method: 'strategy_list_flowseal', params: ['action'], expect: {} }); +var callStrategySetFlowseal = zmDeclare({ object: 'zapret-manager', method: 'strategy_set_flowseal', params: ['name'], expect: {} }); +var callStrategyListYoutube = zmDeclare({ object: 'zapret-manager', method: 'strategy_list_youtube', params: ['action'], expect: {} }); +var callStrategySetYoutube = zmDeclare({ object: 'zapret-manager', method: 'strategy_set_youtube', params: ['name'], expect: {} }); +var callYoutubeQuicSet = zmDeclare({ object: 'zapret-manager', method: 'youtube_quic_set', params: ['mode'], expect: {} }); +var callDiscordStatus = zmDeclare({ object: 'zapret-manager', method: 'discord_status', expect: {} }); +var callDiscordSetDv = zmDeclare({ object: 'zapret-manager', method: 'discord_set_dv', params: ['num'], expect: {} }); +var callDiscordSetFake = zmDeclare({ object: 'zapret-manager', method: 'discord_set_fake', params: ['file'], expect: {} }); +var callGameStatus = zmDeclare({ object: 'zapret-manager', method: 'game_status', expect: {} }); +var callGameSet = zmDeclare({ object: 'zapret-manager', method: 'game_set', params: ['choice'], expect: {} }); +var callGameSetFake = zmDeclare({ object: 'zapret-manager', method: 'game_set_fake', params: ['file'], expect: {} }); +var callGameToggleXtreme = zmDeclare({ object: 'zapret-manager', method: 'game_toggle_xtreme', expect: {} }); +var callSystemStatus = zmDeclare({ object: 'zapret-manager', method: 'system_status', expect: {} }); +var callSystemCheckConnectivity = zmDeclare({ object: 'zapret-manager', method: 'system_check_connectivity', expect: {} }); +var callSystemToggleQuic = zmDeclare({ object: 'zapret-manager', method: 'system_toggle_quic', expect: {} }); +var callSystemToggleIpv6 = zmDeclare({ object: 'zapret-manager', method: 'system_toggle_ipv6', expect: {} }); +var callSystemToggleFlowOffloadingFix = zmDeclare({ object: 'zapret-manager', method: 'system_toggle_flow_offloading_fix', expect: {} }); +var callSystemUninstallPanel = zmDeclare({ object: 'zapret-manager', method: 'system_uninstall_panel', params: ['mode'], expect: {} }); +var callZmCleanupStatus = zmDeclare({ object: 'zapret-manager', method: 'zm_cleanup_status', expect: {} }); +var callZmCleanup = zmDeclare({ object: 'zapret-manager', method: 'zm_cleanup', 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: {} }); + +/* uhttpd принимает в /ubus не больше 64 КБ за запрос: длинный текст сначала уходит частями, + а сам вызов получает метку @zmupload — роутер подставит собранный текст */ +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 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 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 { + /* тёмную тему определяем по настоящему цвету фона: bootstrap и другие темы объявляют свои + * переменные и в светлом, и в тёмном режиме, поэтому одного их наличия мало */ + 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; }; + /* фон бывает у обёртки темы, а не у body (argon и др.) — тогда выдаёт светлый цвет текста */ + 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) { } +} + +/* Номер сборки панели: установщик подставляет его сюда же, в build.txt и в адрес style.css. */ +var ZM_BUILD = '__ZM_BUILD__'; + +function cssUrl(path) { + return L.resource(path) + (/^[0-9]{6,}$/.test(ZM_BUILD) ? '?v=' + ZM_BUILD : ''); +} + +/* После обновления браузер может держать старые файлы панели: LuCI грузит их с одним и тем же + * адресом (?v= — версия самой LuCI, она не меняется), а uhttpd не запрещает кэш. Проверка: + * build.txt читаем всегда с сервера. Если в нём другой номер, чем в этом common.js, — common.js + * устарел: перекачиваем все файлы панели мимо кэша и перезагружаем страницу (не больше двух раз + * подряд на одну сборку, чтобы не зациклиться). Если номер тот же, но браузер в этой сборке ещё + * не проверялся, сверяем остальные файлы (виды страниц): кэш против сервера. */ +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; }); }; + /* по 4 запроса одновременно: uhttpd на роутере обслуживает всего несколько запросов разом */ + 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) {} +} + +/* Windows не рисует флаги стран (🇩🇪 видно как «DE»). Если браузер флаги не умеет, подключаем + * шрифт только с флагами (unicode-range — остальной текст остаётся шрифтом темы). Без интернета + * всё как раньше: буквы вместо флага. */ +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); +} + +function injectCss() { + cacheGuard(); + try { flagFont(); } catch (e) {} + detectMissingThemeVar(); + 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: 'обновление панели' }; +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; +} + +/* relogin — нужен новый вход в LuCI (поставлено приложение со своими правами доступа); без него — + * просто перечитать страницу: сессия остаётся, выходить не надо. */ +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; + +/* Уведомление «идёт работа» — в том же месте и том же виде, что обычные уведомления сбоку, но с + * крутящимся значком и без таймера: висит, пока работа не закончится (close), текст можно менять (set). */ +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; +} + +/* Каждый пункт включается и выключается сам по себе: Meta, Instagram, Facebook и WhatsApp не тянут друг друга. */ +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 swRow(on, label, hint, click, disabled) { + return E('div', { 'class': 'zm-swrow' + (disabled ? ' zm-swrow-off' : ''), 'click': function(ev) { if (!disabled && click) click(ev); } }, [ + E('div', { 'class': 'zm-switch' + (on ? ' zm-switch-on' : '') }, [ E('span') ]), + E('div', { 'class': 'zm-swrow-text' }, [ E('div', { 'class': 'zm-swrow-label' }, label), hint ? E('div', { 'class': 'zm-hint' }, hint) : E([]) ]) + ]); +} + +function 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) { + 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) 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); + /* фокус: в поле ввода, иначе на кнопку подтверждения — Enter сразу подтверждает, Esc отменяет */ + setTimeout(function() { try { (o._focus || okBtn).focus(); if (o._focus) o._focus.select(); } catch (e) {} }, 0); + }); +} + +/* Выбранный узел (сервер) не прошёл проверку перед выбором. Предлагаем рабочий (самый быстрый из + * проверенных или автовыбор), выбрать этот всё равно или отменить; что будет при «всё равно» — по настройке + * «Если … не отвечает». Ответ — 'best' (рабочий), 'force' (этот всё равно) или null. */ +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; }); +} + +/* Вопрос «да/нет» в оформлении панели вместо confirm() браузера. Текст как у confirm: первая строка — + заголовок, остальное (после пустой строки) — пояснение. Подпись кнопки — глагол из вопроса + («Перезагрузить», «Очистить»), опасные действия — красной кнопкой. Ответ — Promise. */ +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; }); +} + +/* Строка ввода в оформлении панели вместо prompt(). Ответ — введённый текст или null (отмена). */ +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; }); +} + +/* Плашка несохранённых изменений. После «Сохранить/Применить» она сразу убирается, а ход работы + * («Применяем… что именно») показывает уведомление сбоку — как и все остальные уведомления панели; когда + * работа закончена, оно исчезает (итог сообщает уведомление страницы). Изменения так и не приняты — + * плашка с кнопками возвращается. + * onSave может вернуть false (не начали — например, ошибка в данных) или Promise (false/ошибка — не вышло). + * Promise считается концом работы; async: true — он лишь запускает фоновую операцию (итог — в окне вывода). */ +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(); + /* страницы зовут set() при каждом опросе роутера — без изменений DOM не трогаем */ + 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); }); + } + /* обработчик ничего не запустил (ошибка в данных показана уведомлением) — через 2,5 с возвращаем кнопки */ + 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 ]); +} + +/* Окно «Роутер перезагружается»: значок работы, заголовок, короткое пояснение и счётчик времени. + * Через 25 с начинаем спрашивать роутер; ответил — перечитываем страницу. note — от прежних версий, не нужен */ +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' }, 'Страница переподключится сама\u00a0— обычно через 1–2\u00a0минуты.'); + 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; return; } + if (r && r[key] === false) return call().then(function() { toast('Не получилось — попробуйте ещё раз', 'error'); b.disabled = false; }); + toast(okText, 'info'); + if (done) done(); + }).catch(function() { b.disabled = false; toast('Роутер не ответил', 'error'); }); + } }, btn) + ])); + } + if (h && h.reboot_hint) box.appendChild(rebootBanner(h.reboot_hint)); + if (h && h.flow_warn) one('Включён Flow Offloading, а FIX для него не применён — Zapret может работать с перебоями.', 'Применить FIX', 'flow_offloading_fix', callSystemToggleFlowOffloadingFix, 'FIX применён'); + if (h && h.ipv6_warn) one('Роутер работает по IPv6, а в Zapret IPv6 выключен — часть сайтов может открываться без обхода.', 'Включить IPv6', 'ipv6_enabled', callSystemToggleIpv6, 'IPv6 в Zapret включён'); + return box; +} + +function exclParse(v, exact) { + var seen = {}, out = []; + String(v || '').split(/[|,\n]/).forEach(function(x) { + x = x.trim(); + var k = exact ? x : x.toLowerCase(); + if (x && !seen[k]) { seen[k] = true; out.push(x); } + }); + return out; +} + +function exclNorm(v) { return exclParse(v).join('|'); } + +function exclVariants(list) { + var seen = {}, out = []; + (list || []).forEach(function(m) { + var lo = m.toLowerCase(), up = m.toUpperCase(); + [ m, lo, up, up.charAt(0) + lo.slice(1) ].forEach(function(x) { if (x && !seen[x]) { seen[x] = true; out.push(x); } }); + }); + return out; +} + +function exclHit(name, list) { + var n = String(name || '').toLowerCase(); + return (list || []).some(function(m) { return m && n.indexOf(m.toLowerCase()) !== -1; }); +} + +function exclField(o) { + var input = E('input', { 'type': 'text', 'class': 'cbi-input-text zm-excl-input', 'spellcheck': 'false', 'maxlength': '400', + 'placeholder': o.placeholder || 'LTE|Мобильный|🇪🇺', 'style': 'width:100%; max-width:560px' }); + input.value = o.value || ''; + var info = E('div', { 'class': 'zm-hint', 'style': 'margin:6px 0 0' }); + function sync() { + var list = exclParse(input.value), names = typeof o.names === 'function' ? (o.names() || []) : []; + var hit = names.filter(function(n) { return exclHit(n, list); }).length; + info.textContent = !list.length ? (o.emptyText || 'Фильтр выключен — видны все серверы.') + : names.length ? 'Под фильтр попадает ' + hit + ' из ' + names.length + (hit >= names.length ? ' — это все серверы, так работать не будет' : '') + : 'Маркеров: ' + list.length + '. Регистр букв не важен.'; + info.style.color = list.length && names.length && hit >= names.length ? 'var(--zm-bad, #dc2626)' : ''; + if (o.onChange) o.onChange(exclNorm(input.value)); + } + input.addEventListener('input', sync); + var el = E('div', { 'class': 'zm-excl' }, [ + E('div', { 'class': 'zm-label', 'style': 'margin-bottom:6px' }, [ o.label || 'Скрывать серверы, в названии которых есть' ]), + input, info, + E('p', { 'class': 'zm-hint', 'style': 'margin:6px 0 0' }, [ o.hint || 'Маркеры через | — например LTE|Мобильный|🇪🇺. Совпадение по части названия, регистр не важен. Такие серверы не показываются и не используются.' ]) + ]); + el.input = input; + el.value = function() { return exclNorm(input.value); }; + el.set = function(v) { input.value = v || ''; sync(); }; + el.refresh = sync; + setTimeout(sync, 0); + return el; +} + +function exactHit(name, list) { + var n = String(name || ''); + return (list || []).some(function(m) { return m && n.indexOf(m) !== -1; }); +} + +function hideTokens(names, exact) { + var hit = exact ? exactHit : exclHit; + var rxFlag, rxPic, rxWord; + try { + rxFlag = new RegExp('\\p{Regional_Indicator}{2}', 'gu'); + rxPic = new RegExp('\\p{Extended_Pictographic}', 'gu'); + rxWord = new RegExp('[\\p{L}\\p{N}]+', 'gu'); + } catch (e) { return []; } + var cand = {}; + function add(key, label, kind) { + var c = cand[key] || (cand[key] = { key: key, kind: kind, forms: {} }); + c.forms[label] = (c.forms[label] || 0) + 1; + } + names.forEach(function(n) { + n = String(n || ''); + var flags = n.match(rxFlag) || []; + flags.forEach(function(f) { add(f, f, 'flag'); }); + var rest = n; + flags.forEach(function(f) { rest = rest.split(f).join(' '); }); + (rest.match(rxPic) || []).forEach(function(x) { add(x, x, 'pic'); }); + (n.match(rxWord) || []).forEach(function(w) { + if (w.length < 2 || /^\d+$/.test(w)) return; + add(exact ? w : w.toLowerCase(), w, 'word'); + }); + }); + var total = names.length, out = []; + Object.keys(cand).forEach(function(k) { + var c = cand[k], best = '', bn = -1; + Object.keys(c.forms).forEach(function(f) { if (c.forms[f] > bn) { bn = c.forms[f]; best = f; } }); + var cnt = names.filter(function(n) { return hit(n, [ k ]); }).length; + if (cnt < 2 || cnt >= total) return; + out.push({ marker: c.kind === 'word' ? best : k, label: c.kind === 'pic' ? k + '️' : best, kind: c.kind, count: cnt }); + }); + var rank = { flag: 0, pic: 1, word: 2 }; + out.sort(function(x, y) { return rank[x.kind] - rank[y.kind] || y.count - x.count || (x.label < y.label ? -1 : 1); }); + var lim = { flag: 40, pic: 12, word: 24 }, used = { flag: 0, pic: 0, word: 0 }; + return out.filter(function(t) { return used[t.kind]++ < lim[t.kind]; }); +} + +function hidePicker(o) { + o = o || {}; + var W = o.words || {}; + var hit = o.exact ? exactHit : exclHit, low = o.exact ? function(x) { return x; } : function(x) { return x.toLowerCase(); }; + var noun = o.noun || 'серверы'; + var wHide = W.hide || 'Скрыть', wHidden = W.hidden || 'Скрыто', wOn = W.on || 'скрыт', wOff = W.off || 'виден'; + var wDesc = W.desc || ('Эти ' + noun + ' не показываются в списке и не используются.'); + var wHint = W.hint || ('Отметьте ' + noun + ', которые нужно скрыть, и нажмите «Сохранить». Скрытые не показываются и не используются — ни вручную, ни в автовыборе.'); + var el = E('div', { 'class': 'zm-hide' }); + var items = [], info = {}, selecting = false, pick = {}, phr = [], phr0 = '', phrI = [], saving = false, lastSig = ''; + var bar = saveBar({ saveLabel: 'Сохранить', busyText: 'Сохраняем…', onCancel: function() { stop(); }, onSave: save }); + function real() { return items.filter(function(it) { return !it.gone; }); } + function hiddenCount() { return items.filter(function(it) { return it.hidden || it.locked; }).length; } + function byPhrase(it) { return !!((phr.length && hit(it.name, phr)) || (it.locked && phrKey(phr) === phr0)); } + function isOn(it) { return byPhrase(it) || !!pick[it.name]; } + function pickedCount() { return real().filter(isOn).length; } + function uniq(a) { var o = {}; a.forEach(function(x) { o[x] = true; }); return Object.keys(o).sort().join('\n'); } + function phrKey(a) { return a.map(low).sort().join('|'); } + function mode(on) { selecting = on; if (o.onMode) o.onMode(on); render(); } + function start() { + pick = {}; + phr = exclParse(info.markers || '', o.exact); + phr0 = phrKey(phr); phrI = phr.slice(); + items.forEach(function(it) { if (it.hidden) pick[it.name] = true; }); + mode(true); + } + function stop() { pick = {}; phr = []; bar.set(false); mode(false); } + function sigWas() { return uniq(items.filter(function(it) { return it.hidden && !hit(it.name, phrI); }).map(function(it) { return it.name; })) + '#' + phr0; } + function sigNow() { return uniq(Object.keys(pick).filter(function(k) { return pick[k] && !hit(k, phr); })) + '#' + phrKey(phr); } + function syncBar() { + var same = sigWas() === sigNow(); + bar.set(true, saving, wHidden + ' будет: ' + pickedCount() + ' из ' + real().length + (same ? ' — без изменений' : ''), same ? 'Готово' : 'Сохранить'); + Array.prototype.forEach.call(el.querySelectorAll('.zm-hide-tools button, .zm-hide-quick button'), function(b) { b.disabled = saving; }); + } + function save() { + if (saving) return; + if (sigWas() === sigNow()) { stop(); return; } + var names = Object.keys(pick).filter(function(k) { return pick[k] && !hit(k, phr); }); + var r = real(), left = r.filter(function(it) { return !isOn(it); }).length; + if (r.length && !left) { toast('Нельзя убрать все ' + noun + ' — тогда подключение не будет работать', 'warning'); return; } + var mk = phr.join('|'); + saving = true; syncBar(); + Promise.resolve(o.onSave(names, mk)).then(function(ok) { + saving = false; + if (ok === false) { syncBar(); return; } + var keep = phr.slice(); + info.hideNames = names; info.markers = mk; + items = items.filter(function(it) { return !it.gone || names.indexOf(it.name) >= 0; }); + items.forEach(function(it) { it.hidden = names.indexOf(it.name) >= 0; it.locked = !it.hidden && keep.length > 0 && hit(it.name, keep); }); + stop(); + }, function() { saving = false; syncBar(); }); + } + function reset() { + 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() : '', 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; +} + +/* «Куда пойдёт запрос»: в каких списках нашёлся домен или адрес — строки для карточки результата. + * w — [{ state, name, list, entry, win, sec }]: state x — исключения, on — действует, off — не выбран или + * выключен; win — именно это правило и решило, куда пойдёт запрос. Пусто — null. */ +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; +} + +/* Плашки «Понятно, больше не показывать»: отметка хранится на роутере (LuCI и Web UI — разные адреса, + браузер держит их хранилища раздельно), а в браузере — только как быстрый запас до ответа роутера */ +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() {}); +} + +/* Скопировать текст: на роутере страница открыта по http, где navigator.clipboard недоступен — тогда старый способ */ +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(); +} + +/* Окно-предупреждение на весь экран (фон размыт) с галочкой «Понимаю…»: кнопка доступна, только когда она + * отмечена. o: title, lead, items [[заголовок, текст]…], tip {title, text}, note (жёлтая/красная строка: + * noteKind 'warn'|'bad'), check, okText. Ответ — Promise. */ +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) { + 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); + } + /* o.info — действие уже сделано (например, блокировка давно включена): только «Понятно», окно не закрыть мимо */ + 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); } + 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': id + '-t', + 'click': function(ev) { if (ev.target === wrap && !o.info) close(false); } }, [ + E('div', { 'class': 'zm-card zm-dlg zmw-modal zm-wn', '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': 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(function(it) { + return E('li', {}, [ E('i', { 'aria-hidden': 'true' }, '!'), E('div', {}, [ E('b', {}, it[0]), it[1] ? E('span', {}, ' — ' + it[1]) : '' ]) ]); + })) : '', + 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) : '', + 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 ? [ okBtn ] : [ E('button', { 'class': 'cbi-button', 'click': function() { close(false); } }, 'Отмена'), okBtn ]) + ]) + ]); + sync(); + document.documentElement.classList.add('zm-wn-lock'); + document.body.appendChild(wrap); + document.addEventListener('keydown', onKey, true); + setTimeout(function() { try { wrap.querySelector('.zm-wn').focus({ preventScroll: true }); } catch (e) {} }, 0); + }); +} + +/* Такие предупреждения показываются один раз: после «Понимаю» отметка сохраняется на роутере + * (и в браузере как запас), и при следующих сменах и включениях окно больше не появляется. + * open() — показать окно, ответ Promise. Ответ warnOnce — можно ли продолжать. */ +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; }); + }); +} + +/* Предупреждение про метку ts (--dpi-desync-fooling=ts): на Windows нужны метки времени TCP. + * На весь экран, фон размыт; кнопка доступна, только когда отмечено «Понимаю…». + * o.parts — в каких частях стратегии ts; o.note — что ещё важно знать; o.info — стратегия уже стоит + * (её поставил автоподбор): одна кнопка «Понятно». Ответ — Promise. */ +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) { + 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); + 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, + toast: toast, + notifyStrategyResult: notifyStrategyResult, + status: callStatus, + systemInfo: callSystemInfo, + zapretAction: callZapretAction, + zapretLatestVersion: callZapretLatestVersion, + zapret2Action: callZapret2Action, + strategyListV: callStrategyListV, + strategySetV: callStrategySetV, + strategyListFlowseal: callStrategyListFlowseal, + strategySetFlowseal: callStrategySetFlowseal, + strategyListYoutube: callStrategyListYoutube, + strategySetYoutube: callStrategySetYoutube, + youtubeQuicSet: callYoutubeQuicSet, + discordStatus: callDiscordStatus, + discordSetDv: callDiscordSetDv, + discordSetFake: callDiscordSetFake, + gameStatus: callGameStatus, + gameSet: callGameSet, + gameSetFake: callGameSetFake, + gameToggleXtreme: callGameToggleXtreme, + systemStatus: callSystemStatus, + systemCheckConnectivity: callSystemCheckConnectivity, + systemToggleQuic: callSystemToggleQuic, + systemToggleIpv6: callSystemToggleIpv6, + systemToggleFlowOffloadingFix: callSystemToggleFlowOffloadingFix, + systemUninstallPanel: callSystemUninstallPanel, + zmCleanupStatus: callZmCleanupStatus, + zmCleanup: callZmCleanup, + 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, + testStatus: callTestStatus, + testAction: bigArg2(callTestAction), + zapretOptSet: callZapretOptSet, + systemTimeSet: callSystemTimeSet, + testResults: callTestResults, + zmUpdateStatus: callZmUpdateStatus, + zmUpdateAction: callZmUpdateAction, + mixomoStatus: callMixomoStatus, + mixomoAction: callMixomoAction, + mixomoConfigGet: callMixomoConfigGet, + mixomoConfigSet: bigText(callMixomoConfigSet), + mixomoApplySubscription: callMixomoApplySubscription, + 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), + 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), + jobsCancel: callJobsCancel, + dock: dock, + riCovers: riCovers, + svcPickToggle: svcPickToggle, + svcIco: svcIco, + riNote: riNote, + swRow: swRow, + dialog: dialog, + deadNodeDialog: deadNodeDialog, + ask: ask, + askText: askText, + saveBar: saveBar, + exclParse: exclParse, + exclNorm: exclNorm, + exclVariants: exclVariants, + exclHit: exclHit, + exclField: exclField, + 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 {}; }) + ]); + }, + + render: function(all) { + var view = this; + var data = all[0], dohData = all[1], hostsData = all[2], sysData = all[3]; + var sysInfo = all[4] || {}; + var zmUpdate = all[5] || {}; + var healthData = all[6] || {}; + var boardInfo = all[7] || {}; + var wrap = E('div', { 'class': 'zm-wrap' }); + var overviewEl = E('div', {}); + var cards = E('div', { 'class': 'zm-cards' }); + + + function row(label, node) { + return E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, label), node ]); + } + + + function st(h, key, fallback) { + return (h && typeof h[key] === 'number') ? h[key] : fallback; + } + + function renderOverview(d, doh, hosts, sys, h) { + var 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' }), 'по вашему списку' ]) : '' ]))); + items.push(row('Zapret2', zm.stateBadge(zr2St))); + var warnBadge = function(text) { return E('span', { 'class': 'zm-badge zm-warn' }, [ E('span', { 'class': 'zm-dot' }), text ]); }; + var offBadge = function(text) { return E('span', { 'class': 'zm-badge zm-off' }, [ E('span', { 'class': 'zm-dot' }), text ]); }; + var stSt = st(h, 'steer', 0); + var stVia = { warp: 'через WARP', own: 'через свой WARP', vpn: 'через VPN' }[h.steer_exit] || ''; + items.push(row('Steer', stSt === 1 ? zm.badge(true, 'работает' + (stVia ? ' · ' + stVia : ''), '') + : stSt === 2 ? zm.badge(false, '', 'не работает' + (stVia ? ' · ' + stVia : '')) + : stSt === 5 ? offBadge(h.steer_off ? 'выключен' : h.steer_exit === 'none' ? 'подключите WARP или VPN' : 'сервисы не выбраны') + : zm.badge(false, '', 'не установлен'))); + var fkSt = st(h, 'forkop', 0); + items.push(row('Forkozz', fkSt === 1 ? zm.badge(true, 'работает', '') + : fkSt === 2 ? zm.badge(false, '', 'не работает') + : fkSt === 5 ? offBadge('выключен') + : zm.badge(false, '', 'не установлен'))); + items.push(row('ByeTube', zm.stateBadge(st(h, 'bytetube', 0)))); + items.push(row('TG WS Proxy', zm.stateBadge(st(h, 'tg', 0)))); + items.push(row('Mixomo', zm.stateBadge(st(h, 'mixomo', 0)))); + items.push(row('DNS over HTTPS', dohNode)); + var awgSt = st(h, 'awg', 0); + items.push(row('AmneziaWG', awgSt === 1 ? zm.badge(true, h.awg_up ? 'туннелей работает: ' + h.awg_up : 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, '', 'не установлен'))); + 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('выключена'))); + 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 {}; }) + ]).then(function(res) { + overviewEl.innerHTML = ''; + overviewEl.appendChild(renderOverview(res[0], res[1], res[2], res[3], res[4])); + 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 || []; + /* последние версии проверяются только кнопкой; checked — проверка уже была хоть раз */ + 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' ? 'новые версии ищутся только по этой кнопке' + : 'роутер сам проверяет раз в день, около 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 && 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)); + 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(); })); + /* в Web UI название и версия уже есть сверху и в боковой панели — карточку-заголовок не показываем */ + 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(); + /* Web UI: боковая панель берёт эти же цифры отсюда, а не спрашивает роутер второй раз */ + 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(); + }).catch(function() {}).then(function() { sysBusy = false; }); + /* раз в 10 секунд (обзор служб — раз в 30): каждый опрос — это запуск программы на роутере, + * а на слабых роутерах частые опросы с открытым Обзором держали процессор в сотке */ + }, 10000); + + var zmUpdateBusy = false; + function waitForServerAndReload() { + setTimeout(function() { location.href = L.url('admin/logout'); }, 4000); + } + function renderZmUpdate() { + updateEl.innerHTML = ''; + if (!zmUpdate.latest || zmUpdate.latest === zmUpdate.current || zmUpdate.newer === false) return; + updateEl.appendChild(zm.floatBanner(E('div', { 'class': 'zm-refresh-banner zm-show' }, [ + E('span', {}, [ 'Доступна новая версия панели Zapret Manager: ' + zmUpdate.latest + ' (у вас установлена ' + zmUpdate.current + ').' ]), + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': function() { + if (zmUpdateBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + zmUpdateBusy = true; + zm.toast('Скачиваем и проверяем новую версию…', 'info', 4000); + zm.zmUpdateAction().then(function(res) { + 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(); + + return wrap; + } +}); +ZM_INSTALLER_EOF +chmod 0644 '/www/luci-static/resources/view/zapret-manager/dashboard.js' + +mkdir -p /www/luci-static/resources/view/zapret-manager +chmod 0755 /www/luci-static/resources/view/zapret-manager +cat > '/www/luci-static/resources/view/zapret-manager/doh.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require view'; +'require zapret-manager.common as zm'; +var 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 PROVIDERS = [ + { id: 'google', label: 'Google', url: 'https://dns.google/dns-query', boot: '8.8.8.8, 8.8.4.4' }, + { id: 'cloudflare', label: 'Cloudflare', url: 'https://cloudflare-dns.com/dns-query', boot: '1.1.1.1, 1.0.0.1' }, + { id: 'quad9', label: 'Quad9', url: 'https://dns.quad9.net/dns-query', boot: '9.9.9.9, 149.112.112.112' }, + { id: 'xbox', label: 'XBOX', url: 'https://xbox-dns.ru/dns-query' }, + { id: 'comss', label: 'Comss', url: 'https://dns.comss.one/dns-query' }, + { id: 'dnsai', label: 'DNS-AI', url: 'https://dns.dns-ai.ru/dns-query' }, + { id: 'yandex_family', label: 'Яндекс Семейный', url: 'https://family.dot.dns.yandex.net/dns-query', boot: '77.88.8.7, 77.88.8.3' }, + { id: 'yandex_safe', label: 'Яндекс Безопасный', url: 'https://safe.dot.dns.yandex.net/dns-query', boot: '77.88.8.88, 77.88.8.2' }, + { id: 'geohide_ru', label: 'GeoHide RU', url: 'https://geohide.ru/dns-query' }, + { id: 'geohide_eu', label: 'GeoHide EU', url: 'https://eu.geohide.ru/dns-query' }, + { id: 'geohide_us', label: 'GeoHide US', url: 'https://us.geohide.ru/dns-query' }, + { id: 'malw', label: 'MALW', url: 'https://dns.malw.link/dns-query' }, + { id: 'zapretmoe', label: 'zapret.moe', url: 'https://dns.zapret.moe/dns-query' } +]; +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 для всей сети: интернет-провайдер не видит и не подменяет запросы устройств.'; + +var GROUPS = [ + { title: 'Обычные', hint: 'только шифрование, ничего не подменяют', ids: [ 'google', 'cloudflare', 'quad9' ] }, + { title: 'С обходом геоблокировок', hint: 'открывают сервисы, закрытые для России', ids: [ 'xbox', 'comss', 'dnsai', 'geohide_ru', 'geohide_eu', 'geohide_us', 'malw', 'zapretmoe' ] }, + { title: 'Семейные', hint: 'блокируют опасные сайты, «Семейный» — ещё и взрослые', ids: [ 'yandex_safe', 'yandex_family' ] } +]; + +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) ? 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) { + 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 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; + /* Проверка настоящим запросом. Результаты рисуются на месте, без перерисовки страницы: + * иначе ответ роутера сбивал бы фокус в поле Bootstrap DNS. */ + var probe = {}, probing = false, subs = {}, health = null, healthSeq = 0, own = []; + var svcBox = E('span', { 'class': 'zm-doh-svc' }); + var probeBtn = E('button', { 'class': 'cbi-button', 'click': runProbe }, 'Проверить все'); + var ownInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'spellcheck': 'false', 'autocapitalize': 'off', 'placeholder': 'Свой: https://dns.example.com/dns-query', 'style': 'flex:1; min-width:220px' }); + ownInput.addEventListener('keydown', function(ev) { if (ev.key === 'Enter') { ev.preventDefault(); addOwn(); } }); + + /* Что стоит сейчас: сервер из списка или свой адрес */ + 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 : ''; + } + + function syncOwn() { + var c = cur(), f = data.failover || {}; + if (isUrl(c) && own.indexOf(c) < 0) own.unshift(c); + (f.chain || []).concat(f.pref ? [ f.pref ] : []).forEach(function(u) { if (isUrl(u) && own.indexOf(u) < 0) own.push(u); }); + } + + function addOwn() { + var v = ownInput.value.trim(), i; + if (!v) return; + if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + if (/^[A-Za-z0-9.-]+\.[A-Za-z0-9-]+$/.test(v)) v = 'https://' + v + '/dns-query'; + if (!OWN_RE.test(v) || v.length > 200) { zm.toast('Нужна ссылка вида https://dns.example.com/dns-query или просто адрес сервера', 'warning'); return; } + for (i = 0; i < PROVIDERS.length; i++) if (PROVIDERS[i].url === v) { v = PROVIDERS[i].id; break; } + if (isUrl(v) && own.indexOf(v) < 0) own.push(v); + ownInput.value = ''; + pickProv = v === cur() ? null : v; + render(); + } + + 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 + ' мс' : 'не отвечает'; + el.title = r && r !== 'wait' && !r.ok ? r.err : ''; + } + + 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); }); + } + + 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 } : { ok: false, err: r.err || r.error || 'роутер не ответил' }; + }, 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; + 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 : 'Ни один сервер не ответил — проверьте интернет на роутере', good ? 'info' : 'error'); + return; + } + left++; + probeOne(queue.shift()).then(function(r) { if (r.ok) good++; 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; bootReset(); render(); }, + onSave: applyPick + }); + + function curForce() { return data.force_mode === 'off' ? 'off' : 'auto'; } + + function pending() { + var n = 0; + if (pickProv && pickProv !== cur()) n++; + if (pickForce && pickForce !== curForce()) n++; + if (bootChanged()) n++; + if (foChanged()) n++; + return n; + } + + 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 !== cur()) steps.push(function() { + var id = pickProv; + logLines.push(' → Проверяем, отвечает ли ' + label(id)); + zm.renderLog(logEl, logLines.join('\n')); + return probeOne(id).then(function(r) { + if (!r.ok) throw new Error(label(id) + ' не отвечает: ' + r.err + '. Настройки не меняли'); + 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 (!steps.length) return; + busy = true; + bar.set(true, true); + var logLines = [ '==> Применяем настройки DNS over HTTPS' ]; + logEl.classList.add('zm-show'); + var chain = Promise.resolve(); + steps.forEach(function(f) { chain = chain.then(function() { zm.renderLog(logEl, logLines.join('\n')); return f(); }); }); + chain.then(function() { + logLines.push('==> Готово — настройки применены'); + zm.toast('Настройки DNS применены', 'info'); + }).catch(function(e) { + logLines.push('==> ОШИБКА: ' + (e && e.message || 'роутер не ответил')); + zm.toast(e && e.message || 'Роутер не ответил', 'error'); + }).then(function() { + zm.renderLog(logEl, logLines.join('\n')); + busy = false; + pickProv = null; pickForce = null; foDraft = 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(); }); + } + + /* Предупреждение про DoH — один раз: перед установкой, а тем, у кого DoH уже стоит, — при входе на страницу */ + 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)); + card.innerHTML = ''; + if (!data.installed && data.forkozz && !busy) { + zm.blockCard(card, 'DNS over HTTPS', INTRO, 'Стоит Forkozz — он сам шифрует DNS. Чтобы поставить DoH, удалите Forkozz.'); + if (logEl.classList.contains('zm-show')) card.appendChild(logEl); + renderForce(); + bar.set(false, false); + 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 ])); + var curProv = pickProv || cur(); + var nodes = function(ids) { + return E('div', { 'class': 'zm-nodes zm-nodes-3' }, ids.map(function(id) { + var p = prov(id); + paintProbe(id); + return E('div', { + 'class': 'zm-node' + (curProv === id ? ' zm-active' : ''), + 'click': function() { + if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + pickProv = id === cur() ? null : id; + render(); + } + }, [ + E('div', { 'class': 'zm-node-name' }, [ label(id) ]), + E('div', { 'class': 'zm-node-foot' }, [ E('span', {}, [ (p ? p.url : id).replace(/^https:\/\//, '') ]), sub(id) ]) + ]); + })); + }; + GROUPS.concat(own.length ? [ { title: 'Свои', hint: '', ids: own } ] : []).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) + ])); + }); + card.appendChild(E('div', { 'class': 'zm-actions' }, [ ownInput, E('button', { 'class': 'cbi-button', 'click': addOwn }, 'Добавить') ])); + if (list.length > 1) card.appendChild(E('p', { 'class': 'zm-hint' }, 'Сейчас настроено несколько серверов сразу, поэтому ни один не выделен. Выбор заменит их одним.')); + renderBoot(curProv); + } + card.appendChild(logEl); + renderFo(); + renderForce(); + 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 + '.' : ' — стандартные.'))); + } + + /* ---------- Если сервер перестал отвечать: цепочка запасных серверов ---------- + * Человек сам собирает цепочку: основной → запасной 2 → 3 → 4 и что делать, если не ответил никто. + * Правки — черновик, как выбор сервера выше: применяются кнопкой на плашке внизу. */ + var foCard = E('div', { 'class': 'zm-card', 'style': 'display:none' }), foDraft = null, foPicking = false, foLats = {}; + var FO_MAX = 3, KINDS = [ 'plain', 'geo', 'family' ]; + var KIND_TEXT = { plain: 'Обычный — только шифрование', 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) { + for (var i = 0; i < GROUPS.length; i++) if (GROUPS[i].ids.indexOf(id) >= 0) return KINDS[i]; + return 'own'; + } + function kindIds(k) { var i = KINDS.indexOf(k); return i >= 0 ? GROUPS[i].ids : own; } + /* основной сервер цепочки — выбор человека; пока работает запасной, это не тот, что стоит сейчас */ + 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', '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.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; + } + 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 вернётся на него.') ])); + 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); + /* 1 — основной */ + 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 !== (f.pref || cur()) ? ' · станет основным после применения' : ' · меняется выбором сервера выше')) + ], [ foLat(main) ])); + /* 2…4 — запасные */ + 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 = '⚠ Без семейного фильтра: пока работает он, опасные и взрослые сайты не блокируются'; + 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), + 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; } }); }, i === 0), + 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.')); + } + + syncOwn(); + render(); + checkHealth(); + /* DoH уже стоит — показываем предупреждение один раз, чтобы человек знал, что делать при поломке */ + if (data.installed) setTimeout(function() { if (document.body.contains(wrap)) dohWarn(true); }, 600); + wrap.appendChild(hostsWarn); + wrap.appendChild(card); + wrap.appendChild(foCard); + wrap.appendChild(forceCard); + wrap.appendChild(bar); + return wrap; + } +}); +ZM_INSTALLER_EOF +chmod 0644 '/www/luci-static/resources/view/zapret-manager/doh.js' +cat > '/www/luci-static/resources/view/zapret-manager/awg.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require view'; +'require zapret-manager.common as zm'; +var 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 gen = { custom: false, ep: '' }; + var mk = { name: '', src: 'warp', text: '', fw: true, route: false, loaded: false }; + var open = {}; + var testRes = {}; + var confEdit = null; + + function refresh() { + return zm.awgStatus().then(function(res) { data = res || {}; renderAll(); }).catch(function() {}); + } + + function follow(job, action) { + busy = true; + logEl.classList.add('zm-show'); + renderAll(); + zm.pollJob(job, logEl, function(ok) { + busy = false; + var msg = ok ? ({ install: 'AmneziaWG установлен', update: 'AmneziaWG переустановлен', remove: 'AmneziaWG удалён', gen: 'WARP сгенерирован', + create: 'Интерфейс создан', pick: 'Точка входа подобрана', mihomo: 'WARP добавлен в Mihomo', + replace: 'Конфиг применён', regen: 'Новый WARP применён', fix: 'Туннель Steer перезапущен на новой точке входа' }[action] || 'Готово') + : 'Не получилось — подробности в журнале'; + zm.toast(msg, ok ? 'info' : 'error'); + if (ok && action === 'gen') mk.loaded = false; + if (action === 'replace' || action === 'regen') Object.keys(open).forEach(function(k) { if (open[k] === 'conf') open[k] = null; }); + refresh(); + }); + } + + function job(action, mode, toastText) { + if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return Promise.resolve(false); } + return zm.awgAction(action, mode || '').then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return false; } + if (toastText) zm.toast(toastText, 'warning'); + follow(res.job || 'awg', action); + return true; + }).catch(function() { zm.toast('Роутер не ответил', 'error'); return false; }); + } + + function steerFix(f) { + if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } + var n = f.name === 'zmwarp' ? '1' : f.name.replace(/^zmwarp/, ''); + zm.steerAction('warp_fix', n).then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return; } + zm.toast('Подбираем точку входа для ' + f.name + ', это до пары минут', 'warning'); + follow('steer', 'fix'); + }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); + } + + function quick(action, mode, okText) { + return zm.awgAction(action, mode || '').then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return res; } + if (okText) zm.toast(okText, 'info'); + refresh(); + return res; + }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); + } + + function renderMain() { + mainCard.innerHTML = ''; + mainCard.appendChild(E('h3', {}, 'AmneziaWG')); + mainCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'WireGuard с маскировкой: провайдеру сложнее заметить и заблокировать туннель.')); + var inst = !!data.installed; + mainCard.appendChild(row('Состояние', inst ? badge('zm-ok', 'установлен') : badge('zm-off', 'не установлен'))); + if (data.kmod || data.tools) { + mainCard.appendChild(row('Пакеты', E('span', { 'style': 'overflow-wrap:anywhere' }, [ + 'kmod-amneziawg ' + (data.kmod || '—') + ' · amneziawg-tools ' + (data.tools || '—') + (data.luci ? ' · ' + (data.luci_pkg || 'luci-proto') + ' ' + data.luci : '') + ]))); + } + if (inst) { + mainCard.appendChild(row('Модуль ядра', data.module ? badge('zm-ok', 'загружен') : badge('zm-bad', 'не загружен'))); + mainCard.appendChild(row('Поддержка в сети', data.proto ? badge('zm-ok', 'есть') : badge('zm-warn', 'нет — нажмите «Доустановить»'))); + } + if (busy) { + mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Сейчас: ' + (PHASE_TEXT[data.phase] || 'идёт операция') + '… Можно закрыть страницу — роутер всё доделает сам.')); + return; + } + var acts = []; + if (!inst) acts.push(E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { + job('install', '', 'Устанавливаем AmneziaWG, сеть может ненадолго пропасть…'); + } }, 'Установить AmneziaWG')); + else { + if (!data.module || !data.proto) acts.push(E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { + job('install', '', 'Доустанавливаем AmneziaWG'); + } }, 'Доустановить')); + acts.push(E('button', { 'class': 'cbi-button', 'click': function() { + 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 ? '' : ' (создан не панелью)') + ' удалится — с ключами и зоной файрвола'; })) } ]; + 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 !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', 'работает в Forkozz') : '', + steer && data.steer_active ? (data.steer_active === f.name ? badge('zm-ok', 'сейчас через него идёт трафик') : live(f) ? badge('zm-off', 'запасной') : '') : '' + ]); + var box = E('div', { 'class': 'zm-awg-if' }, [ head ]); + var hide = !f.warp || f.name === 'zmwarp4'; + if (f.address) box.appendChild(row('Адрес', E('span', {}, hide ? zm.secret(f.address.replace(/,/g, ', ')) : f.address.replace(/,/g, ', ')))); + box.appendChild(row('Точка входа', E('span', { 'style': 'overflow-wrap:anywhere' }, [ f.endpoint ? (hide ? zm.secret(f.endpoint) : f.endpoint) : '—' ]))); + box.appendChild(row('Рукопожатие', E('span', {}, age(f.hs_age)))); + if (f.rx || f.tx) box.appendChild(row('Трафик', E('span', {}, '↓ ' + bytes(f.rx) + ' · ↑ ' + bytes(f.tx)))); + box.appendChild(row('Зона firewall', E('span', {}, [ String(f.zone || 'нет — устройства сети в туннель не попадут') ]))); + box.appendChild(row('Что идёт', E('span', {}, f.route_all ? 'весь трафик роутера' : 'то, что направят Forkozz или Steer'))); + var t = testRes[f.name]; + if (t) { + var ti = []; + if (t.colo) ti.push('сервер ' + t.colo + (t.city ? ' (' + t.city + ')' : '')); + if (t.seen) ti.push('сайты видят: ' + country(t.seen)); + if (t.loss !== undefined && t.loss !== '') ti.push('потери ' + t.loss + '%' + (t.rtt ? ', ' + t.rtt + ' мс' : '')); + if (t.warp && t.warp !== 'off') ti.push('warp=' + t.warp); + box.appendChild(row('Проверка', t.ok + ? badge(t.torn ? 'zm-warn' : 'zm-ok', E('span', {}, [ 'выход ', hide ? zm.secret(t.ip || '?') : (t.ip || '?'), ti.length ? ' · ' + ti.join(' · ') : '' ])) + : badge('zm-bad', 'через туннель ничего не открылось'))); + if (t.ok && t.torn) box.appendChild(E('p', { 'class': 'zm-hint' }, 'Связь обрывается через пару секунд — так DPI рвёт туннель. ' + + (f.warp ? 'Подберите точку входа заново.' : 'Попробуйте другую точку входа или маску I1.'))); + } + + var acts = [ + E('button', { 'class': 'cbi-button cbi-button-action', 'click': function(ev) { + ev.target.disabled = true; + zm.awgAction('test', f.name).then(function(r) { + testRes[f.name] = r || {}; + zm.toast(r && r.ok ? (r.torn ? f.name + ': связь есть, но обрывается' : f.name + ': туннель работает') : f.name + ': через туннель ничего не открылось', r && r.ok ? (r.torn ? 'warning' : 'info') : 'error'); + refresh(); + }).catch(function() { zm.toast('Роутер не ответил', 'error'); refresh(); }); + } }, 'Проверить') + ]; + if (!steer) { + acts.push(E('button', { 'class': 'cbi-button', 'click': function() { quick('restart', f.name, f.name + ' перезапущен'); } }, 'Перезапустить')); + acts.push(f.enabled + ? E('button', { 'class': 'cbi-button', 'click': function() { quick('down', f.name, f.name + ' выключен'); } }, 'Выключить') + : E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { quick('up', f.name, f.name + ' включён'); } }, 'Включить')); + acts.push(E('button', { 'class': 'cbi-button', 'click': function() { open[f.name] = open[f.name] === 'ep' ? null : 'ep'; renderIfaces(); } }, 'Точка входа')); + } + if (!(steer && (data.steer_own || f.name === 'zmwarp4'))) acts.push(E('button', { 'class': 'cbi-button cbi-button-action', 'click': function() { + 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 (f.forkozz) { + zm.dialog({ title: 'Туннель ' + f.name + ' занят', okText: 'Понятно', cancelText: '', + blocks: [ { type: 'note', kind: 'warn', text: 'Через этот туннель сейчас работает Forkozz. Если его удалить, сервисы Forkozz перестанут открываться.' }, + { type: 'list', title: 'Что сделать сначала', items: [ 'Выключите 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 ? '' : ', сейчас выключен') + '. Конфиг меняется здесь или в Steer.' + : data.steer_own ? 'Туннель Steer. Выключен, пока работает свой WARP.' + : 'Туннель Steer. Ключи и конфиг меняются здесь, остальное — в Steer.')); + if (steer && f.warp && f.enabled && f.name !== 'zmwarp4' && !data.steer_own && !live(f)) { + box.appendChild(E('p', { 'class': 'zm-hint' }, [ 'У каждого туннеля Steer свои ключи, и даже без трафика он раз в 25 секунд связывается с Cloudflare — у рабочего туннеля рукопожатие не старше 3 минут. Здесь его нет: точка входа перестала отвечать.' + (data.steer_active && data.steer_active !== f.name ? ' Steer сейчас идёт через ' + data.steer_active + '.' : '') ])); + box.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': busy ? '' : null, 'click': function() { steerFix(f); } }, 'Подобрать точку входа') ])); + } + if (open[f.name] === 'ep') box.appendChild(epEditor(f)); + if (open[f.name] === 'conf') { + var ta = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false', 'style': 'min-height:220px' }); + ta.value = open[f.name + ':orig'] != null ? open[f.name + ':orig'] : open[f.name + ':conf']; + if (open[f.name + ':orig'] == null) open[f.name + ':orig'] = ta.value; + box.appendChild(ta); + var ibar = zm.editorBar(ta, { + onCancel: function() { ta.value = open[f.name + ':orig']; open[f.name + ':conf'] = ta.value; ta.dispatchEvent(new Event('input')); }, + onSave: function(t) { + if (!/\[interface\]/i.test(t) || !/\[peer\]/i.test(t)) { zm.toast('Нужны секции [Interface] и [Peer]', 'warning'); return false; } + 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)); }); + } + + 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(); + } + genCard.appendChild(E('div', { 'class': 'zm-actions' }, [ + E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': busy ? '' : null, 'click': function() { + run('std|default', 'Генерируем WARP'); + } }, 'Сгенерировать'), + E('button', { 'class': 'cbi-button', 'disabled': busy ? '' : null, 'click': function() { + run('std|auto', 'Генерируем WARP и подбираем точку входа'); + } }, 'С подбором точки входа'), + E('button', { 'class': 'cbi-button' + (gen.custom ? ' cbi-button-action' : ''), 'disabled': busy ? '' : null, 'click': function() { + gen.custom = !gen.custom; renderGen(); + } }, 'Своя точка входа') + ])); + if (gen.custom) { + var ci = E('input', { 'class': 'cbi-input-text', 'type': 'text', 'placeholder': 'например 162.159.192.7:2408', 'value': gen.ep, 'style': 'max-width:320px; width:100%' }); + ci.addEventListener('input', function() { gen.ep = ci.value.trim(); }); + genCard.appendChild(E('div', { 'class': 'zm-actions' }, [ + ci, + E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': busy ? '' : null, 'click': function() { + var ep = (gen.ep || '').trim(); + if (!/^(\[[0-9A-Fa-f:]+\]|[A-Za-z0-9.-]+):[0-9]{1,5}$/.test(ep)) { zm.toast('Точка входа: адрес:порт, например 162.159.192.7:2408', 'warning'); return; } + run('std|' + ep, 'Генерируем WARP с точкой ' + ep); + } }, 'Сгенерировать') + ])); + } + + var checkBox = E('div', { 'class': 'zm-awg-if' }, [ + E('div', { 'style': 'font-weight:600; margin-bottom:4px' }, 'С проверкой связи'), + E('p', { 'class': 'zm-hint', 'style': 'margin-top:0' }, 'Роутер сам получит ключи, переберёт точки входа и маски и запишет ту связку, где трафик реально идёт. До нескольких минут.'), + E('div', { 'class': 'zm-actions' }, [ + E('button', { 'class': 'cbi-button cbi-button-action', 'disabled': (busy || !data.installed) ? '' : null, 'click': function() { + if (!data.installed) { zm.toast('Сначала установите AmneziaWG', 'warning'); return; } + run('check', 'Генерируем WARP с проверкой, это несколько минут'); + } }, 'Сгенерировать с проверкой'), + !data.installed ? E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Нужен установленный AmneziaWG') : '' + ]) + ]); + genCard.appendChild(checkBox); + + if (data.warp_conf) { + genCard.appendChild(row('Файл', E('span', {}, data.warp_path || '/root/WARP.conf'))); + if (data.warp_endpoint) genCard.appendChild(row('Точка входа', E('span', {}, data.warp_endpoint))); + var acts = [ + E('button', { 'class': 'cbi-button', 'click': function() { + if (confEdit !== null) { confEdit = null; renderGen(); return; } + zm.awgAction('conf_get', '').then(function(r) { confEdit = (r && r.content) || ''; renderGen(); }); + } }, confEdit !== null ? 'Скрыть' : 'Показать и изменить'), + E('button', { 'class': 'cbi-button cbi-button-action', 'click': function() { + mk.src = 'warp'; mk.loaded = false; newCard.scrollIntoView({ behavior: 'smooth', block: 'start' }); renderNew(); + } }, 'Сделать интерфейс') + ]; + if (data.mihomo) acts.push(E('button', { 'class': 'cbi-button', 'click': function() { + if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } + zm.awgAction('mihomo', '').then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return; } + zm.toast('Добавляем WARP в Mihomo', 'warning'); + follow(res.job || 'mixomo_warp_integrate', 'mihomo'); + }); + } }, 'Отправить в Mihomo')); + genCard.appendChild(E('div', { 'class': 'zm-actions' }, acts)); + if (confEdit !== null) { + var ta = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false', 'style': 'min-height:260px' }); + ta.value = confEdit; + genCard.appendChild(ta); + var gbar = zm.editorBar(ta, { + saveLabel: 'Сохранить', + onSave: function(v) { + return zm.awgAction('conf_set', v).then(function(r) { + if (r.error) { zm.toast(r.error, 'error'); return false; } + zm.toast('WARP.conf сохранён', 'info'); confEdit = null; mk.loaded = false; refresh(); + return true; + }).catch(function() { zm.toast('Роутер не ответил', 'error'); return false; }); + } + }); + ta.addEventListener('input', function() { confEdit = ta.value; }); + genCard.appendChild(gbar); + } + } + } + + function suggestName() { + var names = {}; (data.ifaces || []).forEach(function(f) { names[f.name] = true; }); + var base = mk.src === 'warp' ? 'warp' : 'awg', n = 0, nm = base; + while (names[nm]) { n++; nm = base + n; } + return nm; + } + + function renderNew() { + newCard.innerHTML = ''; + newCard.appendChild(E('h3', {}, 'Создать интерфейс')); + if (!data.installed) { + newCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Сначала установите AmneziaWG.')); + return; + } + newCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Из WARP.conf или своего .conf AmneziaWG / WireGuard. Появится в «Сеть → Интерфейсы».')); + var srcs = [ { id: 'warp', name: 'WARP.conf' }, { id: 'own', name: 'Свой .conf' } ]; + if (!data.warp_conf) srcs.shift(); + if (!data.warp_conf && mk.src === 'warp') mk.src = 'own'; + newCard.appendChild(row('Конфиг', seg(srcs, mk.src, function(id) { mk.src = id; mk.loaded = false; mk.name = ''; renderNew(); }))); + + var ta = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false', 'style': 'min-height:220px', + 'placeholder': '[Interface]\nPrivateKey = …\nAddress = 10.0.0.2/32\nJc = 4\n…\n\n[Peer]\nPublicKey = …\nAllowedIPs = 0.0.0.0/0\nEndpoint = host:port' }); + if (mk.src === 'warp' && !mk.loaded) { + mk.loaded = true; + zm.awgAction('conf_get', '').then(function(r) { mk.text = (r && r.content) || ''; ta.value = mk.text; }); + } + if (mk.src === 'own' && !mk.loaded) { mk.loaded = true; mk.text = ''; } + ta.value = mk.text; + ta.addEventListener('input', function() { mk.text = ta.value; }); + newCard.appendChild(ta); + + if (!mk.name) mk.name = suggestName(); + var ni = E('input', { 'class': 'cbi-input-text', 'type': 'text', 'value': mk.name, 'maxlength': '11', 'style': 'max-width:220px; width:100%' }); + ni.addEventListener('input', function() { mk.name = ni.value.trim().toLowerCase(); }); + newCard.appendChild(row('Имя интерфейса', ni)); + + newCard.appendChild(zm.swRow(mk.fw, 'Доступ из сети', 'Устройства смогут ходить в туннель', function() { mk.fw = !mk.fw; renderNew(); })); + newCard.appendChild(zm.swRow(mk.route, 'Весь трафик', 'Весь интернет роутера — в туннель', function() { mk.route = !mk.route; renderNew(); })); + newCard.appendChild(E('p', { 'class': 'zm-hint' }, mk.route + ? 'Внимание: упадёт туннель — пропадёт интернет.' + : 'Что пускать в туннель, выберите в Forkozz или Steer.' + (mk.fw ? '' : ' Без доступа из сети устройства туннелем не воспользуются.'))); + + newCard.appendChild(E('div', { 'class': 'zm-actions' }, [ + E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': busy ? '' : null, 'click': function() { + var nm = (mk.name || '').trim(); + if (!/^[a-z][a-z0-9_]{0,10}$/.test(nm)) { zm.toast('Имя: латиница в нижнем регистре, цифры и _, до 11 символов', 'warning'); return; } + if (!/\[interface\]/i.test(mk.text) || !/\[peer\]/i.test(mk.text)) { zm.toast('Вставьте конфигурацию с секциями [Interface] и [Peer]', 'warning'); return; } + var exists = (data.ifaces || []).some(function(f) { return f.name === nm; }); + 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(); + renderGen(); + renderNew(); + } + + renderAll(); + wrap.appendChild(zm.rebootSlot()); + wrap.appendChild(mainCard); + wrap.appendChild(logEl); + wrap.appendChild(ifCard); + wrap.appendChild(genCard); + wrap.appendChild(newCard); + if (data.running) follow('awg', data.phase || ''); + return wrap; + } +}); +ZM_INSTALLER_EOF +chmod 0644 '/www/luci-static/resources/view/zapret-manager/awg.js' +cat > '/www/luci-static/resources/view/zapret-manager/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); + +function badge(cls, text) { + return E('span', { 'class': 'zm-badge ' + cls }, [ E('span', { 'class': 'zm-dot' }), text ]); +} + +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:'; + /* В Web UI терминал держит сама панель: он не сбрасывается при переходе в другой раздел */ + 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 — встроить его сюда нельзя, он откроется в отдельной вкладке.')); + } + + function renderAll() { renderHead(); renderView(); renderActions(); } + + card.appendChild(headEl); + card.appendChild(viewEl); + card.appendChild(actEl); + wrap.appendChild(card); + 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/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: 'svc', label: 'Сервисы' }, { id: 'dev', label: 'Устройства' }, { id: 'warp', label: 'WARP' }, { id: 'sub', label: 'VPN' } ]; + +function fmtBytes(n) { + n = +n || 0; + if (n < 1024) return n + ' Б'; + var u = [ 'КБ', 'МБ', 'ГБ', 'ТБ' ], i = -1; + do { n /= 1024; i++; } while (n >= 1024 && i < u.length - 1); + return (n >= 100 ? n.toFixed(0) : n.toFixed(1)) + ' ' + u[i]; +} + +function fmtDate(sec) { + sec = parseInt(sec, 10); + if (!sec || sec <= 0) return ''; + var d = new Date(sec * 1000); + if (isNaN(d.getTime())) return ''; + return ('0' + d.getDate()).slice(-2) + '.' + ('0' + (d.getMonth() + 1)).slice(-2) + '.' + d.getFullYear(); +} + +function latClass(ms) { + if (!(ms > 0)) return 'zm-lat-none'; + if (ms < 400) return 'zm-lat-good'; + if (ms < 900) return 'zm-lat-mid'; + return 'zm-lat-bad'; +} + +var BLOCKERS = { + splify2: 'Установлен splify2 — туннели и списки настраиваются в нём.', + steer: 'Движок Steer уже настроен вручную — Zapret Manager его не перезаписывает.', + forkozz: 'Стоит Forkozz — он сам направляет сервисы в туннель. Чтобы поставить Steer, удалите Forkozz.' +}; + +function badge(cls, text) { + return E('span', { 'class': 'zm-badge ' + cls }, [ E('span', { 'class': 'zm-dot' }), text ]); +} + +function row(label, node) { + return E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, label), node ]); +} + +/* Строка карточки «Проверка»: слева колонка одной ширины (подпись «Итог» или вердикт), справа — значение */ +function 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; +} + +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 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 (!STEER_TABS.some(function(t) { return t.id === tab; })) tab = 'svc'; + var tabBar = E('div', { 'class': 'zm-actions', 'style': 'margin:10px 0' }); + var panes = { svc: E('div', {}), dev: E('div', {}), warp: E('div', {}), sub: E('div', {}) }; + + function refresh() { + return zm.steerStatus().then(function(res) { data = res || {}; renderAll(); }); + } + + function finish(ok, res) { + busy = false; + if (res) data = res; + var msg = 'Готово'; + if (!ok) msg = 'Не получилось — подробности в журнале'; + else if (lastAction === 'install') msg = 'Steer установлен — подключите WARP или VPN'; + else if (lastAction === 'warp_setup') msg = 'WARP подключён'; + else if (lastAction === 'warp_own') msg = 'Свой WARP подключён'; + else if (lastAction === 'warp_mode') msg = 'Автоматический WARP подключён'; + else if (lastAction === 'warp_fix' || lastAction === 'warp_fixkeys') msg = 'Туннель переделан'; + else if (lastAction === 'remove') msg = 'Steer удалён'; + else if (lastAction === 'stop') msg = 'Steer выключен — всё идёт напрямую'; + else if (lastAction === 'domlist') msg = 'Готово, новый список доменов работает'; + else if (lastAction === '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 обновлён'; + zm.toast(msg, ok ? 'info' : 'error'); + var done = lastAction; + lastAction = ''; + if (ok && (done === 'warp_own' || done === 'warp_mode')) { warpPick = null; ownOpen = false; } + if (ok && (done === 'sub_set' || done === 'sub_links' || done === 'sub_update')) { + probeAfterLoad = true; + zm.toast('Серверы обновлены — проверяем задержку до каждого', 'info'); + } + diagRes = null; + domData = {}; + renderAll(); + loadSub(); + if (ok && data.installed && !data.stopped && done !== 'stop' && done !== 'remove') runDiag(true); + } + + var waitN = 0; + function waitRouter() { + if (!document.body.contains(logEl) || ++waitN > 60) return; + zm.steerStatus().then(function(res) { + waitN = 0; + data = res || {}; + if (data.running) { follow(); return; } + finish(true, res); + }).catch(function() { setTimeout(waitRouter, 5000); }); + } + + function follow() { + var ticks = 0; + busy = true; + renderAll(); + zm.pollJob('steer', logEl, function(ok) { + zm.steerStatus().then(function(res) { + if (res && res.running) { data = res; follow(); return; } + finish(ok, res); + }).catch(function() { + zm.toast('Роутер не отвечает — ждём', 'warning'); + setTimeout(waitRouter, 5000); + }); + }, function() { + if (++ticks % 3) return; + zm.steerStatus().then(function(res) { if (res) { data = res; renderMain(); } }).catch(function() {}); + }); + } + + function act(action, arg, toastText, done) { + if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); if (done) done(false); return; } + zm.steerAction(action, arg || '').then(function(res) { + if (done) done(!res.error); + if (res.error) { zm.toast(res.error, 'error'); return; } + if (res.saved) { + zm.toast(data.installed ? 'Выбор сохранён — применится при включении' : 'Выбор сохранён — применится при установке', 'info'); + if (action !== 'onfail' && action !== 'devs_set') pick = null; + refresh(); loadSub(); return; + } + if (action === 'lists') pick = null; + if (toastText) zm.toast(toastText, 'warning'); + lastAction = action; + data.running = true; + setTimeout(function() { try { logEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (e) {} }, 50); + data.phase = action === 'install' ? 'pkgs' : action === 'remove' ? 'remove' : /^warp_/.test(action) ? 'warp' : /^sub_/.test(action) ? 'sub' : action === 'engine' ? 'pkgs' : 'rules'; + follow(); + }).catch(function() { if (done) done(false); zm.toast('Роутер не ответил', 'error'); }); + } + + function warpName() { return data.warp_mode === 'own' ? 'Свой WARP' : 'WARP'; } + + /* Устройства целиком — как «Весь трафик устройства» в Forkozz: весь интернет устройства идёт + * через туннель Steer (WARP или VPN), либо устройство ходит мимо Steer совсем. */ + 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(); + }).catch(function() { devLoading = false; devList = devList || []; renderDevs(); }); + } + function devSaved() { + var m = {}; + (data.devices || []).forEach(function(d) { if (d && d.key) m[String(d.key).toLowerCase()] = { mode: d.mode, name: d.name || '' }; }); + 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 renderDevs() { + devCard.innerHTML = ''; + devCard.style.display = data.blocker || !data.installed ? 'none' : ''; + if (data.blocker || !data.installed) return; + var saved = devSaved(), cur = devPick || saved, changed = devSig(cur) !== devSig(saved); + 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 }); + }); + 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' ? '«Весь трафик через Steer»' : '«Мимо Steer»'), '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), other = cur[r.key] && cur[r.key].mode !== mode; + var foot = [ r.ip, r.mac && r.mac !== r.ip ? r.mac : '' ].filter(Boolean).join(' · ') + (r.manual ? ' · добавлено вручную' : '') + (other ? (mode === 'all' ? ' · мимо Steer' : ' · весь трафик через Steer') : ''); + 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', {}, [ foot ]), E('span') ]) + ])); + }); + if (!rows.length) g.appendChild(E('p', { 'class': 'zm-hint', 'style': 'grid-column: 1 / -1; margin: 0' }, devLoading ? 'Ищем устройства…' : 'Устройства не найдены — добавьте MAC или IP-адрес вручную.')); + return g; + } + var nAll = Object.keys(cur).filter(function(k) { return cur[k].mode === 'all'; }).length; + var nByp = Object.keys(cur).filter(function(k) { return cur[k].mode === 'byp'; }).length; + var via = data.exit === 'vpn' ? 'VPN' + (data.sub_label ? ' (' + data.sub_label + ')' : '') : data.exit === 'warp' ? warpName() : 'туннель'; + devCard.appendChild(E('h3', {}, [ 'Весь трафик устройства через Steer ', nAll ? badge('zm-ok', nAll + ' ' + plural(nAll, 'устройство', 'устройства', 'устройств')) : E([]) ])); + devCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Весь трафик выбранных устройств идёт через ' + via + ', а не только выбранные сервисы. Локальная сеть — напрямую. Если туннель упал — ' + (data.onfail === 'drop' ? 'интернета у них не будет, пока он не вернётся' : 'пойдут напрямую') + '.')); + devCard.appendChild(grid('all')); + devCard.appendChild(E('h3', { 'style': 'margin-top:20px' }, [ 'Мимо Steer ', nByp ? badge('zm-warn', nByp + ' ' + plural(nByp, 'устройство', 'устройства', 'устройств')) : E([]) ])); + devCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Эти устройства всегда ходят напрямую, даже к выбранным сервисам. Этот выбор важнее выбранных сервисов.')); + devCard.appendChild(grid('byp')); + 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('byp'); } }, 'Мимо Steer'), + E('button', { 'class': 'cbi-button', 'disabled': devLoading ? '' : null, 'click': loadDevs }, devLoading ? 'Ищем…' : 'Обновить список') + ])); + if (data.exit === 'none') devCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Туннель ещё не подключён — выбор сохранится и заработает, когда подключите 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'; + 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' ? 'Свой WARP' : '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' ]; + var t = data.tunnels || [], live = t.filter(tunnelLive).length; + if (t.length > 1 && live) return [ 'работают ' + live + ' из ' + t.length + (data.warp_colo ? ' · ' + data.warp_colo : ''), live < t.length ? 'zm-warn' : 'zm-ok' ]; + if (!isNaN(age) && age < 300) return [ 'работает' + (data.warp_colo ? ' · ' + data.warp_colo : ''), 'zm-ok' ]; + return [ 'нет связи', 'zm-warn' ]; + } + + function vpnBadge() { + if (!data.vpn_up) return badge('zm-warn', 'подключаемся'); + if (data.vpn_live === false) return badge('zm-bad', 'нет связи'); + return badge('zm-ok', 'подключено'); + } + + function statusBadge() { + var n = selectedCount(); + if (busy || data.running) return badge('zm-warn', PHASE_TEXT[data.phase] || 'работаем'); + if (!data.installed) return badge('zm-off', 'не установлен'); + if (data.stopped) return badge('zm-off', 'выключен'); + if (!n && !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 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; + } + + mainCard.appendChild(row('Состояние', statusBadge())); + if (data.installed) { + var n = selectedCount(), ts = tunnelState(); + if (data.warp_on && data.has_sub && data.ext) { + var isVpn = data.exit === 'vpn'; + mainCard.appendChild(E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Сервисы идут через'), + E('div', { 'class': 'zm-seg' }, [ + E('div', { 'class': 'zm-seg-item' + (!isVpn ? ' zm-active' : ''), 'click': function() { if (isVpn && !busy) act('sub_exit', 'warp', 'Переключаем на ' + warpName()); } }, warpName()), + E('div', { 'class': 'zm-seg-item' + (isVpn ? ' zm-active' : ''), 'click': function() { if (!isVpn && !busy) act('sub_exit', 'vpn', 'Переключаем на VPN'); } }, [ String(data.sub_label || 'VPN') ]) + ]), + isVpn ? vpnBadge() : badge(ts[1], ts[0]) + ])); + } else if (data.exit === 'vpn') mainCard.appendChild(row('Сервисы идут через', E('span', {}, [ 'VPN' + (data.sub_label ? ' · ' + data.sub_label : '') + ' ', vpnBadge() ]))); + else if (data.exit === 'warp') mainCard.appendChild(row('Сервисы идут через', badge(ts[1], warpName() + ' · ' + ts[0]))); + else mainCard.appendChild(row('Сервисы идут через', badge('zm-warn', 'туннель не подключён — выберите вкладку WARP или VPN'))); + var dAll = devAllCount(); + mainCard.appendChild(row('Через туннель', E('span', {}, [ n ? n + ' ' + plural(n, 'сервис', 'сервиса', 'сервисов') : (dAll ? 'сервисы не выбраны' : 'ничего не выбрано'), + dAll ? ' · весь трафик ' + dAll + ' ' + plural(dAll, 'устройства', 'устройств', 'устройств') : '' ].join('')))); + var isDrop = data.onfail === 'drop'; + mainCard.appendChild(E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Если туннель упал'), + E('div', { 'class': 'zm-seg' }, [ + E('div', { 'class': 'zm-seg-item' + (!isDrop ? ' zm-active' : ''), 'click': function() { if (busy || !isDrop) return; act('onfail', 'direct', 'Без туннеля сервисы пойдут напрямую'); } }, 'Пустить напрямую'), + E('div', { 'class': 'zm-seg-item' + (isDrop ? ' zm-active' : ''), 'click': function() { if (busy || isDrop) return; act('onfail', 'drop', 'Без туннеля сервисы будут остановлены'); } }, 'Остановить') + ]) + ])); + mainCard.appendChild(E('p', { 'class': 'zm-hint' }, isDrop + ? 'Пока туннель не работает, выбранные сервисы не открываются совсем — мимо туннеля, через провайдера, они не пойдут. Остальной интернет работает как обычно.' + : 'Пока туннель не работает, выбранные сервисы идут через провайдера, как без Steer: что не заблокировано — откроется, но ваш настоящий адрес будет виден.')); + if (data.doh) { + var DOHN = { cloudflare: 'Cloudflare', google: 'Google', quad9: 'Quad9', xbox: 'XBOX', comss: 'Comss', dnsai: 'DNS-AI', yandex_family: 'Яндекс Семейный', yandex_safe: 'Яндекс Безопасный', geohide_ru: 'GeoHide RU', geohide_eu: 'GeoHide EU', geohide_us: 'GeoHide US', malw: 'MALW', zapretmoe: 'zapret.moe', multi: 'несколько серверов', other: 'свой сервер' }; + mainCard.appendChild(row('Шифрованный DNS', E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap' }, [ + E('span', {}, 'DNS over HTTPS · ' + (DOHN[data.doh] || data.doh)), + badge('zm-ok', 'работает') + ]))); + } + var newer = data.latest && data.version && verLt(data.version, data.latest); + mainCard.appendChild(row('Версия Steer', E('span', {}, (data.version || '—') + (newer ? ' · доступна ' + data.latest : (!data.ext && data.version ? ' · ядро нужно обновить' : ''))))); + } + + if (busy) { + mainCard.appendChild(E('div', { 'class': 'zm-actions' }, [ + E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { + zm.steerAction('halt', '').then(function(res) { + if (res.error) zm.toast(res.error, 'error'); else zm.toast('Останавливаем — роутер закончит текущий шаг', 'warning'); + }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); + } }, 'Остановить') + ])); + mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Можно закрыть страницу — всё доделается на роутере.')); + return; + } + + if (!data.installed) { + mainCard.appendChild(E('div', { 'class': 'zm-actions' }, [ + E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { + act('install', '', 'Устанавливаем Steer — это займёт пару минут'); + } }, 'Установить') + ])); + mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Ставится ядро steer — около минуты. Потом на вкладке WARP или VPN выберите, через что пускать сервисы.')); + return; + } + + var actions = []; + if (data.stopped) actions.push(E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { act('start', '', 'Включаем Steer'); } }, 'Включить')); + else { + actions.push(E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { act('apply', '', 'Перезапускаем туннели и правила'); } }, 'Перезапустить')); + actions.push(E('button', { 'class': 'cbi-button', 'click': function() { act('stop', '', 'Выключаем Steer'); } }, 'Выключить')); + } + if ((data.latest && data.version && verLt(data.version, data.latest)) || (data.version && !data.ext)) + actions.push(E('button', { 'class': 'cbi-button cbi-button-action', 'click': function() { act('engine', '', 'Обновляем движок Steer'); } }, 'Обновить движок')); + actions.push(E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { + 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' }, '«Выключить» — всё пойдёт напрямую, настройки и выбор сервисов сохранятся.')); + } + + function currentPick() { + var m = {}; + (data.services || []).forEach(function(s) { if (s.on) m[s.id] = true; }); + return m; + } + + var CATEGORY_IDS = [ 'geoblock', 'block', 'news', 'anime', 'porn', 'hodca', 'russia_inside', 'russia_outside', 'ukraine_inside' ]; + var openGroups = {}; + + /* Домены списка пункта: значок «≡» на карточке раскрывает их панелью под рядом карточек (как + * раскрывается b4geoip). Открыт один список; показываем первые 500, весь — «Скачать .txt» + * (читается с роутера частями). У пункта, где в списке одни подсети, значка нет. */ + 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) { + 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 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'); + } + /* домены, затем IP-подсети — частями, как читает роутер; в файле — два раздела с заголовками «#», + * их понимают и «Свой список», и большинство программ для списков */ + 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) { + 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], 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)); + } + /* Панель встаёт сразу за последней карточкой того же ряда — ряд узнаём по offsetTop. */ + 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); + }); + + /* «Найти сайт»: окно поиска поверх страницы (кнопка у заголовка «Сервисы» или Ctrl+K). Ищет домен — + * вместе с поддоменами — и его адрес (подсети, b4geoip) по спискам всех пунктов и сразу даёт их + * включить тем же переключателем, что на карточке; применяется общей кнопкой «Сохранить и применить». + * Списки, которых ещё нет на роутере, докачиваются в фоне — поиск сам повторяется. */ + 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(); } + 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) { 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'); + 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); + zm.steerAction('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: !!csvc.on, 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 (!csvc.on && 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 + ' добавлен в «Свой список»' + (csvc.on ? '' : ' — включите его на карточке «Свой список»'), 'info'); + refresh(); + }).catch(function() { fail('Роутер не ответил'); }); + } + if (customData && !customLoading) go(customData.content); + else zm.steerAction('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'; }); + /* по адресу ищем, только если по домену ничего нет: сайты за Cloudflare и другими CDN делят адреса + * с тысячами чужих, и включать ради них «Cloudflare» — значит пустить через туннель всё подряд */ + 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; + 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 el = zm.svcCard({ name: s.name, key: s.id, on: !!sel[s.id], inc: !!sel.russia_inside && s.id !== 'russia_inside' && !s.group && zm.riCovers(s.id), 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' }, 'Сервисы'), + E('button', { 'type': 'button', 'class': 'zm-find-open', 'title': 'Найти сайт или IP-адрес в списках (Ctrl+K)', 'aria-label': 'Найти сайт или IP-адрес в списках', 'click': findOpen }, [ + findSvg('zm-find-ico', FIND_LOUPE), 'Найти сайт или IP' + ]) + ])); + listCard.appendChild(zm.svcGrid(svcs.map(tile))); + if (cats.length) { + listCard.appendChild(E('h4', { 'style': 'margin:18px 0 0' }, 'Категории')); + listCard.appendChild(zm.svcGrid(cats.map(tile))); + if (sel.russia_inside) listCard.appendChild(zm.riNote(list, sel)); + else listCard.appendChild(E('p', { 'class': 'zm-hint' }, '«Всё сразу» — полный список Russia inside: все категории и сервисы одним набором. Он большой, на слабых роутерах лучше включать отдельные пункты.')); + } + var groups = []; + remote.forEach(function(s) { if (groups.indexOf(s.group) < 0) groups.push(s.group); }); + if (groups.length) { + listCard.appendChild(E('h4', { 'style': 'margin:20px 0 4px' }, 'Дополнительные списки')); + listCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin:0 0 10px' }, 'Готовые наборы доменов и подсетей из каталога. Разверните источник и отметьте нужное.')); + } + groups.forEach(function(g) { + var items = remote.filter(function(s) { return s.group === g; }); + var on = items.filter(function(s) { return sel[s.id]; }).length; + var m = /^(.*?)\s*\((.*)\)\s*$/.exec(g); + var gTitle = m ? m[1] : g, gSub = m ? m[2] : ''; + listCard.appendChild(zm.svcGroup({ + name: gTitle, sub: gSub, key: g, + open: g in openGroups ? openGroups[g] : on > 0, + badge: E('span', { 'class': 'zm-badge ' + (on ? 'zm-ok' : 'zm-off') }, on ? 'выбрано ' + on + ' из ' + items.length : String(items.length)), + body: zm.svcGrid(items.map(tile)), + toggle: function(o) { openGroups[g] = o; } + })); + }); + listCard.appendChild(catalogBlock()); + var canRun = data.installed && !data.stopped && (selectedCount() > 0 || devAllCount() > 0) && data.exit !== 'none'; + listCard.appendChild(E('div', { 'class': 'zm-lists-now' }, [ + E('div', { 'class': 'zm-lists-now-text' }, [ + E('b', {}, 'Списки сервисов'), + E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, canRun ? 'Обновляются при каждом применении. Можно обновить сейчас.' : 'Скачаются, когда Steer включён, выбраны сервисы и подключён WARP или VPN.') + ]), + E('button', { 'class': 'cbi-button cbi-button-action', 'disabled': !canRun || busy || changed ? '' : null, 'title': changed ? 'Сначала сохраните изменения' : '', 'click': function() { + act('apply', '', 'Скачиваем и применяем списки'); + } }, 'Обновить списки сейчас') + ])); + listCard.appendChild(zm.swRow(!!data.lists_via, 'Скачивать списки через VPN', 'Списки и каталог будут качаться через туннель WARP или свой конфиг, а если он не ответит — напрямую. Помогает, если GitHub у провайдера не открывается. В режиме VPN-подписки списки качаются напрямую.', function() { + if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } + var on = !data.lists_via; + zm.steerAction('lists_via', on ? 'on' : 'off').then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return; } + data.lists_via = on; + zm.toast(on ? 'Списки будут качаться через туннель' : 'Списки будут качаться напрямую', 'info'); + renderLists(); + }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); + })); + var bar = zm.saveBar({ + saveLabel: data.installed && !data.stopped ? 'Сохранить и применить' : 'Сохранить', + onCancel: function() { pick = null; renderLists(); }, + 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() { + customLoading = true; + renderCustom(); + zm.steerAction('list_get', 'custom').then(function(res) { + customLoading = false; + if (res.error) { customData = null; renderCustom(); zm.toast(res.error, 'error'); return; } + customData = res; + customDraft = null; + customDirty = false; + renderCustom(); + }).catch(function() { customLoading = false; renderCustom(); }); + } + + function saveCustom(action, arg, okText) { + if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } + zm.steerAction(action, arg).then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return; } + customDraft = null; + customDirty = false; + if (res.saved) { + zm.toast(okText + (res.count ? ' (' + res.count + ')' : ''), 'info'); + loadCustom(); + refresh(); + return; + } + zm.toast(okText + ' — применяем правила', 'info'); + lastAction = 'domlist'; + data.running = true; + data.phase = 'rules'; + follow(); + loadCustom(); + }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); + } + + function renderCustom() { + if (customEditor && customDirty) customDraft = customEditor.value; + if (customEditor && !customDirty) customDraft = null; + customCard.innerHTML = ''; + var svc = (data.services || []).filter(function(s) { return s.id === 'custom'; })[0]; + customCard.style.display = data.blocker || !svc ? 'none' : ''; + if (data.blocker || !svc) return; + customCard.appendChild(E('h3', {}, 'Свой список')); + customCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Домены и IP-адреса из этого списка пойдут через Steer. По умолчанию список выключен — сохраните его и включите переключателем ниже. «Очистить» — убирает его.')); + if (customLoading && !customData) { customCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Загружаем список…')); return; } + var n = customData ? (parseInt(customData.count, 10) || 0) : 0; + customCard.appendChild(zm.swRow(!!svc.on, 'Свой список', svc.on ? 'Включён: домены и IP-адреса из списка идут через Steer. Выключите — они пойдут напрямую, сам список останется.' : 'Выключен: список не используется. Включите — домены и IP-адреса из него пойдут через Steer.', function() { + if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } + if (!svc.on && !n) { zm.toast('Сначала добавьте в список домены или IP', 'warning'); return; } + if (customDirty) { zm.toast('Сначала сохраните изменения в списке', 'warning'); return; } + var ids = (data.services || []).filter(function(s) { return s.on && s.id !== 'custom'; }).map(function(s) { return s.id; }); + if (!svc.on) ids.push('custom'); + act('lists', ids.join(','), svc.on ? 'Выключаем свой список' : 'Включаем свой список'); + }, busy)); + customCard.appendChild(row('Состояние', !n ? badge('zm-off', 'список пуст') + : !svc.on ? badge('zm-warn', 'выключен') + : !data.installed ? badge('zm-warn', 'пойдёт через Steer после установки') + : data.stopped ? badge('zm-off', 'Steer выключен') + : data.exit === 'vpn' ? badge('zm-ok', 'идёт через VPN') + : data.exit === 'warp' ? badge('zm-ok', 'идёт через ' + warpName()) + : badge('zm-warn', 'подключите WARP или VPN'))); + customCard.appendChild(row('В списке', E('span', {}, customData && customData.domains != null ? 'доменов: ' + customData.domains + ' · IP и подсетей: ' + customData.subnets : String(n)))); + customEditor = E('textarea', { + 'class': 'zm-config-editor', 'spellcheck': 'false', 'style': 'min-height:200px', 'placeholder': 'example.com\nsite.org\n91.108.4.0/22\n149.154.167.99' + }); + customEditor.value = (customData && customData.content) || ''; + customCard.appendChild(customEditor); + var cbar = zm.editorBar(customEditor, { + saveLabel: data.installed && !data.stopped ? 'Сохранить и применить' : 'Сохранить', + onSave: function(v) { + if (!v.trim()) { zm.toast('Добавьте хотя бы один домен или IP, либо нажмите «Очистить»', 'warning'); return false; } + saveCustom('list_set', 'custom|' + v, 'Свой список сохранён'); + return false; + } + }); + customEditor.addEventListener('input', function() { customDirty = cbar.dirty(); customDraft = customEditor.value; }); + if (customDirty && customDraft !== null) { customEditor.value = customDraft; customEditor.dispatchEvent(new Event('input')); } + if (n) customCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { + return zm.ask('Очистить свой список?\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); + } + + /* Исключения: домены и IP, которые всегда идут напрямую — выше любого сервиса и у устройств «весь трафик». */ + 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(); + }).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 — даже если входят в выбранный сервис.')); + 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(); } }).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'; + if (t.warp === 'on') items.push([ 'ok', who + 'трафик идёт через ' + warpName() + (t.colo ? ' (сервер ' + t.colo + (t.city ? ', ' + t.city : '') + ')' : '') + (t.seen ? ' · сайты видят: ' + country(t.seen) : ''), '' ]); + else if (t.warp === 'notls') items.push([ 'warn', who + 'соединение есть, но HTTPS через туннель не проходит', ownW ? 'замените конфиг на вкладке WARP' : 'нажмите «Сменить точки входа»' ]); + else items.push([ tn.length > 1 && diagRes.warp === 'on' ? 'warn' : 'fail', who + 'трафик через ' + warpName() + ' не идёт', + ownW ? 'нажмите «Перезапустить туннель» или замените конфиг на вкладке WARP' : 'нажмите «Перезапустить туннели» или «Сменить точки входа»' ]); + }); + else if (diagRes.warp === 'on') items.push([ 'ok', 'Трафик идёт через WARP' + (diagRes.colo ? ' (сервер ' + diagRes.colo + ')' : ''), '' ]); + else if (diagRes.warp === 'off') items.push([ 'fail', 'Трафик через WARP не идёт', 'нажмите «Перезапустить туннели» или «Сменить точки входа»' ]); + var d = diagRes.diag; + if (d && d.checks) d.checks.forEach(function(c) { + if (c.verdict === 'ok' || c.verdict === 'warn' || c.verdict === 'fail') items.push([ c.verdict, c.what, c.why ]); + }); + var bad = items.filter(function(i) { return i[0] === 'fail'; }).length, warn = items.filter(function(i) { return i[0] === 'warn'; }).length; + checkCard.appendChild(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 renderWarp() { + warpCard.innerHTML = ''; + warpCard.style.display = data.blocker ? 'none' : ''; + if (data.blocker) return; + var tl = data.tunnels || []; + var own = data.warp_on && data.warp_mode === 'own'; + warpCard.appendChild(E('h3', {}, own ? 'Свой WARP' : tl.length > 1 ? 'Туннели WARP' : 'Туннель WARP')); + if (!data.installed) { warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Сначала установите Steer.')); return; } + + var sel = warpPick || (data.warp_mode === 'own' ? 'own' : 'auto'); + warpCard.appendChild(E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Режим'), + E('div', { 'class': 'zm-seg' }, [ + E('div', { 'class': 'zm-seg-item' + (sel === 'auto' ? ' zm-active' : ''), 'click': function() { + if (busy || sel === 'auto') return; + if (own) { + return zm.ask('Перейти на автоматический WARP?\n\nВключатся прежние автоматические туннели (если их ещё не было — роутер получит ключи у Cloudflare и подберёт три туннеля за несколько минут). Свой туннель выключится, но сохранится.').then(function(ok) { + if (!ok) return; + act('warp_mode', 'auto', 'Переходим на автоматический WARP — это займёт несколько минут'); + return; + }); + } + warpPick = null; renderWarp(); + } }, 'Автоматически'), + E('div', { 'class': 'zm-seg-item' + (sel === 'own' ? ' zm-active' : ''), 'click': function() { + if (busy || sel === 'own') return; + warpPick = own ? null : 'own'; renderWarp(); + } }, 'Свой конфиг') + ]) + ])); + + function ownEditor(btnText, toastText) { + if (!ownTa) ownTa = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false', 'style': 'min-height:220px', + 'placeholder': '[Interface]\nPrivateKey = …\nAddress = 172.16.0.2/32\n\n[Peer]\nPublicKey = …\nEndpoint = 162.159.192.1:2408' }); + warpCard.appendChild(ownTa); + if (data.warp_own_saved && !ownLoaded && !ownTa.value.trim()) { + ownLoaded = true; + zm.steerAction('warp_own_get', '').then(function(r) { + if (r && r.content && !ownTa.value.trim()) ownTa.value = r.content.replace(/\s+$/, ''); + }).catch(function() { ownLoaded = false; }); + } + var btns = [ E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': busy ? '' : null, 'click': function() { + var v = ownTa.value.trim(); + if (!v && !data.warp_own_saved) { zm.toast('Вставьте конфиг WARP', 'warning'); ownTa.focus(); return; } + if (v && (!/^\s*\[interface\]/im.test(v) || !/^\s*\[peer\]/im.test(v))) { zm.toast('Это не конфиг WARP: нужны секции [Interface] и [Peer]', 'error'); return; } + act('warp_own', v, toastText); + } }, btnText) ]; + if (warpPick === 'own' || ownOpen) btns.push(E('button', { 'class': 'cbi-button', 'click': function() { warpPick = null; ownOpen = false; renderWarp(); } }, 'Отмена')); + warpCard.appendChild(E('div', { 'class': 'zm-actions' }, btns)); + warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Подойдёт конфиг WireGuard или AmneziaWG: ключи, маскировка и точка входа берутся из него как есть.' + + (data.warp_own_saved ? ' В поле — ваш сохранённый конфиг: поправьте его или вставьте новый. Он хранится отдельно и переживёт даже переустановку Steer.' : ''))); + } + + if (sel === 'own' && !own) { + warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Один туннель по вашему конфигу. Роутер не будет получать ключи и искать точки входа сам.' + + (data.warp_on ? ' Автоматические туннели выключатся, но сохранятся — вернуться к ним можно в один клик.' : ''))); + ownEditor(data.warp_on ? 'Перейти на свой конфиг' : 'Подключить', 'Подключаем свой WARP'); + return; + } + if (!data.warp_on) { + warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Бесплатный туннель Cloudflare WARP с маскировкой AmneziaWG. Поставится AmneziaWG, роутер получит ключи и подберёт три туннеля в разных колониях — это займёт несколько минут.')); + warpCard.appendChild(E('div', { 'class': 'zm-actions' }, [ + E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': busy ? '' : null, 'click': function() { act('warp_setup', '', 'Подключаем WARP — это займёт несколько минут'); } }, 'Подключить WARP') + ])); + return; + } + if (own) { + if (data.exit === 'vpn') warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Сейчас сервисы идут через подписку — свой WARP не используется.')); + var t0 = tl[0] || {}, live0 = tunnelLive(t0), info0 = []; + var ep0 = t0.host ? zm.secret(t0.host + ':' + t0.port) : null; + if (t0.colo && t0.colo !== '?') info0.push('сервер ' + t0.colo + (t0.city ? ' (' + t0.city + ')' : '')); + if (t0.seen) info0.push('сайты видят: ' + country(t0.seen)); + if (t0.up) info0.push('связь ' + fmtAge(t0.hs_age)); + if (t0.up) info0.push('↓ ' + zm.fmtSize(+t0.rx || 0) + ' / ↑ ' + zm.fmtSize(+t0.tx || 0)); + warpCard.appendChild(E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Туннель'), + badge(live0 ? 'zm-ok' : t0.up ? 'zm-warn' : 'zm-bad', live0 ? 'работает' : t0.up ? 'нет связи' : 'не поднят'), + 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; + } + if (data.exit === 'vpn') warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Сейчас сервисы идут через подписку — туннели WARP не используются.')); + 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() { 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() { act('warp_endpoint', '', 'Ищем точки входа'); } }, 'Сменить точки входа'), + 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 = ''; + autoCard.style.display = data.installed && !data.blocker && (data.warp_on || data.has_sub) ? '' : 'none'; + if (!data.installed || data.blocker || !(data.warp_on || data.has_sub)) return; + autoCard.appendChild(E('h3', {}, 'Автоперезапуск')); + autoCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Туннели и Steer перезапускаются по расписанию — помогает, если обход со временем «подвисает».')); + var cur = data.autorestart || '', 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'; + 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; + zm.steerAction('sub_status', '').then(function(res) { + subLoading = false; + if (probeAfterLoad && res && !res.error) { + probeAfterLoad = false; + if (res.has && res.ext && res.list && (res.list.nodes || []).length) setTimeout(function() { lat = {}; probeAll(); }, 300); + } + if (res && !res.error) { + var old = subData && subData.list && subData.list.nodes ? subData.list.nodes.map(function(n) { return n.name; }).join('|') : null; + subData = res; + var now = res.list && res.list.nodes ? res.list.nodes.map(function(n) { return n.name; }).join('|') : null; + if (old !== now) lat = {}; + } + renderSub(); + }).catch(function() { subLoading = false; renderSub(); }); + } + + function subAct(action, arg, toastText) { + act(action, arg, toastText); + } + + 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; } + zm.steerAction('sub_links_get', '').then(function(r) { + if (r.error) { zm.toast(r.error, 'error'); return; } + linksVal = String(r.links || '').replace(/\s+$/, ''); + linksEd.set(linksVal); + linksOpen = true; + renderLinksBar(); + renderSub(); + }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); + } + function subActP(action, arg, text) { return new Promise(function(res) { act(action, arg, text, res); }); } + + /* Задержка узла: проверка на странице, иначе — замер роутера (автовыбор по пингу) */ + 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: 'не ответил на пинг роутера' }; + } + /* Самый быстрый рабочий узел (проверенные через узел — раньше проверенных только по TCP), кроме ex */ + 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); + }); + } + /* Выбор узла: сначала проверяем его (TCP и запрос через узел) — мёртвый узел не выбираем молча */ + 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 секунд'); + var go = function() { subAct('sub_node', n.name, 'Выбираем узел ' + n.name); }; + Promise.all([ + zm.steerAction('sub_tcp', String(n.index)).catch(function() { return null; }), + zm.steerAction('sub_probe', String(n.index)).catch(function() { return null; }) + ]).then(function(rr) { + picking = false; bt.close(); + 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]); + }); + } + var hidePick = zm.hidePicker({ noun: 'узлы', busy: function() { return busy; }, + onMode: function(on) { hideMode = on; renderSub(); }, + onSave: function(names, markers) { return subActP('sub_hide', JSON.stringify({ names: names, markers: markers }), 'Применяем список узлов'); }, + onReset: function() { return subActP('sub_hide', JSON.stringify({ reset: true }), 'Возвращаем все узлы'); } }); + + /* Задержка — TCP-пинг, как «TCP» в Happ: пачками по 8 узлов, две пачки одновременно, результаты + * появляются по мере проверки. Hysteria2 по TCP не проверить — его проверяет ядро steer. */ + 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)); + /* проверка через узел (ядро steer). tcp — время TCP этого узла, если он уже ответил по TCP: + * тогда «не прошёл» значит «порт открыт, не пускает» (обычно узел блокирует провайдер) */ + var realMode = subData && subData.lat_mode !== 'fast'; + function one(i) { + var tcp = lat[i] && lat[i].pend ? lat[i].ms : 0; + return zm.steerAction('sub_probe', String(i)).then(function(res) { + 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() { lat[i] = tcp > 0 ? { ok: true, ms: tcp, tcpOnly: true, why: 'только TCP' } : { ok: false }; }).then(function() { probeDone++; renderSub(); }); + } + function nextChunk() { + if (!chunks.length) return Promise.resolve(); + var part = chunks.shift(); + return zm.steerAction('sub_tcp', part.join(',')).then(function(res) { + var rs = (res && res.results) || {}; + part.forEach(function(i) { + var r = rs[i]; + if (r && r.core) { core.push(i); return; } + /* «через узел»: ответил по TCP — показываем время TCP с «…» и ставим в очередь настоящей проверки */ + 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() { part.forEach(function(i) { lat[i] = { ok: false }; probeDone++; }); }).then(function() { + 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() { + 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 renderSub() { + subCard.innerHTML = ''; + subCard.style.display = data.blocker ? 'none' : ''; + if (data.blocker) return; + subCard.appendChild(E('h3', {}, 'VPN: подписка или свои серверы')); + if (!data.installed) { + subCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Выбранные сервисы могут идти через ваш VPN: подписку или свои серверы (VLESS, Hysteria2, Trojan, Shadowsocks, VMess, SOCKS или HTTP). Сначала установите Steer.')); + return; + } + if (!subData) { subCard.appendChild(E('p', { 'class': 'zm-hint' }, subLoading ? 'Загружаем…' : 'Нет данных')); return; } + + if (!subData.has) { + subCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Выбранные сервисы пойдут через ваш 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 || {}, used = (+q.up || 0) + (+q.down || 0), total = +q.total || 0; + var facts = []; + if (q.up !== '' || q.down !== '' || q.total !== '') { + var pct = total > 0 ? Math.min(100, Math.round(used * 100 / total)) : 0; + facts.push(E('div', { 'class': 'zm-sub-fact' }, [ + E('span', {}, 'Трафик'), + E('b', {}, fmtBytes(used) + ' / ' + (total > 0 ? fmtBytes(total) : '∞')), + total > 0 ? E('div', { 'class': 'zm-quota' + (pct >= 90 ? ' zm-quota-high' : '') }, [ E('i', { 'style': 'width:' + pct + '%' }) ]) : '' + ])); + } + if (fmtDate(q.expire)) facts.push(E('div', { 'class': 'zm-sub-fact' }, [ E('span', {}, 'Действует до'), E('b', {}, fmtDate(q.expire)) ])); + var allNodes = (subData.list && subData.list.nodes) || [], hidMap = subHidden(); + var nodes = allNodes.filter(function(n) { return !hidMap[n.index]; }); + facts.push(E('div', { 'class': 'zm-sub-fact' }, [ E('span', {}, 'Узлов'), E('b', {}, String(nodes.length) + (nodes.length !== allNodes.length ? ' из ' + allNodes.length : '')) ])); + if (subData.updated) facts.push(E('div', { 'class': 'zm-sub-fact' }, [ E('span', {}, 'Обновлена'), E('b', {}, fmtDate(subData.updated)) ])); + subCard.appendChild(E('div', { 'class': 'zm-sub-meta' }, [ + E('div', { 'class': 'zm-sub-title' }, [ String(subData.title || 'Подписка') ]), + E('div', { 'class': 'zm-sub-facts' }, facts) + ])); + + if (subData.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() { + zm.steerAction('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 = (data.exit || 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() { + zm.dialog({ title: 'Удалить подписку?', danger: true, okText: 'Удалить подписку', + blocks: [ { type: 'list', title: 'Что произойдёт', items: [ + 'Подписка и её серверы удалятся', + data.warp_on ? 'Выбранные сервисы пойдут через WARP' : 'Туннеля не останется — сервисы пойдут напрямую' + ] } ] + }).then(function(v) { if (v) subAct('sub_remove', '', 'Удаляем подписку'); }); + } }, 'Удалить')); + if (!hideMode) subCard.appendChild(E('div', { 'class': 'zm-actions' }, acts)); + if (!hideMode && !(linksOpen && subData.kind === 'links') && nodes.length > 1) subCard.appendChild(zm.swRow(sortPing, 'Сортировать по пингу', 'Серверы идут от быстрых к медленным, неотвечающие — в конце.', function() { + sortPing = !sortPing; + try { localStorage.setItem('zm.steer.sort', sortPing ? 'ping' : 'list'); } catch (e) {} + var tested = nodes.some(function(n) { return lat[n.index] && !lat[n.index].busy; }); + renderSub(); + if (sortPing && !tested && !probing && !busy) probeAll(); + })); + /* как проверять узлы: только TCP (по умолчанию) или через узел */ + 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; } + zm.steerAction('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'; + /* пинг, замеренный роутером (автовыбор по пингу), — пока страница сама не проверяла задержку: nodeLat() */ + 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; + zm.steerAction('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 && data.exit === 'warp') subCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Сейчас сервисы идут через ' + warpName() + ' — переключить можно в карточке Steer вверху. Выбор узла сохранится.')); + if (subData.kind === 'url') { + var AUTO = [ { id: 'off', name: 'Не обновлять' }, { id: '3', name: 'Каждые 3 часа' }, { id: '6', name: 'Каждые 6 часов' }, { id: '12', name: 'Каждые 12 часов' }, { id: '24', name: 'Раз в сутки' } ]; + var curAuto = subData.auto || 'off'; + subCard.appendChild(E('h4', { 'style': 'margin:16px 0 8px' }, 'Обновлять подписку')); + subCard.appendChild(E('div', { 'class': 'zm-grid' }, AUTO.map(function(a) { + return E('div', { 'class': 'zm-tile' + (curAuto === a.id ? ' zm-active' : ''), 'click': function() { + if (curAuto === a.id) return; + zm.steerAction('sub_auto', a.id).then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return; } + zm.toast(a.id === 'off' ? 'Автообновление подписки выключено' : 'Подписка будет обновляться: ' + a.name.toLowerCase(), 'info'); + loadSub(); + }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); + } }, a.name); + }))); + } + } + + function routeVerdict(r) { + var tl = data.tunnels || []; + var lines = []; + if (r.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 === 'other') ? 'on' : ''; + for (var wi = 0; winState && wi < wh.length; wi++) if (wh[wi].state === winState) { wh[wi].win = true; break; } + var whl = zm.routeWhere(wh); + if (whl) lines.push([ 'Списки', whl ]); + else if (r.channel && !/^zm_/.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 === 'exclude') return { tone: 'warn', cls: 'zm-warn', label: 'напрямую · исключение', title: 'адрес в «Исключениях» Steer — идёт мимо WARP и VPN', lines: lines }; + if (r.verdict === 'vpn') { + lines.push([ 'Выход', 'VPN' + (data.sub_label ? ' · ' + data.sub_label : '') ]); + lines.push([ 'Сервер', r.node ? r.node : 'выбирается автоматически (первый рабочий)' ]); + if (r.dev) lines.push([ 'Интерфейс', r.dev ]); + return { tone: 'ok', cls: 'zm-ok', label: 'через VPN', title: 'идёт через подписку', lines: lines, raw: r.text }; + } + if (r.verdict === 'warp') { + var act = data.warp_active && tl.length > 1 ? tl.filter(function(t) { return t.if === data.warp_active; })[0] : null; + lines.push([ 'Выход', warpName() + (act ? ' · сейчас работает туннель ' + act.n : '') ]); + if (r.dev) lines.push([ 'Интерфейс', act ? act.if : r.dev ]); + return { tone: 'ok', cls: 'zm-ok', label: 'через ' + warpName(), title: 'идёт через туннель', lines: lines, raw: r.text }; + } + if (r.verdict === 'other') { + lines.push([ 'Выход', r.out + (r.dev ? ' · ' + r.dev : '') ]); + return { tone: 'ok', cls: 'zm-ok', label: 'через ' + (r.out || 'туннель'), title: 'идёт через выход из своих настроек', lines: lines, raw: r.text }; + } + if (r.verdict === 'direct') return { tone: 'warn', cls: 'zm-warn', label: 'напрямую', title: 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 }; + } + + function renderRoute() { + var on = data.installed && !data.blocker; + routeCard.style.display = on ? '' : 'none'; + if (!on) return; + if (!routeBox) routeBox = zm.routeCheck({ + call: function(v) { return zm.steerAction('explain', v); }, + verdict: routeVerdict + }); + if (routeBox.parentNode === routeCard) return; + routeCard.innerHTML = ''; + routeCard.appendChild(E('h3', {}, 'Куда пойдёт запрос')); + routeCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Введите сайт или IP-адрес — Steer покажет, пойдёт он через WARP/VPN или напрямую.')); + routeCard.appendChild(routeBox); + } + + function renderTabs() { + tabBar.innerHTML = ''; + var tabsOn = !data.blocker && !!data.installed; + tabBar.style.display = tabsOn ? '' : 'none'; + STEER_TABS.forEach(function(t) { + tabBar.appendChild(E('button', { + 'class': 'cbi-button' + (t.id === tab ? ' cbi-button-positive' : ''), + 'click': function() { + tab = t.id; + try { localStorage.setItem('zm.steer.tab', tab); } catch (e) {} + renderTabs(); + if (tab === 'sub') loadSub(); + if (tab === 'dev' && !devList) loadDevs(); + } + }, t.label)); + }); + Object.keys(panes).forEach(function(k) { panes[k].style.display = tabsOn && k === tab ? '' : 'none'; }); + } + + function renderAll() { + renderTabs(); + renderSub(); + renderMain(); + renderDns(); + renderLists(); + renderCustom(); + renderExcl(); + renderCheck(); + renderRoute(); + renderWarp(); + renderAuto(); + renderWfix(); + renderDevs(); + } + + renderAll(); + if (tab === 'dev' && !data.blocker && data.installed) loadDevs(); + [ listCard, customCard, exclCard, checkCard, routeCard ].forEach(function(n) { panes.svc.appendChild(n); }); + [ warpCard, wfixCard, autoCard ].forEach(function(n) { panes.warp.appendChild(n); }); + panes.dev.appendChild(devCard); + panes.sub.appendChild(subCard); + [ zm.rebootSlot(), mainCard, hostsWarn, logEl, dnsCard, tabBar, panes.svc, panes.dev, panes.warp, panes.sub ].forEach(function(n) { wrap.appendChild(n); }); + if (!data.blocker) { loadCustom(); loadExcl(); 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||||||tiktok +hdrezka|HDRezka||||||hdrezka +google_meet|Google Meet||||||google_meet +google_ai|Google AI (Gemini)||||||google_ai +google_play|Google Play||||||google_play +roblox|Roblox||||||roblox +cloudflare|Cloudflare||||||cloudflare +cloudfront|Amazon CloudFront||||||cloudfront +digitalocean|DigitalOcean||||||digitalocean +hetzner|Hetzner||||||hetzner +ovh|OVH||||||ovh +geoblock|Геоблок||||||geoblock +block|Заблокированные сайты||||||block +news|Новости||||||news +anime|Аниме||||||anime +porn|Сайты 18+||||||porn +hodca|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 +# Meta, Instagram, Facebook и WhatsApp — четыре отдельных пункта без общих списков (v2fly + itdoginfo): +# Instagram — v2fly instagram + instagram_extra (ниже); Facebook — v2fly facebook + messenger; +# WhatsApp — v2fly whatsapp + whatsapp_extra (то, что есть только у itdoginfo); Meta — v2fly meta (без include) + threads + oculus. +# instagram_extra — единственное, что Instagram берёт у Facebook: фото и видео грузятся с *.fna.fbcdn.net, +# вложения Direct — lookaside.fbsbx.com, Direct в реальном времени и E2EE-чаты — хосты ниже. Сам facebook.com сюда не входит. +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 +chmod 0644 /usr/share/zm-redbtn/svc/instagram_extra.lst /usr/share/zm-redbtn/svc/whatsapp_extra.lst +if [ -d /usr/share/zm-redbtn/lists ]; then + mkdir -p /opt/zapret-manager-luci/lists + for f in /usr/share/zm-redbtn/lists/*.lst; do + [ -s "$f" ] || continue + [ -e "/opt/zapret-manager-luci/lists/${f##*/}" ] || cp -p "$f" /opt/zapret-manager-luci/lists/ 2>/dev/null || true + done + if cat /etc/steer/*.json /etc/steer/*.yaml /etc/steer/*.yml /etc/zm-steer/spec.before /etc/zm-steer/model.svc 2>/dev/null | grep -q '/usr/share/zm-redbtn/lists/'; then + for f in /usr/share/zm-redbtn/lists/*; do + [ -e "$f" ] || continue + cat /etc/steer/*.json /etc/steer/*.yaml /etc/steer/*.yml /etc/zm-steer/spec.before /etc/zm-steer/model.svc 2>/dev/null | grep -qF "$f" || rm -rf "$f" + done + else + rm -rf /usr/share/zm-redbtn/lists + fi +fi + +mkdir -p /www/luci-static/resources/view/zapret-manager +chmod 0755 /www/luci-static/resources/view/zapret-manager +cat > '/www/luci-static/resources/view/zapret-manager/hosts.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require view'; +'require zapret-manager.common as zm'; +var 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; } + }); + } + /* AI-сервисы — отдельным разделом под остальными, как «Дополнительные списки» в Steer */ + 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 : ''); + } + + /* Предупреждение: адреса из hosts важнее VPN и DoH. Один раз — при первом добавлении адресов + * или первой замене всего hosts; после «Понимаю» больше не показывается. */ + 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); + /* в hosts уже есть записи (сервисы, готовый список или свои) — предупреждение один раз при открытии */ + 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' + +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/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 badge(cls, text) { return E('span', { 'class': 'zm-badge ' + cls }, [ E('span', { 'class': 'zm-dot' }), text ]); } +function mb(kb) { return Math.max(0, Math.round((+kb || 0) / 1024)); } +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 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' : ''); + } + + /* ---------- Статистика запросов ---------- + * Счётчики считает роутер (журнал dnsmasq в памяти → почасовые итоги, раз в час они прибавляются к итогам + * за всё время на флеше). Страница берёт сводку за час, сутки или всё время и обновляет её раз в минуту. */ + 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 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) + '%', 'от всех запросов'), + tile('d', 'Устройства', num(st.clients), 'спрашивали 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) { + 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' }, d.name ? d.name + ' · ' + d.ip : d.ip), E('span', { 'class': 'zm-ab-rank-n' }, num(d.q)) ]), + E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'заблокировано ' + 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' }, 'Устройства из «Без блокировки» в статистику не входят.'), + 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 ? num(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 ? num(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 ? ' — в блокировке ' + num(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'); }); + } + + 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(); + } + + /* Исключения, которые добавляет сама панель: серверы DNS, TG WS Proxy, проверка интернета и времени. + * Их не видно в белом списке и убрать нельзя — показываем, чтобы было понятно, что они есть. */ + 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(); + + /* ---------- Устройства без блокировки ---------- + * DNS отмеченных устройств уходит во второй dnsmasq без списка, с той же пересылкой, что у основного: + * реклама у них не режется, а Forkozz и Steer работают как прежде (YouTube через VPN остаётся). */ + 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 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, checkCard ])); + renderAll(); + renderAllow(); + 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' + +mkdir -p /www/luci-static/resources/view/zapret-manager +chmod 0755 /www/luci-static/resources/view/zapret-manager +cat > '/www/luci-static/resources/view/zapret-manager/mixomo.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require view'; +'require zapret-manager.common as zm'; +var 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 view = this; + var data = all[0]; + var warpData = all[1] || {}; + var wrap = E('div', { 'class': 'zm-wrap' }); + var busy = false; + var activeTab = 'main'; + + var tabBar = E('div', { 'class': 'zm-actions', 'style': 'margin-bottom:14px' }); + var panels = {}; + TABS.forEach(function(t) { + panels[t.id] = E('div', { 'style': t.id === activeTab ? '' : 'display:none' }); + }); + + function renderTabBar() { + tabBar.innerHTML = ''; + TABS.forEach(function(t) { + tabBar.appendChild(E('button', { + 'class': 'cbi-button' + (t.id === activeTab ? ' cbi-button-positive' : ''), + 'click': function() { + activeTab = t.id; + TABS.forEach(function(t2) { panels[t2.id].style.display = t2.id === activeTab ? '' : 'none'; }); + renderTabBar(); + } + }, t.label)); + }); + } + + var mainLogEl = E('pre', { 'class': 'zm-log' }); + var statusCard = E('div', { 'class': 'zm-card' }); + var panelCard = E('div', { 'class': 'zm-card' }); + var subCard = E('div', { 'class': 'zm-card' }); + var autoCard = E('div', { 'class': 'zm-card' }); + + function verRow(label, ver, latest) { + var text = ver || '—'; + if (ver && latest && ver !== latest) text += ' (доступно ' + latest + ')'; + return E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, label), + E('span', {}, text) + ]); + } + + function applyInstalled(installed) { + tabBar.style.display = installed ? '' : 'none'; + [ panelCard, subCard, autoCard ].forEach(function(c) { c.style.display = installed ? '' : 'none'; }); + if (!installed && activeTab !== 'main') { + activeTab = 'main'; + TABS.forEach(function(t2) { panels[t2.id].style.display = t2.id === activeTab ? '' : 'none'; }); + renderTabBar(); + } + } + + function renderStatus(d) { + statusCard.innerHTML = ''; + var installed = d.mihomo === 'installed'; + applyInstalled(installed); + if (!installed && (d.forkozz || d.bytetube) && !busy) { + zm.blockCard(statusCard, 'Mixomo', 'Связка из трёх программ: Mihomo (прокси-ядро) + hev-socks5-tunnel (мост в туннель) + MagiTrickle (направляет в туннель только выбранные сайты).', + d.forkozz ? 'Стоит Forkozz — он сам направляет сервисы в туннель. Чтобы поставить Mixomo, удалите Forkozz.' : 'Стоит ByeTube — он тоже использует hev-socks5-tunnel, вместе они мешают друг другу. Чтобы поставить Mixomo, удалите ByeTube.'); + return; + } + var allRunning = d.mihomo_running === true && d.hev_running === true && d.magitrickle_running === true; + var hasUpdate = (d.mihomo_version && d.mihomo_latest && d.mihomo_version !== d.mihomo_latest) || + (d.magitrickle_version && d.magitrickle_latest && d.magitrickle_version !== d.magitrickle_latest); + var actions = []; + if (installed) { + actions.push(E('button', { + 'class': 'cbi-button cbi-button-remove', + 'click': function() { + 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)); + statusCard.appendChild(E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Подписка'), + zm.badge(d.subscription === true, 'настроена', 'не настроена') + ])); + } + statusCard.appendChild(E('div', { 'class': 'zm-actions' }, actions)); + } + + var uiBusy = false; + function renderPanel(d) { + panelCard.innerHTML = ''; + panelCard.appendChild(E('h3', {}, 'Веб-панель Mihomo')); + panelCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Собственный веб-интерфейс Mihomo (статистика, выбор прокси вручную). По умолчанию ставится MetaCubeXD.')); + if (d.mihomo !== 'installed') { + panelCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Установите Mixomo, чтобы выбрать панель.')); + return; + } + panelCard.appendChild(E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Сейчас'), + E('span', {}, d.ui_panel === 'zashboard' ? 'Zashboard' : d.ui_panel === 'metacubexd' ? 'MetaCubeXD' : 'не выбрана') + ])); + panelCard.appendChild(E('div', { 'class': 'zm-grid' }, [ + E('div', { + 'class': 'zm-tile' + (d.ui_panel === 'zashboard' ? ' zm-active' : ''), + 'click': function() { doUi('zashboard'); } + }, 'Zashboard'), + E('div', { + 'class': 'zm-tile' + (d.ui_panel === 'metacubexd' ? ' zm-active' : ''), + 'click': function() { doUi('metacubexd'); } + }, 'MetaCubeXD') + ])); + } + + function doUi(which) { + if (uiBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + uiBusy = true; + zm.toast('Устанавливаем панель', 'warning'); + zm.mixomoUiAction(which).then(function(res) { + if (res.error) { uiBusy = false; zm.toast(res.error, 'error'); return; } + zm.pollJob('mixomo_ui_install', mainLogEl, function(ok) { + uiBusy = false; + zm.toast(ok ? 'Панель установлена' : 'Ошибка установки', ok ? 'info' : 'error'); + zm.mixomoStatus().then(renderPanel); + }); + }).catch(function() { uiBusy = false; }); + } + + function doAction(action) { + if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + var job = action === 'remove' ? 'mixomo_remove' : 'mixomo_install'; + var isBg = action === 'install' || action === 'update' || action === 'remove'; + busy = true; + zm.toast( + action === 'remove' ? 'Удаляем Mixomo' + : action === 'update' ? 'Обновляем Mixomo (три программы, может занять пару минут)' + : action === 'install' ? 'Устанавливаем Mixomo (три программы, может занять пару минут)' + : action === 'start' ? 'Запускаем Mixomo' + : action === 'stop' ? 'Останавливаем Mixomo' + : 'Перезапускаем Mixomo', + 'warning' + ); + zm.mixomoAction(action).then(function(res) { + if (res.error) { busy = false; zm.toast(res.error, 'error'); return; } + if (isBg && res.started) { + zm.pollJob(job, mainLogEl, function(ok) { + busy = false; + zm.toast(ok ? 'Готово' : 'Ошибка', ok ? 'info' : 'error'); + refreshAll(); + }); + } else { + busy = false; + refreshAll(); + zm.toast('Готово', 'info'); + } + }).catch(function() { busy = false; zm.toast('Роутер не ответил', 'error'); }); + } + + function refreshAll() { + zm.mixomoStatus().then(function(d) { + renderStatus(d); + renderPanel(d); + renderPresets(d); + renderEmbed(d); + renderAuto(d); + renderMtStatus(d); + renderExcl(d); + }); + } + + var subInput = E('input', { 'type': 'text', 'placeholder': 'https://...', 'class': 'cbi-input-text' }); + var subBusy = false; + subCard.appendChild(E('h3', {}, 'Подписка')); + subCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Вставьте ссылку на VPN-подписку — если в конфигурации уже настроен провайдер подписки, обновится только ссылка; если нет, будет создана готовая конфигурация с раздельной маршрутизацией YouTube и остального трафика через подписку.')); + subCard.appendChild(E('div', { 'class': 'zm-actions' }, [ + subInput, + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': function() { + if (subBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + var url = subInput.value.trim(); + if (!/^https?:\/\//i.test(url)) { zm.toast('Ссылка должна начинаться с http:// или https://', 'error'); return; } + subBusy = true; + zm.toast('Применяем подписку', 'warning'); + zm.mixomoApplySubscription(url).then(function(res) { + subBusy = false; + if (res.error) { zm.toast(res.error, 'error'); return; } + zm.toast(res.mode === 'updated' ? 'Ссылка на подписку обновлена' : 'Подписка применена, создана новая конфигурация', 'info'); + refreshAll(); + setTimeout(loadMxSrv, 4000); + refreshConfig(); + }).catch(function() { subBusy = false; zm.toast('Роутер не ответил', 'error'); }); + } + }, 'Применить подписку') + ])); + + var mxSrvEl = E('div', {}), mxSrv = null, mxSrvBusy = false, mxHideMode = false; + var mxHide = zm.hidePicker({ noun: 'серверы', exact: true, + words: { hide: 'Исключить', hidden: 'Исключено', on: 'исключён', off: 'остаётся', edit: 'Изменить список исключённых', reset: 'Вернуть все серверы', + desc: 'Эти серверы исключены из подписки — Mihomo их не загружает.', + hint: 'Отметьте серверы, которые нужно исключить из подписки, и нажмите «Сохранить». Остальные серверы работают как раньше.' }, + onMode: function(on) { mxHideMode = on; renderMxSrv(); }, + onSave: function(names, markers) { return mxHideSave(JSON.stringify({ names: names, markers: markers })); }, + onReset: function() { return mxHideSave(JSON.stringify({ reset: true })); } }); + function mxHideSave(v) { + return zm.mixomoFilterSet(v).then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return false; } + zm.toast(res.providers === false ? 'Сохранено — применится, когда в конфигурации появится подписка (proxy-providers)' : 'Список серверов применён' + (res.restarted ? ', Mihomo перезапущен' : ''), 'info'); + setTimeout(loadMxSrv, 2500); + refreshConfig(); + return true; + }, function() { zm.toast('Роутер не ответил', 'error'); return false; }); + } + function mxNodes() { + var out = [], seen = {}, prov = (mxSrv && mxSrv.data && mxSrv.data.providers) || {}; + Object.keys(prov).forEach(function(k) { + var p = prov[k] || {}; + if (k === 'default' || p.vehicleType === 'Compatible') return; + (p.proxies || []).forEach(function(x) { + if (!x || !x.name || seen[x.name]) return; + seen[x.name] = true; + var h = x.history && x.history.length ? x.history[x.history.length - 1].delay : null; + out.push({ name: x.name, type: String(x.type || '').toLowerCase(), delay: h }); + }); + }); + return out; + } + function loadMxSrv() { + if (mxSrvBusy) return; + mxSrvBusy = true; renderMxSrv(); + zm.mixomoProxies().then(function(r) { mxSrvBusy = false; mxSrv = r || {}; renderMxSrv(); }, function() { mxSrvBusy = false; mxSrv = { error: 'роутер не ответил' }; renderMxSrv(); }); + } + function renderMxSrv() { + mxSrvEl.innerHTML = ''; + mxSrvEl.appendChild(E('h4', { 'style': 'margin:16px 0 8px' }, 'Исключение серверов из подписки')); + mxSrvEl.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin:0 0 8px' }, [ 'Здесь можно убрать ненужные серверы из подписки — панель добавит их в exclude-filter, и Mihomo не будет их загружать. Это не выбор сервера: выбирать, через какой сервер подключаться, — в панели Mihomo.' ])); + if (!mxSrv) { mxSrvEl.appendChild(E('p', { 'class': 'zm-hint' }, mxSrvBusy ? 'Загружаем…' : 'Список ещё не загружен.')); return; } + var nodes = mxNodes(), hn = mxSrv.hide_names || [], seen = {}; + var items = nodes.map(function(n) { seen[n.name] = true; return { name: n.name, foot: n.type, hidden: hn.indexOf(n.name) >= 0 }; }); + (mxSrv.all_names || []).forEach(function(nm) { + if (!nm || seen[nm]) return; + seen[nm] = true; + var byName = hn.indexOf(nm) >= 0; + items.push({ name: nm, foot: 'не загружается', hidden: byName, locked: !byName }); + }); + mxHide.update(items, { markers: mxSrv.exclude || '', hideNames: hn, noEdit: !nodes.length, error: mxSrv.error ? 'Список серверов недоступен: ' + mxSrv.error : (!nodes.length ? 'Mihomo не отдал список серверов — проверьте подписку в конфигурации' : '') }); + if (!mxHideMode && nodes.length) { + mxSrvEl.appendChild(E('div', { 'class': 'zm-nodes' }, nodes.map(function(n) { + return E('div', { 'class': 'zm-node', 'style': 'cursor:default' }, [ + E('div', { 'class': 'zm-node-name' }, [ String(n.name) ]), + E('div', { 'class': 'zm-node-foot' }, [ E('span', {}, [ n.type ]), E('span', { 'class': 'zm-lat ' + (n.delay > 0 ? (n.delay < 300 ? 'zm-lat-good' : n.delay < 800 ? 'zm-lat-mid' : 'zm-lat-bad') : 'zm-lat-none') }, n.delay > 0 ? n.delay + ' мс' : '') ]) + ]); + }))); + mxSrvEl.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'disabled': mxSrvBusy ? '' : null, 'click': loadMxSrv }, mxSrvBusy ? 'Загружаем…' : 'Обновить список') ])); + mxSrvEl.appendChild(E('p', { 'class': 'zm-hint' }, [ 'Серверов загружено: ' + nodes.length + '. Это список для справки — нажатие на сервер ничего не выбирает.' ])); + if (mxSrv.own_filter) mxSrvEl.appendChild(E('p', { 'class': 'zm-hint' }, [ 'В конфигурации есть свой фильтр exclude-filter: «' + mxSrv.own_filter + '» — панель его сохраняет и добавляет к нему свои исключения.' ])); + } else if (!mxHideMode) { + mxSrvEl.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'disabled': mxSrvBusy ? '' : null, 'click': loadMxSrv }, mxSrvBusy ? 'Загружаем…' : 'Обновить список') ])); + } + mxSrvEl.appendChild(mxHide); + } + subCard.appendChild(mxSrvEl); + function renderExcl(d) { + if (d && d.mihomo === 'installed' && !mxSrv && !mxSrvBusy) loadMxSrv(); + } + + + var autoBusy = false; + function renderAuto(d) { + autoCard.innerHTML = ''; + autoCard.appendChild(E('h3', {}, 'Автоперезапуск Mihomo')); + autoCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Периодический перезапуск по расписанию — полезно для VPN-подписок, у которых иногда «зависает» соединение.')); + var cur = d.autorestart || ''; + var mode = cur === '' ? 'off' : (cur.indexOf('every:') === 0 ? 'every' + cur.split(':')[1] : 'daily'); + var curHour = mode === 'daily' ? parseInt(cur.split(':')[1], 10) : 4; + if (isNaN(curHour) || curHour < 0 || curHour > 23) curHour = 4; + function hh(h) { return (h < 10 ? '0' : '') + h + ':00'; } + + var opts = []; + for (var h = 0; h < 24; h++) + opts.push(E('option', { 'value': String(h), 'selected': h === curHour ? 'selected' : null }, hh(h))); + var hourSel = E('select', { 'class': 'cbi-input-select zm-hour-select' }, opts); + + var dailyRow = E('div', { 'class': 'zm-actions', 'style': mode === 'daily' ? '' : 'display:none' }, [ + E('span', { 'class': 'zm-label' }, 'Время перезапуска'), + hourSel, + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': function() { doAuto('daily', hourSel.value); } + }, mode === 'daily' ? 'Сохранить время' : 'Включить') + ]); + + function tile(id, label, onclick) { + return E('div', { 'class': 'zm-tile' + (mode === id ? ' zm-active' : ''), 'click': onclick }, label); + } + autoCard.appendChild(E('div', { 'class': 'zm-grid' }, [ + tile('off', 'Выключен', function() { if (mode !== 'off') doAuto('off', ''); }), + tile('every2', 'Каждые 2 часа', function() { if (mode !== 'every2') doAuto('every', '2'); }), + tile('every6', 'Каждые 6 часов', function() { if (mode !== 'every6') doAuto('every', '6'); }), + tile('daily', mode === 'daily' ? 'Ежедневно в ' + hh(curHour) : 'Ежедневно в заданное время', function() { + dailyRow.style.display = ''; + hourSel.focus(); + }) + ])); + autoCard.appendChild(dailyRow); + } + + function doAuto(mode, value) { + if (autoBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + autoBusy = true; + zm.toast('Настраиваем автоперезапуск', 'warning'); + zm.mixomoAutorestartSet(mode, value).then(function(res) { + autoBusy = false; + if (res.error) { zm.toast(res.error, 'error'); return; } + zm.toast('Автоперезапуск настроен', 'info'); + zm.mixomoStatus().then(renderAuto); + }).catch(function() { autoBusy = false; }); + } + + panels.main.appendChild(statusCard); + panels.main.appendChild(mainLogEl); + panels.main.appendChild(panelCard); + panels.main.appendChild(subCard); + panels.main.appendChild(autoCard); + + var configEl = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false' }); + var configCard = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Редактор конфигурации Mihomo'), + E('p', { 'class': 'zm-hint' }, 'Прямое редактирование /etc/mihomo/config.yaml. Проверка — как в оригинальном установщике: сохранение перезапускает Mihomo, и если он не поднимается с новой конфигурацией, изменения автоматически откатываются, а рабочая версия остаётся нетронутой.') + ]); + configCard.appendChild(configEl); + var configBar = zm.editorBar(configEl, { + onSave: function(v) { + zm.toast('Сохраняем конфигурацию, проверяем и перезапускаем Mihomo', 'warning'); + return zm.mixomoConfigSet(v).then(function(res) { + if (res.error) { zm.toast(res.error, 'error', 12000); return false; } + zm.toast('Проверка пройдена, Mihomo перезапущен с новой конфигурацией', 'info'); + refreshAll(); + return true; + }).catch(function() { zm.toast('Роутер не ответил', 'error'); return false; }); + } + }); + + function refreshConfig() { + zm.mixomoConfigGet().then(function(res) { + if (res.error) { configBar.reset(''); configEl.placeholder = res.error; return; } + configBar.reset(res.content || ''); + }); + } + + configCard.appendChild(E('div', { 'class': 'zm-actions' }, [ + E('button', { + 'class': 'cbi-button', + 'click': function() { + 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 presetCard = E('div', { 'class': 'zm-card' }); + var embedCard = E('div', { 'class': 'zm-card' }); + + function renderMtStatus(d) { + mtStatusCard.innerHTML = ''; + mtStatusCard.appendChild(E('h3', {}, 'MagiTrickle')); + mtStatusCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Направляет в туннель Mixomo только выбранные сайты и адреса — остальной трафик идёт напрямую через провайдера. Устанавливается вместе с Mixomo на вкладке «Mixomo».')); + mtStatusCard.appendChild(E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Статус'), + d.magitrickle === 'installed' ? zm.badge(d.magitrickle_running === true, 'запущен', 'остановлен') : zm.badge(false, '', 'не установлен') + ])); + if (d.magitrickle_version) mtStatusCard.appendChild(verRow('Версия', d.magitrickle_version, d.magitrickle_latest)); + } + + var presetBusy = false; + function renderPresets(d) { + presetCard.innerHTML = ''; + presetCard.appendChild(E('h3', {}, 'Готовые списки доменов')); + presetCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Быстрая замена текущего списка на один из готовых наборов — зелёным отмечен список, применённый сейчас. Для собственного точного набора сайтов используйте окно ниже.')); + if (d.magitrickle !== 'installed') { + presetCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Установите Mixomo на вкладке «Mixomo», чтобы выбрать список.')); + return; + } + presetCard.appendChild(E('div', { 'class': 'zm-grid' }, PRESETS.map(function(p) { + return E('div', { + 'class': 'zm-tile' + (d.magitrickle_list === p.id ? ' zm-active' : ''), + 'click': function() { + if (presetBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + presetBusy = true; + zm.toast('Применяем список: ' + p.label, 'warning'); + zm.mixomoMagitrickleListSet(p.id).then(function(res) { + presetBusy = false; + if (res.error) { zm.toast(res.error, 'error'); return; } + zm.toast('Список применён: ' + p.label, 'info'); + waitForListAppliedThenRefresh(p.id); + }).catch(function() { presetBusy = false; }); + } + }, p.label); + }))); + } + + var mtFrame = null; + function refreshMtEmbed() { + if (mtFrame) { mtFrame.src = mtFrame.src.split('?')[0] + '?_r=' + Date.now(); } + } + + function waitForListAppliedThenRefresh(presetId) { + var attempts = 0, maxAttempts = 10, inFlight = false; + var timer = setInterval(function() { + if (inFlight) return; + attempts++; + if (attempts > maxAttempts + 2) { clearInterval(timer); return; } + inFlight = true; + zm.mixomoStatus().then(function(d) { + inFlight = false; + if (!d || d.error) return; + if (d.magitrickle_list === presetId || attempts >= maxAttempts) { + clearInterval(timer); + renderPresets(d); + setTimeout(refreshMtEmbed, 2500); + } + }).catch(function() { inFlight = false; }); + }, 1500); + } + + function renderEmbed(d) { + embedCard.innerHTML = ''; + embedCard.appendChild(E('h3', {}, 'Окно MagiTrickle')); + if (d.magitrickle !== 'installed') { + embedCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Установите Mixomo, чтобы открыть управление группами и подписками MagiTrickle.')); + mtFrame = null; + return; + } + var host = window.location.hostname; + var url = 'http://' + host + ':8080'; + if (window.location.protocol === 'https:') { + mtFrame = null; + embedCard.appendChild(E('p', { 'class': 'zm-hint' }, 'HTTPS-соединение блокирует встроенное окно MagiTrickle. Откройте его в новой вкладке для управления «Группами» и «Подписками» — после применения готового списка выше просто обновите ту вкладку.')); + embedCard.appendChild(E('div', { 'class': 'zm-actions' }, [ + E('a', { 'class': 'cbi-button cbi-button-positive', 'href': url, 'target': '_blank', 'rel': 'noreferrer' }, 'Открыть MagiTrickle') + ])); + } else { + mtFrame = E('iframe', { + 'src': url, + 'style': 'width:100%; height:640px; border:1px solid var(--border, rgba(127,127,127,.25)); border-radius:10px' + }); + embedCard.appendChild(E('div', { 'class': 'zm-actions' }, [ + E('button', { 'class': 'cbi-button', 'click': refreshMtEmbed }, 'Обновить окно MagiTrickle') + ])); + embedCard.appendChild(mtFrame); + } + } + + panels.magitrickle.appendChild(mtStatusCard); + panels.magitrickle.appendChild(presetCard); + panels.magitrickle.appendChild(embedCard); + + var warpLogEl = E('pre', { 'class': 'zm-log' }); + var warpStatusCard = E('div', { 'class': 'zm-card' }); + var warpConfCard = E('div', { 'class': 'zm-card' }); + var warpBusy = false; + var warpIntegrateBusy = false; + + function renderWarpStatus(w) { + warpStatusCard.innerHTML = ''; + warpStatusCard.appendChild(E('h3', {}, 'WARP')); + warpStatusCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Генерирует бесплатный ключ Cloudflare WARP и сохраняет его в /root/WARP.conf (тот же файл, что на вкладке AmneziaWG). Дальше файл можно интегрировать в Mihomo как ещё один прокси-выход. «С подбором точки входа»: если установлен AmneziaWG, роутер проверит точки входа настоящим туннелем и выберет зарубежный узел Cloudflare — через российский (DME и др.) часть сайтов не открывается; без AmneziaWG подбор приблизительный.')); + warpStatusCard.appendChild(E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'WARP.conf'), + zm.badge(w.exists === true, 'сгенерирован', 'не сгенерирован') + ])); + warpStatusCard.appendChild(E('div', { 'class': 'zm-actions' }, [ + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': function() { doWarpGenerate('fixed'); } + }, 'Сгенерировать WARP'), + E('button', { + 'class': 'cbi-button', + 'click': function() { doWarpGenerate('auto'); } + }, 'С подбором точки входа'), + E('button', { + 'class': 'cbi-button', + 'click': function() { + if (warpIntegrateBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + if (w.exists !== true) { zm.toast('Сначала сгенерируйте WARP.conf', 'error'); return; } + warpIntegrateBusy = true; + zm.toast('Интегрируем WARP в Mihomo', 'warning'); + zm.mixomoWarpIntegrateAction().then(function(res) { + if (res.error) { warpIntegrateBusy = false; zm.toast(res.error, 'error'); return; } + zm.pollJob('mixomo_warp_integrate', warpLogEl, function(ok) { + warpIntegrateBusy = false; + zm.toast(ok ? 'WARP добавлен в Mihomo как прокси' : 'Ошибка интеграции', ok ? 'info' : 'error'); + if (ok) refreshConfig(); + }); + }).catch(function() { warpIntegrateBusy = false; }); + } + }, 'Интегрировать в Mihomo') + ])); + } + + function doWarpGenerate(mode) { + if (warpBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + warpBusy = true; + zm.toast(mode === 'auto' ? 'Генерируем WARP и подбираем точку входа — это может занять несколько минут' : 'Генерируем WARP', 'warning'); + zm.mixomoWarpAction(mode).then(function(res) { + if (res.error) { warpBusy = false; zm.toast(res.error, 'error'); return; } + zm.pollJob('mixomo_warp', warpLogEl, function(ok) { + warpBusy = false; + zm.toast(ok ? 'WARP сгенерирован' : 'Не удалось сгенерировать WARP', ok ? 'info' : 'error'); + zm.mixomoWarpStatus().then(function(w) { renderWarpStatus(w); renderWarpConf(w); }); + }); + }).catch(function() { warpBusy = false; }); + } + + var warpConfigEl = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false' }); + var warpBar = zm.editorBar(warpConfigEl, { + saveLabel: 'Сохранить', + onSave: function(v) { + zm.toast('Сохраняем WARP.conf', 'warning'); + return zm.mixomoWarpConfigSet(v).then(function(res) { + if (res.error) { zm.toast(res.error, 'error', 10000); return false; } + zm.toast('WARP.conf сохранён', 'info'); + zm.mixomoWarpStatus().then(function(w) { renderWarpStatus(w); renderWarpConf(w); }); + return true; + }).catch(function() { zm.toast('Роутер не ответил', 'error'); return false; }); + } + }); + function renderWarpConf(w) { + warpBar.reset(w.content || ''); + } + + warpConfCard.appendChild(E('h3', {}, 'Редактор WARP.conf')); + warpConfCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Можно вставить сюда свой готовый WARP.conf (например, купленный отдельно ключ) вместо генерации выше — при сохранении проверяются обязательные поля ([Interface]/[Peer], PrivateKey/PublicKey).')); + warpConfCard.appendChild(warpConfigEl); + warpConfCard.appendChild(E('div', { 'class': 'zm-actions' }, [ + E('button', { + 'class': 'cbi-button', + 'click': function() { + 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' + +mkdir -p /www/luci-static/resources/view/zapret-manager +chmod 0755 /www/luci-static/resources/view/zapret-manager +cat > '/www/luci-static/resources/view/zapret-manager/strategy.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require view'; +'require zapret-manager.common as zm'; +var 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); + +/* Метка ts: перед применением спрашиваем роутер, будет ли она в стратегии, и если да — предупреждаем + * про команду для Windows. spec — v:v3, flowseal:имя, youtube:Yv08, dv:5, gv:2, text:<блок>. + * Ответ — список частей с ts или null (ts нет или проверить не удалось: тогда не мешаем применению). */ +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; }); +} +/* go() — само применение. ask — обычный вопрос «Применить…?», если ts нет (без него — сразу). */ +function tsGate(spec, go, ask, note) { + if (tsChecking) return; + tsChecking = true; + tsCheck(spec).then(function(parts) { + tsChecking = false; + /* предупреждение про ts — один раз; дальше применяется как обычно */ + 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' } ]; +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 view = this; + var statusData = all[0], vListData = all[1], testData = all[2], ytInitialData = all[3], gameData = all[4], discordData = all[5], exclusionsData = all[6]; + var latestVersion = (all[7] && all[7].version) || ''; + var wrap = E('div', { 'class': 'zm-wrap' }); + var mainSection = E('div', {}); + var activeTab = 'strategy'; + var zapretInstalled = statusData.zapret === 'installed'; + + var tabBar = E('div', { 'class': 'zm-actions', 'style': 'margin:10px 0' }); + var panels = {}; + var tabHooks = {}; + var refreshers = {}; + function refreshAll() { + Object.keys(refreshers).forEach(function(k) { try { refreshers[k](); } catch (e) {} }); + } + TABS.forEach(function(t) { + panels[t.id] = E('div', { 'style': t.id === activeTab ? '' : 'display:none' }); + }); + + function renderTabBar() { + tabBar.innerHTML = ''; + TABS.forEach(function(t) { + tabBar.appendChild(E('button', { + 'class': 'cbi-button' + (t.id === activeTab ? ' cbi-button-positive' : ''), + 'click': function() { + activeTab = t.id; + TABS.forEach(function(t2) { panels[t2.id].style.display = t2.id === activeTab ? '' : 'none'; }); + renderTabBar(); + if (tabHooks[t.id]) tabHooks[t.id](); + } + }, t.label)); + }); + } + + (function buildStrategyPanel() { + var status = statusData, vList = vListData; + var lastFlowseal = null; + var logEl = E('pre', { 'class': 'zm-log' }); + var busy = false; + + var zBusy = false; + var zLogEl = E('pre', { 'class': 'zm-log' }); + var zBannerEl = E('div', {}); + var zCardWrap = E('div', {}); + + function renderZCard(d) { + zCardWrap.innerHTML = ''; + var zActions = []; + if (d.zapret === 'installed') { + zActions.push(E('button', { + 'class': 'cbi-button cbi-button-remove', + 'click': function() { + 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' }); + var fCard = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Стратегии Flowseal'), + E('p', { 'class': 'zm-hint' }, 'Стратегия Flowseal полностью заменяет текущую. В ней уже есть основной блок, игровой, блоки для YouTube и Discord.'), + E('div', { 'class': 'zm-actions' }, [ + E('button', { + 'class': 'cbi-button', + 'click': function() { + if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + busy = true; + zm.toast('Обновляем список Flowseal', 'warning'); + fGrid.innerHTML = 'Загрузка списка'; + zm.strategyListFlowseal('refresh').then(function(res) { + if (res.started) { + zm.pollJob('flowseal_download', logEl, function(ok) { + busy = false; + zm.toast(ok ? 'Список Flowseal обновлён' : 'Не удалось обновить список', ok ? 'info' : 'error'); + zm.strategyListFlowseal().then(renderFlowseal); + }); + } else { + busy = false; + renderFlowseal(res); + zm.toast('Список Flowseal обновлён', 'info'); + } + }).catch(function() { busy = false; }); + } + }, 'Обновить список') + ]), + fGrid + ]); + + function renderBanner() { + currentBanner.className = 'zm-current-banner zm-current-top' + (status.strategy ? '' : ' zm-current-empty'); + currentBanner.innerHTML = ''; + if (status.strategy) { + currentBanner.appendChild(E('span', {}, 'Сейчас применено: ')); + currentBanner.appendChild(E('b', {}, [ String(status.strategy) ])); + 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' }, 'Список пуст — нажмите «Обновить список».')); + } + + 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 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 === '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; + renderExtra(); renderBanner(); renderV(); + if (lastFlowseal) renderFlowseal(lastFlowseal); + zm.toast(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; + 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); + + zm.strategyListFlowseal().then(function(res) { + if (!res.started) { renderFlowseal(res); return; } + busy = true; + fGrid.appendChild(E('p', { 'class': 'zm-hint' }, 'Загружаем список…')); + zm.pollJob('flowseal_download', logEl, function(ok) { + busy = false; + if (!ok) { fGrid.innerHTML = ''; fGrid.appendChild(E('p', { 'class': 'zm-hint' }, 'Список не загрузился — нажмите «Обновить список».')); return; } + zm.strategyListFlowseal().then(renderFlowseal); + }); + }); + + var nfqwsCard = E('div', { 'class': 'zm-card' }); + var nfqwsOpen = false; + var nfqwsEl = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false', 'style': 'display:none' }); + var nfqwsBar = zm.editorBar(nfqwsEl, { + onSave: function(v) { + 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; + /* в стратегии, которая стоит сейчас, есть метка ts (её поставили раньше или ночью — автоподбор), + * а про команду для Windows ещё не предупреждали — показываем один раз, только с «Понятно» */ + 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) { + if (autoBusy) return; + autoBusy = true; renderAuto(); + zm.testAction('auto_set', when + '|' + mode).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'; + 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' }, 'Роутер проверяет стратегии на списке заблокированных сайтов и сравнивает лучшую с текущей. Новая стратегия применяется, только если открывает больше сайтов. Настройки YouTube, Discord, игр, QUIC и дополнения к стратегии сохраняются.')); + 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, m.id, 'Автоподбор будет проверять: ' + m.label); } }, m.label); + })) + ])); + } + var last = autoInfo.last; + if (last && last.at) { + var res = last.result === 'applied' ? [ E('b', { 'class': 'zm-ab-ok' }, 'применена ' + last.best), ' — ' + last.best_ok + ' из ' + last.total + ' сайтов (было ' + (last.cur || 'текущая') + ' — ' + last.cur_ok + ' из ' + last.total + ')' ] + : last.result === 'kept' ? [ E('b', {}, 'оставлена ' + (last.cur || 'текущая')), ' — ' + last.cur_ok + ' из ' + last.total + ' сайтов; лучшая из проверенных ' + last.best + ' — ' + last.best_ok + ' из ' + last.total ] + : last.result === 'nochange' ? [ 'пропущен — включено «Не изменять стратегию»' ] + : last.result === 'none' ? [ 'лучшую стратегию определить не удалось, оставлена текущая' ] : [ E('b', { 'class': 'zm-ab-bad' }, 'ошибка'), ' — подробности в журнале теста' ]; + autoCard.appendChild(E('div', { 'class': 'zm-ab-last' }, [ E('span', { 'class': 'zm-label' }, 'Последний запуск ' + whenText(last.at) + ': ') ].concat(res))); + } + 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'); + 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 renderResults(loading) { + resCard.innerHTML = ''; + resCard.appendChild(E('h3', {}, 'Результаты')); + var avail = TEST_RESULT_MODES.filter(function(m) { return status['has_results_' + m]; }); + if (!avail.length) { + resCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Пока пусто — запустите любой тест выше.')); + return; + } + resCard.appendChild(E('div', { 'class': 'zm-tt-tabs' }, [ + E('div', { 'class': 'zm-seg' }, avail.map(function(m) { + return E('div', { 'class': 'zm-seg-item' + (m === resMode ? ' zm-active' : ''), 'click': function() { if (m !== resMode) loadResults(m); } }, TEST_MODE_LABELS[m] || m); + })), + E('button', { 'class': 'cbi-button', 'click': function() { + 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]+$/.test(words[i])) return words[i]; + } + return ''; + } + + function renderGrid(res) { + lastList = res; + grid.innerHTML = ''; + grid.appendChild(E('div', { + 'class': 'zm-tile' + (yvOff && !current ? ' zm-active' : ''), + 'click': function() { + if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + if (yvOff && !current) return; + busy = true; + zm.toast('Выключаем YouTube-стратегию', 'warning'); + zm.strategySetYoutube('off').then(function(r2) { + busy = false; + if (r2.error) { zm.toast(r2.error, 'error'); return; } + zm.toast(r2.removed ? 'YouTube-стратегия выключена' : 'YouTube-стратегии и так не было — теперь она не будет добавляться', 'info'); + refreshAll(); + }).catch(function() { busy = false; }); + } + }, yvOff && !current ? 'Выключена' : 'Выключить')); + (res.items || []).forEach(function(it) { + grid.appendChild(E('div', { + 'class': 'zm-tile' + (current === it.id ? ' zm-active' : ''), + 'click': function() { + if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + if (current === it.id) return; + 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 ? 'Выключить' : 'Включить') + ])); + } + + function refreshState() { + zm.status().then(function(s) { + current = currentYv(s); + yvOff = !!s.yv_off; + quicOn = !!s.quic_yt; + if (lastList) renderGrid(lastList); + renderQuic(); + }); + zm.systemStatus().then(function(ss) { + quicBlocked = !!(ss && ss.quic_blocked); + renderQuic(); + }).catch(function() {}); + } + + refreshers.youtube = refreshState; + tabHooks.youtube = refreshState; + var yvOff = !!status.yv_off; + current = currentYv(status); + renderQuic(); + + var card = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Стратегии для YouTube'), + E('div', { 'class': 'zm-actions' }, [ + E('button', { + 'class': 'cbi-button', + 'click': function() { + if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + busy = true; + zm.toast('Обновляем список YouTube-стратегий', 'warning'); + grid.innerHTML = 'Загрузка списка'; + zm.strategyListYoutube('refresh').then(function(res) { + if (res.started) { + zm.pollJob('youtube_download', logEl, function(ok) { + busy = false; + zm.toast(ok ? 'Список YouTube-стратегий обновлён' : 'Не удалось обновить список', ok ? 'info' : 'error'); + zm.strategyListYoutube().then(renderGrid); + }); + } else { + busy = false; + renderGrid(res); + zm.toast('Список YouTube-стратегий обновлён', 'info'); + } + }).catch(function() { busy = false; }); + } + }, 'Обновить список') + ]), + grid, + E('p', { 'class': 'zm-hint' }, 'Меняет только YouTube-часть текущей стратегии. «Выключить» убирает её совсем — например, если YouTube идёт через ByeTube или Steer.') + ]); + + panels.youtube.appendChild(card); + panels.youtube.appendChild(logEl); + panels.youtube.appendChild(quicCard); + refreshState(); + + if (!initial.started) { + renderGrid(initial); + } else { + busy = true; + grid.appendChild(E('p', { 'class': 'zm-hint' }, 'Загружаем список…')); + zm.pollJob('youtube_download', logEl, function(ok) { + busy = false; + if (!ok) { grid.innerHTML = ''; grid.appendChild(E('p', { 'class': 'zm-hint' }, 'Список не загрузился — нажмите «Обновить список».')); return; } + zm.strategyListYoutube().then(renderGrid); + }); + } + })(); + + (function buildGamePanel() { + var data = gameData; + var gvGrid = E('div', { 'class': 'zm-grid' }); + var xtremeRow = E('div', {}); + var fakeGrid = E('div', { 'class': 'zm-grid' }); + var busy = false; + + function renderGv() { + gvGrid.innerHTML = ''; + var off = !data.active; + gvGrid.appendChild(E('div', { + 'class': 'zm-tile' + (off ? ' zm-active' : ''), + 'click': function() { + if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + if (off) return; + busy = true; + zm.toast('Выключаем игровую стратегию', 'warning'); + zm.gameSet('off').then(function(res) { + busy = false; + if (res.error) { zm.toast(res.error, 'error'); return; } + zm.toast('Игровая стратегия выключена', 'info'); + refreshAll(); + }).catch(function() { busy = false; }); + } + }, off ? 'Выключена' : 'Выключить')); + [1, 2, 3, 4].forEach(function(n) { + gvGrid.appendChild(E('div', { + 'class': 'zm-tile' + (data.active && data.current === ('Gv' + n) ? ' zm-active' : ''), + 'click': function() { + if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + if (data.active && data.current === ('Gv' + n)) return; + 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' + +mkdir -p /www/luci-static/resources/view/zapret-manager +chmod 0755 /www/luci-static/resources/view/zapret-manager +cat > '/www/luci-static/resources/view/zapret-manager/zapret2.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require view'; +'require zapret-manager.common as zm'; +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') { + 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' + +mkdir -p /www/luci-static/resources/view/zapret-manager +chmod 0755 /www/luci-static/resources/view/zapret-manager +cat > '/www/luci-static/resources/view/zapret-manager/style.css' << 'ZM_INSTALLER_EOF' +.zm-wrap { display: flex; flex-direction: column; gap: 16px; max-width: 1100px; } + +.zm-header { display: flex; align-items: baseline; gap: 10px; margin-bottom: -4px; flex-wrap: wrap; } +.zm-header h2 { margin: 0; font-size: 22px; font-weight: 700; } +.zm-header-by { font-size: 13px; opacity: .55; } +.zm-header-links { display: flex; gap: 8px; margin-left: auto; flex-wrap: wrap; } +.zm-header-links a { + font-size: 12.5px; font-weight: 600; text-decoration: none; + color: #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; } +/* тёмные темы LuCI: те же значки, но цвета светлее — иначе зелёный и серый на тёмном фоне не читаются */ +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-grid-devices { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 9px; } +.zm-grid-devices .zm-tile { flex: none; min-width: 0; width: 100%; box-sizing: border-box; } + +/* «Спасибо»: благодарность сверху, авторы по разделам — по кнопке */ +.zm-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-avs { display: flex; align-items: center; flex-wrap: wrap; margin-top: 10px; padding-left: 6px; } +.zm-th-av { width: 30px; height: 30px; box-sizing: border-box; border-radius: 50%; display: grid; place-items: center; margin-left: -6px; color: #fff; font-weight: 700; font-size: 13px; border: 2px solid #fff; } +.zm-th-more { width: auto; padding: 0 9px; border-radius: 999px; background: #64748b; font-size: 11.5px; } +html.zm-theme-dark .zm-th-av { border-color: #1c2128; } +.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-credits-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } +@media (max-width: 720px) { .zm-credits-grid { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 460px) { .zm-credits-grid { grid-template-columns: 1fr; } } +.zm-credit-tile { + min-width: 0; + box-sizing: border-box; + border: 1px solid rgba(0,0,0,.1); + border-radius: 10px; + padding: 12px 14px; + text-align: center; + background: rgba(0,0,0,.02); +} +html.zm-theme-dark .zm-credit-tile { border-color: rgba(255,255,255,.12); background: rgba(255,255,255,.03); } +.zm-credit-tile.zm-credit-self { border-color: rgba(26,127,55,.35); background: rgba(26,127,55,.06); } +.zm-credit-product { font-weight: 700; font-size: 13.5px; margin-bottom: 2px; overflow-wrap: anywhere; } +.zm-credit-author { font-size: 12px; opacity: .75; margin-bottom: 6px; } +.zm-credit-tile a { font-size: 12px; word-break: break-all; overflow-wrap: anywhere; } +.zm-credit-tile.zm-credit-all { grid-column: 1 / -1; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; } +.zm-credit-tile.zm-credit-all .zm-credit-author { margin-bottom: 0; } + +.zm-tile { + flex: 0 1 auto; + min-width: 90px; + max-width: 100%; + border: 1px solid rgba(0,0,0,.1); + border-radius: 9px; + padding: 10px 16px; + cursor: pointer; + text-align: center; + font-size: 13px; + line-height: 1.35; + overflow-wrap: break-word; + transition: border-color .15s, background .15s, transform .1s; + background: var(--background-color-low, #fafafa); +} +html.zm-theme-dark .zm-tile:not(.zm-active):not(.zm-tile-off) { + background: #22272e; + border-color: rgba(255,255,255,.12); +} +.zm-tile:hover { border-color: #1a7f37; transform: translateY(-1px); } +.zm-tile.zm-active { + border-color: #1a7f37; background: rgba(26,127,55,.16); + font-weight: 700; color: #15803d; + box-shadow: 0 0 0 2px rgba(26,127,55,.35); +} +.zm-tile.zm-active::before { content: none; } + +.zm-tile.zm-tile-off { + border-color: rgba(207,34,46,.35); background: rgba(207,34,46,.08); + color: #cf222e; font-weight: 600; +} +.zm-tile.zm-tile-off::before { content: none; } +.zm-tile.zm-tile-off:hover { border-color: #cf222e; } +.zm-tile.zm-tile-pending { opacity: .55; border-style: dashed; cursor: not-allowed; } +.zm-tile.zm-tile-pending:hover { border-color: rgba(0,0,0,.1); transform: none; } +.zm-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-svc { display: inline-flex; align-items: center; gap: 6px 10px; flex-wrap: wrap; min-width: 0; } +/* цепочка запасных DoH-серверов */ +.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 { border: 1px solid rgba(127,127,127,.18); border-radius: 12px; margin-top: 10px; overflow: hidden; } +.zm-cat .zm-cat-head { padding: 12px 16px; } +.zm-cat-sub { margin-top: 2px; font-size: 12px; opacity: .6; } +.zm-cat-count { flex: 0 0 auto; } +.zm-cat-body { padding: 12px 16px 16px; border-top: 1px solid rgba(127,127,127,.15); } +.zm-cat-tools { display: flex; align-items: center; justify-content: flex-end; margin: -4px -6px 8px; } +.zm-cat-links { display: flex; gap: 4px; margin-left: auto; } +.zm-linkbtn { background: none; border: 0; padding: 6px 10px; border-radius: 8px; font: inherit; font-size: 13px; font-weight: 600; color: #2563eb; cursor: pointer; } +.zm-linkbtn:hover { background: rgba(37,99,235,.08); } +.zm-linkbtn[disabled] { opacity: .35; cursor: default; background: none; } +html.zm-theme-dark .zm-linkbtn { color: #6ea8fe; } +.zm-cat-grid { margin: 0; } +.zm-cat-src { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 16px; padding: 14px 16px; border: 1px dashed rgba(127,127,127,.3); border-radius: 12px; } +.zm-cat-src-info { flex: 1 1 260px; min-width: 0; } +.zm-cat-src-title { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 14px; } +.zm-cat-src-state { margin-top: 4px; font-size: 13px; opacity: .8; } +.zm-cat-src-url { margin-top: 2px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; opacity: .55; overflow-wrap: anywhere; } +.zm-cat-src-actions { margin: 0 !important; flex: 0 0 auto; } +@media (max-width: 600px) { .zm-cat-tools { justify-content: flex-start; } .zm-cat-src-actions { flex: 1 1 100%; } } +@media (max-width: 600px) { + .zm-list-head { padding: 12px 14px; gap: 10px; } + .zm-list-body { padding: 4px 14px 14px; } + .zm-list-count { min-width: 0; } + .zm-list-actions .cbi-button { flex: 1 1 100%; } + .zm-list-dirty { font-size: 0; gap: 0; } +} +@media (max-width: 600px) { + .zm-config-editor { font-size: 16px; min-height: 320px; } +} + +.zm-log-arrow { color: #56d4dd; font-weight: 700; } +.zm-log-msg-info { color: #e3c04a; } +.zm-log-msg-ok { color: #3fb950; font-weight: 600; } +.zm-log-msg-error { color: #ff7b72; font-weight: 600; } +.zm-log-msg-warn { color: #ffa657; } +.zm-log-code { color: #8b949e; } + +.zm-hint { font-size: 12px; opacity: .65; margin-top: 6px; line-height: 1.5; overflow-wrap: break-word; } + +.zm-refresh-banner { + display: flex; align-items: center; justify-content: space-between; gap: 14px; + background: rgba(191,135,0,.12); border: 2px solid rgba(191,135,0,.35); + color: #9a6700; border-radius: 12px; padding: 16px 20px; font-size: 15px; font-weight: 500; + margin-top: 12px; +} +.zm-refresh-banner button { flex-shrink: 0; } +/* Блокировка рекламы: исключения, которые добавляет сама панель */ +.zm-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-qr-btn { white-space: nowrap; } +.zm-tg-qr-box { margin-top: 10px; text-align: center; } +.zm-tg-qr-box img { max-width: 100%; height: auto; border-radius: 8px; background: #fff; padding: 8px; box-shadow: 0 0 0 1px rgba(0,0,0,.08); box-sizing: border-box; } + +.zm-current-banner { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + background: rgba(26,127,55,.07); border: 1px solid rgba(26,127,55,.22); + border-radius: 10px; padding: 10px 16px; font-size: 13px; margin-bottom: 4px; +} +.zm-current-banner b { font-weight: 700; } +.zm-current-banner.zm-current-empty { + background: rgba(110,118,129,.08); border-color: rgba(110,118,129,.2); +} +.zm-current-banner.zm-current-top { margin: 0 0 10px; } + +.bt-cols { display: grid; grid-template-columns: 1fr 1fr; column-gap: 44px; } +.bt-col { min-width: 0; } +.bt-col .zm-label { flex: 0 0 170px; } +@media (max-width: 860px) { + .bt-cols { grid-template-columns: 1fr; } + .bt-col .zm-label { flex: 0 0 128px; } +} +@media (max-width: 420px) { + .bt-col .zm-label { flex: 0 0 108px; font-size: 12px; } +} + +.cbi-page-actions { display: none !important; } +.zm-ab-panel { display: flex; flex-direction: column; gap: 16px; } +.zm-ab-head { display: flex; align-items: flex-start; gap: 14px; flex-wrap: wrap; } +.zm-ab-head > .zm-badge { margin-left: auto; } +.zm-ab-title { flex: 1 1 260px; min-width: 0; } +.zm-ab-title h3 { margin: 2px 0 2px 0; font-size: 17px; } +.zm-ab-title .zm-hint { margin-top: 0; } +.zm-ab-icon { + width: 44px; height: 44px; border-radius: 13px; flex-shrink: 0; + display: grid; place-items: center; color: #fff; + background: linear-gradient(135deg, #5fd8ff 0%, #1aa3ff 50%, #0a7cff 100%); + box-shadow: 0 8px 18px -8px rgba(10,124,255,.8); +} +.zm-ab-hero .zm-grid { margin-top: 16px; } +.zm-ab-go { display: inline-flex; align-items: center; gap: 8px; font-weight: 700; } +.zm-ab-go svg { width: 17px; height: 17px; } +.zm-ab-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; font-size: 12.5px; opacity: .8; border-top: 1px dashed rgba(0,0,0,.12); padding-top: 12px; margin-top: 4px; } +html.zm-theme-dark .zm-ab-foot { border-top-color: rgba(255,255,255,.12); } +.zm-ab-sum b { font-size: 15px; } +.zm-ab-sum-ok b { color: #1a7f37; } +.zm-ab-note { margin-top: 14px; border-radius: 10px; padding: 11px 14px; font-size: 13px; font-weight: 600; } +.zm-ab-note-warn { background: rgba(191,135,0,.12); color: #9a6700; border: 1px solid rgba(191,135,0,.3); } + +.zm-ab-steps { display: flex; gap: 6px; margin: 18px 0 4px; counter-reset: s; overflow-x: auto; padding-bottom: 2px; } +.zm-ab-step { flex: 1 1 0; min-width: 78px; display: flex; flex-direction: column; align-items: center; gap: 6px; position: relative; font-size: 12px; opacity: .55; } +.zm-ab-step::before { content: ""; position: absolute; top: 14px; left: calc(-50% + 18px); right: calc(50% + 18px); height: 2px; background: rgba(110,118,129,.3); border-radius: 2px; } +.zm-ab-step:first-child::before { display: none; } +.zm-ab-step-dot { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-weight: 700; font-size: 12.5px; background: rgba(110,118,129,.14); color: inherit; } +.zm-ab-step-label { white-space: nowrap; font-weight: 600; } +.zm-ab-step.zm-ab-done { opacity: 1; } +.zm-ab-step.zm-ab-done .zm-ab-step-dot { background: #1a7f37; color: #fff; } +.zm-ab-step.zm-ab-done::before, .zm-ab-step.zm-ab-now::before { background: #1a7f37; } +.zm-ab-step.zm-ab-now { opacity: 1; } +.zm-ab-step.zm-ab-now .zm-ab-step-dot { background: #1aa3ff; color: #fff; animation: zm-ab-pulse 1.6s ease-in-out infinite; } +@keyframes zm-ab-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(26,163,255,.55); } 50% { box-shadow: 0 0 0 7px rgba(26,163,255,0); } } + +.zm-ab-svc { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 9px; margin-top: 10px; } +.zm-ab-svc-item .zm-badge { flex-shrink: 0; } +.zm-ab-svc-item { + display: flex; align-items: center; justify-content: space-between; gap: 10px; min-width: 0; + border: 1px solid rgba(0,0,0,.08); border-radius: 10px; padding: 10px 12px; + background: var(--background-color-low, #fafafa); +} +html.zm-theme-dark .zm-ab-svc-item { background: #22272e; border-color: rgba(255,255,255,.1); } +.zm-ab-svc-item.zm-ab-svc-bad { border-color: rgba(207,34,46,.3); } +.zm-ab-svc-name { font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.zm-awg-if { border: 1px solid rgba(0,0,0,.1); border-radius: 12px; padding: 14px 16px; margin-top: 12px; background: var(--background-color-low, #fafafa); } +html.zm-theme-dark .zm-awg-if { background: #22272e; border-color: rgba(255,255,255,.1); } +.zm-awg-if .zm-actions { margin-top: 10px; } + +.zm-st-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 16px; } +.zm-st-stat { display: flex; flex-direction: column; gap: 4px; min-width: 0; padding: 11px 14px; border-radius: 11px; border: 1px solid rgba(0,0,0,.08); background: var(--background-color-low, #fafafa); } +html.zm-theme-dark .zm-st-stat { background: #22272e; border-color: rgba(255,255,255,.1); } +.zm-st-stat-label { font-size: 11.5px; opacity: .65; text-transform: uppercase; letter-spacing: .04em; } +.zm-st-stat-value { font-size: 14px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.zm-st-ok { color: #1a7f37; } .zm-st-warn { color: #9a6700; } .zm-st-bad { color: #cf222e; } .zm-st-off { opacity: .6; } +.zm-st-promo { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 16px; } +.zm-st-promo > div { display: flex; flex-direction: column; gap: 3px; padding: 12px 14px; border-radius: 11px; background: rgba(26,163,255,.07); border: 1px solid rgba(26,163,255,.2); } +.zm-st-promo b { font-size: 14px; } .zm-st-promo span { font-size: 12px; opacity: .7; } +.zm-svc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; margin-top: 12px; } +.zm-svc { display: flex; align-items: center; gap: 11px; min-width: 0; padding: 10px 12px; border-radius: 12px; cursor: pointer; user-select: none; + border: 1px solid rgba(0,0,0,.1); background: var(--background-color-low, #fafafa); transition: border-color .15s, background .15s, transform .1s; } +html.zm-theme-dark .zm-svc { background: #22272e; border-color: rgba(255,255,255,.1); } +.zm-svc:hover { transform: translateY(-1px); border-color: #1aa3ff; } +.zm-svc.zm-svc-on { border-color: rgba(26,163,255,.6); background: rgba(26,163,255,.08); } +.zm-svc-ico { width: 34px; height: 34px; border-radius: 10px; flex-shrink: 0; display: grid; place-items: center; color: #fff; font-weight: 800; font-size: 12.5px; letter-spacing: -.02em; } +.zm-svc-text { display: flex; flex-direction: column; min-width: 0; flex: 1; } +.zm-svc-name { font-weight: 700; font-size: 13.5px; } +.zm-svc-sub { font-size: 11.5px; opacity: .6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.zm-switch { width: 38px; height: 22px; border-radius: 999px; background: rgba(110,118,129,.35); position: relative; flex-shrink: 0; transition: background .15s; } +.zm-switch > span { position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3); transition: left .15s; } +.zm-switch.zm-switch-on { background: #1aa3ff; } .zm-switch.zm-switch-on > span { left: 19px; } +.zm-st-apply .zm-hint { margin: 0; } +.zm-st-note-ok { background: rgba(26,127,55,.1); color: #1a7f37; border: 1px solid rgba(26,127,55,.25); } +.zm-st-note-bad { background: rgba(207,34,46,.08); color: #cf222e; border: 1px solid rgba(207,34,46,.25); } +.zm-st-checks { display: flex; flex-direction: column; gap: 7px; margin-top: 12px; } +.zm-st-check { display: flex; gap: 10px; align-items: flex-start; font-size: 13px; } +.zm-st-check > span:last-child { display: flex; flex-direction: column; min-width: 0; } +.zm-st-check-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; margin-top: 5px; } +.zm-st-check-ok .zm-st-check-dot { background: #1a7f37; } .zm-st-check-warn .zm-st-check-dot { background: #bf8700; } .zm-st-check-fail .zm-st-check-dot { background: #cf222e; } +.zm-st-check-why { font-size: 12px; opacity: .65; } +@media (max-width: 600px) { + .zm-st-stats, .zm-st-promo { grid-template-columns: 1fr; } + .zm-svc-grid { grid-template-columns: 1fr; } +} +.zm-ab-player video { display: block; width: 100%; max-height: 72vh; aspect-ratio: 16 / 9; object-fit: contain; border-radius: 12px; background: #000; } +.zm-ab-player .zm-actions { margin-bottom: 0; } +.zm-ab-logcard .zm-log { margin-top: 0; } +@media (max-width: 600px) { + .zm-ab-title { flex-basis: calc(100% - 58px); } + .zm-ab-head > .zm-badge { margin-left: 58px; } + .zm-ab-step { min-width: 40px; } + .zm-ab-step:not(.zm-ab-now) .zm-ab-step-label { visibility: hidden; } + .zm-ab-step-label { font-size: 11.5px; } + .zm-ab-svc { grid-template-columns: 1fr; } +} +.zm-st-tunnels { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; margin: 10px 0 14px; } +.zm-st-tunnel { border: 1px solid rgba(127,127,127,.25); border-radius: 12px; padding: 10px 14px; } +.zm-st-tunnel-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 6px; } +.zm-st-tunnel-name { font-weight: 600; } +.zm-nodes { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(180px, 100%), 1fr)); gap: 10px; margin-top: 6px; } +.zm-nodes > .zm-node { min-width: 0; } +.zm-node { + position: relative; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; + min-height: 64px; padding: 10px 12px; border-radius: 10px; cursor: pointer; box-sizing: border-box; + border: 1px solid rgba(0,0,0,.1); background: var(--background-color-low, #fafafa); + transition: border-color .15s, background .15s, transform .1s, box-shadow .15s; +} +html.zm-theme-dark .zm-node:not(.zm-active) { background: #22272e; border-color: rgba(255,255,255,.12); } +.zm-node:hover { border-color: #1a7f37; transform: translateY(-1px); } +.zm-node.zm-active { border-color: #1a7f37; background: rgba(26,127,55,.12); box-shadow: 0 0 0 2px rgba(26,127,55,.3); } +.zm-node.zm-node-dead { opacity: .6; } +.zm-node-name { font-weight: 600; font-size: 13px; line-height: 1.35; overflow-wrap: anywhere; } +.zm-node.zm-active .zm-node-name::before { content: none; } +.zm-node.zm-sec .zm-node-name::before { content: none; } +.zm-tabsplit { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 40px; margin: 4px 0 0; } +.zm-tabsplit > .zm-tabgrp { margin: 0; } +.zm-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-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 24px; + padding: 12px 16px; margin: 4px 0 10px; border-radius: 10px; + background: rgba(26,127,55,.06); border: 1px solid rgba(26,127,55,.18); +} +.zm-sub-title { font-weight: 700; font-size: 15px; overflow-wrap: anywhere; } +.zm-sub-facts { display: flex; flex-wrap: wrap; gap: 12px 24px; } +.zm-sub-fact { display: flex; flex-direction: column; gap: 3px; font-size: 12px; } +.zm-sub-fact > span { opacity: .6; } +.zm-sub-fact > b { font-size: 13px; font-variant-numeric: tabular-nums; } +.zm-quota { width: 140px; height: 5px; border-radius: 99px; background: rgba(0,0,0,.1); overflow: hidden; } +.zm-quota > i { display: block; height: 100%; border-radius: 99px; background: #1a7f37; } +.zm-quota.zm-quota-high > i { background: #cf222e; } +.zm-seg { display: inline-flex; padding: 3px; gap: 3px; border-radius: 9px; border: 1px solid rgba(0,0,0,.1); background: var(--background-color-low, #fafafa); } +html.zm-theme-dark .zm-seg { background: #22272e; border-color: rgba(255,255,255,.12); } +.zm-seg-item { padding: 5px 14px; border-radius: 7px; cursor: pointer; font-size: 13px; font-weight: 600; opacity: .7; transition: background .15s, opacity .15s; } +.zm-seg-item:hover { opacity: 1; } +.zm-seg-item.zm-active { opacity: 1; background: #1a7f37; color: #fff; cursor: default; } +.zm-sub-input { min-height: 72px !important; } +.zm-svc { min-height: 58px; box-sizing: border-box; } +.zm-svc-text { overflow: hidden; } +.zm-svc-name { overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; line-height: 1.25; overflow-wrap: anywhere; } +.zm-svc-text:has(.zm-svc-sub) .zm-svc-name { -webkit-line-clamp: 1; line-clamp: 1; } +.zm-svc-grid + .zm-hint, .zm-svc-groupwrap + .zm-hint { margin-top: 12px; } +.zm-svc-groupwrap { margin-top: 12px; } +.zm-svc-groupwrap > div:last-child > .zm-svc-grid { margin-top: 10px; } +@media (max-width: 600px) { .zm-svc-grid { gap: 8px; } .zm-svc { min-height: 50px; padding: 8px 10px; gap: 10px; } .zm-svc-ico { width: 30px; height: 30px; border-radius: 9px; font-size: 11.5px; } } +.zm-svc-group { width: 100%; } +.zm-svc-chev { display: inline-block; font-size: 20px; line-height: 1; opacity: .55; transition: transform .15s; } +.zm-svc-open .zm-svc-chev { transform: rotate(90deg); } +.zm-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; } +.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-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-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 14px; margin-top: 14px; } +.zm-si-box { border: 1px solid rgba(0,0,0,.1); border-radius: 12px; padding: 12px 14px; background: rgba(0,0,0,.02); min-width: 0; box-sizing: border-box; } +div > .zm-si-box { margin-top: 14px; } +.zm-si-grid > .zm-si-box { margin-top: 0; } +html.zm-theme-dark .zm-si-box { border-color: rgba(255,255,255,.12); background: rgba(255,255,255,.03); } +.zm-si-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px; } +.zm-si-head h4 { margin: 0; font-size: 14px; } +.zm-si-box .zm-row { padding: 5px 0; gap: 12px; } +.zm-si-box .zm-row > .zm-label { min-width: 150px; } +.zm-si-val { overflow-wrap: anywhere; } +@media (max-width: 600px) { .zm-si-box .zm-row > .zm-label { min-width: 110px; } } +.zm-si-pre { margin: 0; white-space: pre-wrap; word-break: break-all; font-size: 12px; max-height: 260px; overflow: auto; } +.zm-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.zm-chip { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; background: rgba(110,118,129,.14); } +.zm-chip .zm-dot { width: 7px; height: 7px; background: currentColor; } +.zm-chip-ok { background: rgba(46,160,67,.12); color: #1a7f37; } +.zm-chip-bad { background: rgba(207,34,46,.10); color: #cf222e; } +.zm-tt-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +@media (max-width: 800px) { .zm-tt-grid { grid-template-columns: 1fr; } } +.zm-tt-tile { border: 1px solid rgba(0,0,0,.1); border-radius: 12px; padding: 12px 14px; background: rgba(0,0,0,.02); display: flex; flex-direction: column; gap: 6px; min-width: 0; } +html.zm-theme-dark .zm-tt-tile { border-color: rgba(255,255,255,.12); background: rgba(255,255,255,.03); } +.zm-tt-tile.zm-tt-active { border-color: #2d5bff; box-shadow: 0 0 0 1px #2d5bff inset; } +.zm-tt-tile .zm-hint { margin: 0; flex: 1; } +.zm-tt-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-meta { margin: 10px 0 8px; } +.zm-tt-best { padding: 9px 12px; border-radius: 10px; margin-bottom: 10px; font-size: 13px; } +.zm-tt-best.zm-tt-good { background: rgba(46,160,67,.12); } +.zm-tt-best.zm-tt-mid { background: rgba(191,135,0,.12); } +.zm-tt-best.zm-tt-bad { background: rgba(207,34,46,.10); } +.zm-tt-list { display: flex; flex-direction: column; gap: 6px; } +.zm-tt-row { border: 1px solid rgba(0,0,0,.08); border-radius: 10px; background: rgba(0,0,0,.015); overflow: hidden; } +html.zm-theme-dark .zm-tt-row { border-color: rgba(255,255,255,.1); background: rgba(255,255,255,.025); } +.zm-tt-row-ctrl { border-style: dashed; } +.zm-tt-row-head { display: grid; grid-template-columns: 28px minmax(0, 1.3fr) minmax(60px, 1fr) 64px 100px 18px; align-items: center; gap: 10px; padding: 7px 10px; cursor: pointer; } +.zm-tt-row-head:hover { background: rgba(45,91,255,.06); } +.zm-tt-rank { font-size: 12px; opacity: .55; text-align: right; font-variant-numeric: tabular-nums; } +.zm-tt-name { font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.zm-tt-bar { height: 7px; border-radius: 99px; background: rgba(110,118,129,.18); overflow: hidden; } +.zm-tt-fill { display: block; height: 100%; border-radius: 99px; } +.zm-tt-fill.zm-tt-good { background: #2ea043; } .zm-tt-fill.zm-tt-mid { background: #d4a72c; } .zm-tt-fill.zm-tt-bad { background: #e5534b; } .zm-tt-fill.zm-tt-ctrl { background: #8b949e; } +.zm-tt-score { font-weight: 700; font-size: 13px; text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } +.zm-tt-score.zm-tt-good, .zm-tt-domhead.zm-tt-good { color: #1a7f37; } .zm-tt-score.zm-tt-mid { color: #9a6700; } .zm-tt-score.zm-tt-bad, .zm-tt-domhead.zm-tt-bad { color: #cf222e; } .zm-tt-score.zm-tt-ctrl { opacity: .7; } +.zm-tt-apply { padding: 3px 10px !important; font-size: 12px !important; min-height: 0 !important; } +.zm-tt-apply { justify-self: end; } +.zm-tt-apply-m { display: none; } +.zm-tt-chev { font-size: 18px; opacity: .5; transition: transform .15s; text-align: center; } +.zm-tt-chev.zm-tt-open { transform: rotate(90deg); } .zm-tt-chev.zm-tt-nochev { visibility: hidden; } +.zm-tt-doms { padding: 4px 12px 12px 48px; display: flex; flex-direction: column; gap: 10px; } +.zm-tt-domhead { font-size: 12px; font-weight: 700; margin-bottom: 6px; } +@media (max-width: 600px) { + .zm-tt-row-head { grid-template-columns: 22px minmax(0, 1fr) 56px 16px; } + .zm-tt-bar, .zm-tt-apply, .zm-tt-apply-ph { display: none; } + .zm-tt-doms { padding-left: 12px; } + .zm-tt-apply-m { display: block; } +} +.zm-alerts { display: flex; flex-direction: column; gap: 10px; } +.zm-alerts:empty { display: none; } +.zm-wrap > .zm-alerts { margin-bottom: 2px; }.zm-log-step { color: #a5b4c3; white-space: pre-wrap; } +.zm-log-step-ok { color: #3fb950; } +.zm-log-step-bad { color: #ff7b72; font-weight: 600; } +.zm-savebar { position: sticky; bottom: 12px; z-index: 6; display: flex; align-items: center; flex-wrap: wrap; gap: 10px 14px; margin-top: 16px; padding: 12px 14px 12px 16px; border-radius: 14px; background: 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; } } +/* после «Применить»: строка состояния вместо кнопок, затем короткое «Готово» */ +.zm-savebar-sub { display: block; margin-top: 2px; font-weight: 400; font-size: 12.5px; opacity: .72; } +.zm-savebar-spin { width: 16px; height: 16px; box-sizing: border-box; border-radius: 50%; border: 2px solid rgba(26,163,255,.25); border-top-color: #1aa3ff; animation: zm-savebar-spin .8s linear infinite; flex-shrink: 0; } +@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-savebar-spin, .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-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; } +.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; } +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; } +} +/* предупреждение про метку ts */ +@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-ts-more { margin-top: 12px; font-size: 13px; } +.zm-ts-more summary { cursor: pointer; font-weight: 600; } +.zm-ts-more p { margin: 6px 0 0; line-height: 1.55; opacity: .8; } +.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_INSTALLER_EOF +chmod 0644 '/www/luci-static/resources/view/zapret-manager/style.css' + +mkdir -p /www/luci-static/resources/view/zapret-manager +chmod 0755 /www/luci-static/resources/view/zapret-manager +cat > '/www/luci-static/resources/view/zapret-manager/system.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require view'; +'require zapret-manager.common as zm'; +var 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() { + zm.injectCss(); + return Promise.all([ zm.systemStatus(), zm.mirrorStatus(), zm.sysinfoGet().catch(function() { return {}; }) ]); + }, + + render: function(data) { + var sysData = data[0], mirrorData = data[1]; + var view = this; + var wrap = E('div', { 'class': 'zm-wrap' }); + var netEl = E('div', {}, [ E('p', { 'class': 'zm-hint' }, 'Нажмите «Проверить», чтобы протестировать IPv4/IPv6') ]); + + var toggleBusy = false; + function renderStatusCard(d) { + var card = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Система'), + E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Блокировка QUIC'), + zm.badge(d.quic_blocked === true, 'включена', 'выключена') + ]), + E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'IPv6 в Zapret'), + zm.badge(d.ipv6_enabled === true, 'включён', 'выключен') + ]), + E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Fix для Flow Offloading'), + zm.badge(d.flow_offloading_fix === true, 'применён', 'не применён') + ]), + E('div', { 'class': 'zm-actions' }, [ + E('button', { + 'class': d.quic_blocked ? 'cbi-button cbi-button-remove' : 'cbi-button', + 'click': function() { + if (toggleBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + toggleBusy = true; + zm.toast(d.quic_blocked ? 'Выключаем блокировку QUIC' : 'Включаем блокировку QUIC', 'warning'); + zm.systemToggleQuic().then(function(res) { + toggleBusy = false; + zm.toast(d.quic_blocked ? 'Блокировка QUIC выключена' : 'Блокировка QUIC включена', 'info'); + zm.systemStatus().then(refresh); + }).catch(function() { toggleBusy = false; }); + } + }, d.quic_blocked ? 'Выключить блокировку QUIC' : 'Включить блокировку QUIC'), + E('button', { + 'class': 'cbi-button', + 'click': function() { + if (toggleBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + toggleBusy = true; + zm.toast(d.ipv6_enabled ? 'Выключаем IPv6 в Zapret' : 'Включаем IPv6 в Zapret', 'warning'); + zm.systemToggleIpv6().then(function(res) { + toggleBusy = false; + if (res.error) { zm.toast(res.error, 'error'); return; } + zm.toast(res.ipv6_enabled ? 'IPv6 в Zapret включён' : 'IPv6 в Zapret выключен', 'info'); + zm.systemStatus().then(refresh); + }).catch(function() { toggleBusy = false; }); + } + }, d.ipv6_enabled ? 'Выключить IPv6 в Zapret' : 'Включить IPv6 в Zapret'), + E('button', { + 'class': 'cbi-button', + 'click': function() { + if (toggleBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + toggleBusy = true; + zm.toast(d.flow_offloading_fix ? 'Отключаем Fix Flow Offloading' : 'Применяем Fix Flow Offloading', 'warning'); + zm.systemToggleFlowOffloadingFix().then(function(res) { + toggleBusy = false; + if (res.error) { zm.toast(res.error, 'error'); return; } + zm.toast(res.flow_offloading_fix ? 'Fix для Flow Offloading применён' : 'Fix для Flow Offloading отключён', 'info'); + zm.systemStatus().then(refresh); + }).catch(function() { toggleBusy = false; }); + } + }, d.flow_offloading_fix ? 'Отключить Fix Flow Offloading' : 'Применить Fix Flow Offloading') + ]) + ]); + return card; + } + + function refresh(d) { + var newCard = renderStatusCard(d); + wrap.replaceChild(newCard, wrap.firstChild); + } + + wrap.appendChild(renderStatusCard(sysData)); + + var si = data[2] && data[2].sections && data[2].sections.length ? data[2] : null, siBusy = false; + var siLog = E('pre', { 'class': 'zm-log' }); + var siBody = E('div'); + var siActs = E('div', { 'class': 'zm-actions' }); + var siCard = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Системная информация'), + E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Роутер, сеть, Zapret, hosts, пакеты и доступность сайтов — одним отчётом. Займёт до минуты.'), + siActs, siLog, siBody + ]); + + function siState(v, st) { + if (st === 'ok') return E('span', { 'class': 'zm-badge zm-ok' }, [ E('span', { 'class': 'zm-dot' }), [ v ] ]); + if (st === 'bad') return E('span', { 'class': 'zm-badge zm-bad' }, [ E('span', { 'class': 'zm-dot' }), [ v ] ]); + if (st === 'warn') return E('span', { 'class': 'zm-badge zm-warn' }, [ E('span', { 'class': 'zm-dot' }), [ v ] ]); + return E('span', { 'class': 'zm-si-val' }, [ v ]); + } + + function siBox(title, body, badge) { + return E('div', { 'class': 'zm-si-box' }, [ E('div', { 'class': 'zm-si-head' }, [ E('h4', {}, [ title ]), badge || E([]) ]) ].concat(body)); + } + + function siText() { + var out = [ 'Zapret Manager — системная информация' + (si.ts ? ' (' + si.ts + ')' : '') ]; + (si.sections || []).forEach(function(s) { + out.push('', '== ' + s.title + ' =='); + s.rows.forEach(function(r) { out.push(r[0] + ': ' + r[1]); }); + }); + if (si.strategy) out.push('', '== Стратегия ==', si.strategy); + if (si.hosts && si.hosts.length) out.push('', '== Домены в hosts ==', si.hosts.join(', ')); + if (si.packages && si.packages.length) out.push('', '== Пакеты ==', si.packages.join(', ')); + if (si.sites && si.sites.length) { + out.push('', '== Доступность сайтов: ' + si.sites_ok + '/' + si.sites_total + ' =='); + si.sites.forEach(function(x) { out.push((x.ok ? '[ OK ] ' : '[FAIL] ') + x.name); }); + } + return out.join('\n'); + } + + function siCopy() { + var txt = siText(); + var done = function() { zm.toast('Отчёт скопирован', 'info'); }; + if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(txt).then(done, fallback); return; } + fallback(); + function fallback() { + var ta = E('textarea', { 'style': 'position:fixed; left:-9999px; top:0' }); + ta.value = txt; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy'); done(); } catch (e) { zm.toast('Не удалось скопировать', 'error'); } + document.body.removeChild(ta); + } + } + + function siRender() { + siActs.innerHTML = ''; + siActs.appendChild(E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': siBusy ? '' : null, 'click': siRun }, siBusy ? 'Собираем…' : (si ? 'Собрать заново' : 'Собрать'))); + if (si && !siBusy) siActs.appendChild(E('button', { 'class': 'cbi-button', 'click': siCopy }, 'Скопировать отчёт')); + if (si && si.ts && !siBusy) siActs.appendChild(E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Собрано ' + si.ts)); + siBody.innerHTML = ''; + if (!si || siBusy) return; + var grid = E('div', { 'class': 'zm-si-grid' }); + (si.sections || []).forEach(function(s) { + grid.appendChild(siBox(s.title, s.rows.map(function(r) { + return E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, [ r[0] ]), siState(r[1], r[2]) ]); + }))); + }); + if (si.hosts && si.hosts.length) grid.appendChild(siBox('Домены в hosts', [ E('div', { 'class': 'zm-chips' }, si.hosts.map(function(h) { return E('span', { 'class': 'zm-chip' }, [ h ]); })) ])); + siBody.appendChild(grid); + if (si.strategy) siBody.appendChild(siBox('Стратегия Zapret', [ E('pre', { 'class': 'zm-si-pre' }, [ si.strategy ]) ])); + if (si.sites && si.sites.length) { + var ok = si.sites_ok, all = si.sites_total, cls = ok === all ? 'zm-ok' : ok * 2 >= all ? 'zm-warn' : 'zm-bad'; + siBody.appendChild(siBox('Доступность сайтов', [ E('div', { 'class': 'zm-chips' }, si.sites.map(function(x) { + return E('span', { 'class': 'zm-chip ' + (x.ok ? 'zm-chip-ok' : 'zm-chip-bad') }, [ E('span', { 'class': 'zm-dot' }), x.name ]); + })) ], E('span', { 'class': 'zm-badge ' + cls }, [ E('span', { 'class': 'zm-dot' }), [ ok + ' из ' + all ] ]))); + } + if (si.packages && si.packages.length) siBody.appendChild(siBox('Пакеты, поставленные вручную', [ E('div', { 'class': 'zm-chips' }, si.packages.map(function(p) { return E('span', { 'class': 'zm-chip' }, [ p ]); })) ], E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, [ String(si.packages.length) ]))); + } + + function siRun() { + if (siBusy) return; + siBusy = true; + siRender(); + zm.toast('Собираем информацию…', 'warning'); + zm.renderLog(siLog, '==> Запускаем'); + siLog.classList.add('zm-show'); + zm.sysinfoRun().then(function(res) { + if (res && res.error) { siBusy = false; siRender(); zm.toast(res.error, 'error'); return; } + zm.pollJob('sysinfo', siLog, function(ok) { + zm.sysinfoGet().then(function(r) { + siBusy = false; + if (r && r.sections && r.sections.length) si = r; + siLog.classList.remove('zm-show'); + siRender(); + zm.toast(ok ? 'Готово' : 'Не удалось собрать всё', ok ? 'info' : 'error'); + }).catch(function() { siBusy = false; siRender(); }); + }); + }).catch(function() { siBusy = false; siRender(); zm.toast('Роутер не ответил', 'error'); }); + } + + siRender(); + wrap.appendChild(siCard); + + var 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; + 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)); + 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 ? ' · выбран' : '')) ]); + }))); + } + } + if (timeData.time) { clockBase = timeData.time; clockAt = Date.now(); } + renderTime(); + wrap.appendChild(timeCard); + setTimeout(tick, 1000); + + var netBusy = false; + var netCard = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Проверка сети'), + netEl, + E('div', { 'class': 'zm-actions' }, [ + E('button', { + 'class': 'cbi-button', + 'click': function() { + if (netBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + netBusy = true; + netEl.textContent = 'Проверяем'; + zm.toast('Проверяем IPv4/IPv6', 'warning'); + zm.systemCheckConnectivity().then(function(res) { + netBusy = false; + netEl.innerHTML = ''; + netEl.appendChild(E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'IPv4 (google.com)'), + zm.badge(res.ipv4_ok === true, 'доступен, ' + res.ipv4_ms + ' мс', 'недоступен') + ])); + 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, 'доступен, ' + res.ipv6_ms + ' мс', 'недоступен') + ])); + if (noDns) netEl.appendChild(E('p', { 'class': 'zm-hint' }, 'IPv6 на роутере работает, но DNS роутера не отдаёт IPv6-адреса сайтов — устройства ходят на сайты по IPv4. Включать IPv6 в Zapret не нужно.')); + }).catch(function() { netBusy = false; }); + } + }, 'Проверить IPv4 / IPv6') + ]) + ]); + wrap.appendChild(netCard); + + var mirrorGrid = E('div', { 'class': 'zm-grid' }); + var mirrorCurrentEl = E('span', {}, mirrorData.current); + var mirrorBusy = false; + MIRRORS.forEach(function(m) { + mirrorGrid.appendChild(E('div', { + 'class': 'zm-tile' + (mirrorData.current === m.label ? ' zm-active' : ''), + 'click': function() { + if (mirrorBusy) { zm.toast('Дождитесь завершения переключения зеркала', 'warning'); return; } + if (mirrorData.current === m.label) { zm.toast('Это зеркало уже выбрано', 'info'); return; } + mirrorBusy = true; + zm.toast('Переключаем зеркало на «' + m.label + '»', 'warning'); + mirrorLog.classList.add('zm-show'); + zm.renderLog(mirrorLog, '==> Проверяем и переключаем'); + zm.mirrorSet(m.id).then(function(res) { + if (res.error) { mirrorBusy = false; zm.toast(res.error, 'error'); return; } + zm.pollJob('mirror_set', mirrorLog, function(ok) { + mirrorBusy = false; + zm.toast(ok ? ('Зеркало переключено на «' + m.label + '»') : 'Не удалось переключить зеркало', ok ? 'info' : 'error'); + if (!ok) return; + zm.mirrorStatus().then(function(res2) { + mirrorData.current = res2.current; + mirrorCurrentEl.textContent = res2.current; + Array.prototype.forEach.call(mirrorGrid.children, function(el, i) { + el.classList.toggle('zm-active', MIRRORS[i].label === res2.current); + }); + }); + }); + }).catch(function() { mirrorBusy = false; }); + } + }, m.label)); + }); + /* Поиск новых версий: раз в день (роутер сам, ночью), при открытии Дашборда или только кнопкой */ + var 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'); + 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); + 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' ? 'Новые версии роутер будет искать сам раз в день, около 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' + ? 'Панель сама в интернет не ходит: новые версии проверяются только кнопкой «Проверить» на Дашборде или здесь.' + : 'Роутер сам проверяет новые версии раз в сутки, ночью около 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' ? ' · следующая — около 04:' + mm : '') + : 'ещё не было' + (mode === 'day' ? ' · первая — около 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(); + zm.versions('').then(function(r) { verD = r || {}; renderVer(); verPoll(); }).catch(function() { verD = { mode: 'day' }; renderVer(); }); + wrap.appendChild(verCard); + + 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 + ]); + wrap.appendChild(mirrorCard); + + 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 cleanCard = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Очистка Zapret Manager'), + E('p', { 'class': 'zm-hint' }, 'Удаляет с роутера временные файлы и мусор панели: журналы операций, результаты тестов стратегий Zapret и ByeTube, недокачанные архивы, кэш списков невыбранных сервисов, сведения о новых версиях, собранную системную информацию и остатки прошлых версий. Настройки, установленные программы и списки, которыми пользуются правила Steer и Splify 2, не трогает.'), + E('div', { 'class': 'zm-actions' }, [ + E('button', { + 'class': 'cbi-button cbi-button-action', + 'click': 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'); + }); + } + }, 'Очистить') + ]), + cleanLog + ]); + wrap.appendChild(cleanCard); + + var uninstallLog = E('pre', { 'class': 'zm-log' }); + var uninstallCard = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Удалить Zapret Manager'), + E('p', { 'class': 'zm-hint' }, + 'Уберёт саму панель — приложение в LuCI и Web UI. Всё, что ставилось через неё (Zapret, Steer, Forkozz, ' + + 'Mixomo и остальное), останется на роутере и продолжит работать. Остановится то, что делает сама панель: ' + + 'блокировка рекламы, автопереключение DoH, ночной автоподбор стратегии и автообновления. Настройки панели ' + + 'можно сохранить — при повторной установке всё вернётся само.' + ), + E('div', { 'class': 'zm-actions' }, [ + E('button', { + 'class': 'cbi-button cbi-button-remove', + 'click': 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); + }); + }); + } + }, 'Удалить панель') + ]), + uninstallLog + ]); + wrap.appendChild(uninstallCard); + + /* «Спасибо»: сверху благодарность авторам и всем пользователям, ниже по кнопке — авторы по разделам. + * g — раздел: zm (сама панель), block, vpn, tg, dns, other. */ + 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: '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: '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-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: 'zapret.moe (DoH)', author: 'zapret.moe', url: 'https://zapret.moe' }, + { 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 others = CREDITS.filter(function(c) { return c.g !== 'zm'; }); + 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); + wrap.appendChild(creditsCard); + + return wrap; + } +}); +ZM_INSTALLER_EOF +chmod 0644 '/www/luci-static/resources/view/zapret-manager/system.js' + +mkdir -p /www/luci-static/resources/view/zapret-manager +chmod 0755 /www/luci-static/resources/view/zapret-manager +cat > '/www/luci-static/resources/view/zapret-manager/tgproxy.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require view'; +'require zapret-manager.common as zm'; +var 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 view = this; + var data = all[0]; + var tgwsData = all[1] || {}; + var wrap = E('div', { 'class': 'zm-wrap' }); + var cards = E('div', { 'class': 'zm-cards zm-cards-2' }); + var linksWrap = E('div', {}); + var logEl = E('pre', { 'class': 'zm-log' }); + + function statusOf(d, id) { + if (id === 'mtproto') return { installed: d.mtproto === 'installed', running: d.mtproto_running === true, secret: d.secret_mtproto ? 'dd' + d.secret_mtproto : '', version: d.mtproto_version, latest: d.mtproto_latest, newer: d.mtproto_newer === true }; + if (id === 'socks5') return { installed: d.socks5 === 'installed', running: d.socks5_running === true, secret: '', version: d.socks5_version, latest: d.socks5_latest, newer: d.socks5_newer === true }; + /* secret_rust приходит уже с dd/ee-префиксом; у установки пакетом порт и адрес — из его настроек */ + 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 }; + } + + 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) { + 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.uci ? E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Порт'), E('span', {}, [ String(st.port || '') ]) ]) : E([]), + pkgNote || E([]), + E('div', { 'class': 'zm-actions' }, actions) + ])); + }); + if (tgwsCard) cards.appendChild(tgwsCard); + } + + function doAction(variantId, action) { + if (busyMap[variantId]) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + busyMap[variantId] = true; + var job = action === 'remove' ? ('tg_remove_' + (variantId === 'mtproto' ? 'mtproto' : variantId)) + : ('tg_install_' + (variantId === 'mtproto' ? 'mtproto' : variantId)); + zm.toast((action === 'remove' ? 'Удаляем…' : action === 'reinstall' ? 'Переустанавливаем…' : action === 'update' ? 'Обновляем…' : 'Устанавливаем…'), 'warning'); + zm.tgAction(variantId, action).then(function(res) { + if (res.error) { busyMap[variantId] = false; zm.toast(res.error, 'error'); return; } + if (res.started) { + zm.pollJob(job, logEl, function(ok) { + busyMap[variantId] = false; + zm.toast(ok ? 'Готово' : 'Не получилось — причина в журнале ниже', ok ? 'info' : 'error'); + zm.tgStatus().then(function(d) { data = d; renderCards(d); renderLinks(d); renderAuto(); }); + }); + } else { + busyMap[variantId] = false; + } + }).catch(function() { busyMap[variantId] = false; }); + } + + renderLinks(data); + renderCards(data); + wrap.appendChild(linksWrap); + wrap.appendChild(cards); + wrap.appendChild(logEl); + + var tgwsCard = E('div', { 'class': 'zm-card' }); + var tgwsBusy = false; + + function renderTgws(d) { + var installed = d.installed === 'installed'; + var actions = []; + if (installed) { + actions.push(E('button', { + 'class': 'cbi-button cbi-button-remove', + 'click': function() { + 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: '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' ] } + ], + dot: [ + { id: 'quad9', name: 'Quad9', value: 'dns.quad9.net', boot: '9.9.9.9', alias: [ '9.9.9.9' ] }, + { id: 'cloudflare', name: 'Cloudflare', value: '1.1.1.1', boot: '1.1.1.1', alias: [ 'one.one.one.one', '1dot1dot1dot1.cloudflare-dns.com' ] }, + { id: 'google', name: 'Google', value: 'dns.google', boot: '8.8.8.8', alias: [ '8.8.8.8' ] }, + { id: 'yandex', name: 'Яндекс', value: 'common.dot.dns.yandex.net', boot: '77.88.8.8' }, + { id: 'yandex_safe', name: 'Яндекс Безопасный', value: 'safe.dot.dns.yandex.net', boot: '77.88.8.88' }, + { id: 'yandex_family', name: 'Яндекс Семейный', value: 'family.dot.dns.yandex.net', boot: '77.88.8.7' }, + { id: 'comss', name: 'Comss', value: 'dns.comss.one', boot: '77.88.8.8' } + ], + udp: [ + { id: 'quad9', name: 'Quad9', value: '9.9.9.9', boot: '9.9.9.9' }, + { id: 'cloudflare', name: 'Cloudflare', value: '1.1.1.1', boot: '1.1.1.1' }, + { id: 'google', name: 'Google', value: '8.8.8.8', boot: '8.8.8.8' }, + { id: 'yandex', name: 'Яндекс', value: '77.88.8.8', boot: '77.88.8.8' }, + { id: 'yandex_safe', name: 'Яндекс Безопасный', value: '77.88.8.88', boot: '77.88.8.88' }, + { id: 'yandex_family', name: 'Яндекс Семейный', value: '77.88.8.7', boot: '77.88.8.7' } + ] +}; + +var DNS_DEF = { doh: [ 'https://dns.quad9.net/dns-query', 'https://common.dot.dns.yandex.net/dns-query' ], dot: [ 'dns.quad9.net', 'common.dot.dns.yandex.net' ], udp: [ '9.9.9.9' ] }; +var DNS_MAX = 8; + +var DNS_TYPES = { udp: 'UDP', dot: 'DoT', doh: 'DoH' }; + +var DNS_HELP = { + udp: { ph: '9.9.9.9', hint: 'Обычный DNS без шифрования — только IP-адрес. Провайдер видит и может подменять ответы.' }, + dot: { ph: 'dns.quad9.net', hint: 'DNS over TLS: адрес сервера без https://, например dns.quad9.net или 9.9.9.9.' }, + doh: { ph: 'https://dns.quad9.net/dns-query', hint: 'DNS over HTTPS: ссылка https://… или адрес сервера. Голый адрес (dns.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 +}; + +/* Свой DoH-сервер: голый адрес (dns.example.com) — стандартный путь /dns-query; ссылка https://сервер без пути — + * корень «/» (иначе sing-box сам подставит /dns-query); адрес со своим путём остаётся как есть. */ +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, block_doh: false, log_level: 'panic', yacd: false, yacd_wan: false, yacd_secret: '' }; + +/* Дополнительные опции секции (SEC_EXT) и общие (SET_EXT) живут в черновике плоско, рядом с остальными. */ +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' } +]; +var SB_NAMES = { tiny: 'облегчённый', stable: 'обычный', extended: 'расширенный', lite: 'расширенный облегчённый', compact: 'расширенный компактный' }; +var SUB_IV = [ { id: '1h', label: '1 ч' }, { id: '6h', label: '6 ч' }, { id: '12h', label: '12 ч' }, { id: '1d', label: '24 ч' } ]; +var LIST_IV = [ { id: '3h', label: '3 ч' }, { id: '12h', label: '12 ч' }, { id: '1d', label: '24 ч' }, { id: '3d', label: '3 дня' } ]; +var LINK_RE = /^(vless|vmess|trojan|ss|socks4a?|socks5|hysteria2|hy2):\/\/\S+$/i; +var DOMAIN_RE = /^\.?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; + +function domainProblem(v) { + var raw = v, d = String(v || '').trim().toLowerCase().replace(/^full:/, '').replace(/^\*\./, ''); + if (/^(keyword|regex|domain_keyword|domain_regex):/.test(d)) return '«' + raw + '» — keyword: и regex: 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 пускайте только через что-то одно.' +}; + +var ACT_TEXT = { + install: 'Устанавливаем Forkozz', update: 'Обновляем Forkozz', reinstall: 'Переустанавливаем Forkozz', remove: 'Удаляем Forkozz', stop: 'Выключаем Forkozz', + start: 'Включаем Forkozz', restart: 'Перезапускаем Forkozz', apply: 'Применяем настройки', lists: 'Обновляем списки сервисов', + subs: 'Обновляем серверы подписки', 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: 'Серверы подписки обновлены', 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 fmtDate(sec) { + var d = new Date((+sec || 0) * 1000); + if (isNaN(d.getTime()) || !(+sec > 0)) return ''; + return ('0' + d.getDate()).slice(-2) + '.' + ('0' + (d.getMonth() + 1)).slice(-2) + '.' + d.getFullYear(); +} + +function tokLines(t) { + var out = []; + textLines(t).forEach(function(l) { l.replace(/\s+(#|\/\/).*$/, '').split(/[\s,;]+/).forEach(function(w) { if (w) out.push(w); }); }); + return out; +} + +function textLines(t) { + return String(t || '').split(/\r?\n/).map(function(l) { return l.trim(); }).filter(function(l) { return l && !/^(#|\/\/)/.test(l); }); +} + +function pageLink(id) { + return document.body.classList.contains('zmw-body') ? '#/' + id : L.url('admin/services/zapret-manager/' + id); +} + +function fromCfg(c) { + c = c || {}; + var dns = c.dns || {}, pick = pickFromCfg(c); + 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) { + return (x && typeof x === 'object' && !x.error && x.dns) ? x : null; +} + +function sig(d) { + var srt = function(a) { return (a || []).slice().sort(); }; + var tl = function(v) { return String(v || '').split(/\n/).map(function(x) { return x.trim(); }).filter(Boolean).join('\n'); }; + return JSON.stringify([ 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]); + var draft = withBootMode(fromCfg(cfg)), savedSig = sig(draft), dirty = false, busy = false, lastAct = '', saving = false, barKey = null, 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 = ''; + 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; + } + + var taLinks = area('links', 'vless://…\nss://…\ntrojan://…', 130); + var linksEd = zm.linksEditor({ value: draft.links, re: LINK_RE, kinds: 'vless, vmess, trojan, ss, socks5, hysteria2', placeholder: 'vless://…\nss://…\ntrojan://…', + empty: 'Серверов пока нет — вставьте ссылки ниже и нажмите «Добавить».', + busy: function() { return busy || saving; }, + onChange: function(v) { draft.links = v; taLinks.value = v; touch(); renderLinkCount(); } }); + var taSub = area('sub', 'https://…/sub/…\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)$/.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.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 && st.legacy) return badge('zm-warn', st.running ? 'работает на прежнем движке' : 'нужен переход на новый движок'); + 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 ? 'проверяем…' : 'нет данных'); + } + + /* Метки сервисов: до SVC_SHOW штук, остальные — под «ещё N», раскрываются прямо в карточке */ + 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) + ]); + } + + /* «Общее» — наверху на всю ширину: состояние, трафик всего Forkozz и всё, что действует для всех секций */ + function topBox() { + var bp = cfg.bypass || {}, bn = (bp.domains || []).length + (bp.subnets || []).length, e = (cfg.excl || []).length; + var sb = st.singbox ? 'sing-box ' + st.singbox.replace(/-extended.*$/, '') : 'sing-box не установлен'; + var dn = cfg.dns ? dnsLabel(cfg.dns) : '—', dt = cfg.dns ? (DNS_TYPES[cfg.dns.type] || '') + (cfg.dns.detour ? ' · через VPN' : '') : ''; + 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 || '—') + ' · ' + sb), + live ? info('Память sing-box', sbMem) : E([]) + ]) + ]); + } + + function rate(b) { return fmtBytes(Math.max(0, Math.round(b))) + '/с'; } + + /* Трафик через sing-box раз в 3 с, пока страница открыта и Forkozz работает. Скорость — по разнице счётчиков. */ + 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()); + /* колонок столько, чтобы ряды были полными: 1, 2, 3; четыре секции — 2×2 */ + 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 (st.foreign && !busy) { + mainCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show', 'style': 'margin-top:14px' }, [ + E('span', {}, 'Стоит старая версия с LuCI и сторонними зеркалами. Перевод на Zapret Manager уберёт их и сохранит настройки.'), + E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { act('install'); } }, 'Перевести') + ])); + } + + if (busy) { + mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Можно закрыть страницу — роутер всё доделает сам.')); + return; + } + + var b = []; + if (!st.installed && st.legacy) { + mainCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show', 'style': 'margin:4px 0 12px' }, [ + E('span', {}, 'Forkozz переходит на новый движок. Подключения, секции, списки, свои домены, исключения, устройства и DNS перенесутся сами. Займёт около минуты.'), + E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { act('install'); } }, 'Перейти на новый движок') + ])); + mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Пока перехода не было, Forkozz работает на прежнем движке как раньше, но настройки здесь не меняются. keyword: и regex: в своих доменах новый движок не понимает — такие строки при переходе пропустятся, а журнал покажет какие.')); + return; + } + if (!st.installed) { + b.push(E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { act('install'); } }, 'Установить')); + mainCard.appendChild(E('div', { 'class': 'zm-actions' }, b)); + mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Около минуты. Нужно ~15 МБ свободной памяти. Всё скачивается только из официальных источников: GitHub и репозиторий OpenWrt. DNS по умолчанию — Quad9 (9.9.9.9).' + (st.saved ? ' Прежние настройки вернутся.' : ''))); + if (st.singbox) mainCard.appendChild(sbLeft()); + return; + } + if (st.enabled) { + b.push(E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { act('restart'); } }, 'Перезапустить')); + b.push(E('button', { 'class': 'cbi-button', 'click': function() { act('stop'); } }, 'Выключить')); + } else { + b.push(E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': st.blocker ? '' : null, 'title': st.blocker ? (st.blocker_text || '') : '', 'click': function() { + if (!configured()) { tabTo('conn'); zm.toast('Сначала настройте подключение и сервисы', 'warning'); return; } + if (dirty) { save(true); return; } + act('start'); + } }, 'Включить')); + } + if (st.newer) b.push(E('button', { 'class': 'cbi-button cbi-button-action', 'click': function() { act('update'); } }, 'Обновить до ' + st.latest)); + 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; } + barKey = 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 !LINK_RE.test(x); }).length; + linkCountEl.innerHTML = ''; + if (!l.length) { linkCountEl.appendChild(badge('zm-off', 'ссылок нет')); return; } + linkCountEl.appendChild(bad ? badge('zm-warn', 'не распознано: ' + bad) : badge('zm-ok', nn(l.length, 'сервер', 'сервера', 'серверов'))); + } + + function sw(on, label, hint, click) { + return E('div', { 'class': 'zm-row', 'style': 'cursor:pointer; align-items:flex-start; flex-wrap:nowrap; margin:12px 0', 'click': click }, [ + E('div', { 'class': 'zm-switch' + (on ? ' zm-switch-on' : ''), 'style': 'margin-top:1px' }, [ E('span') ]), + E('div', {}, [ E('div', { 'style': 'font-weight:600' }, label), hint ? E('div', { 'class': 'zm-hint', 'style': 'margin-top:2px' }, hint) : E([]) ]) + ]); + } + + function seg(items, cur, pick) { + return E('div', { 'class': 'zm-seg' }, items.map(function(it) { + return E('div', { 'class': 'zm-seg-item' + (it.id === cur ? ' zm-active' : ''), 'click': function() { if (it.id !== cur) pick(it.id); } }, it.label); + })); + } + + function node(name, foot, on, click, right, cls) { + return E('div', { 'class': 'zm-node' + (on ? ' zm-active' : '') + (cls ? ' ' + cls : ''), 'click': click }, [ + E('div', { 'class': 'zm-node-name' }, [ String(name) ]), + E('div', { 'class': 'zm-node-foot' }, [ E('span', {}, [ String(foot || '') ]), right || E('span') ]) + ]); + } + + /* Сворачиваемый блок «Дополнительно»: заголовок со сводкой включённого, содержимое по клику. */ + function 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' + ? 'Чем выше ссылка, тем она главнее. Пока в первой подписке есть рабочий сервер, работает только она. Если все её серверы отказали — включается следующая. Forkozz проверяет подписки раз в 2 минуты и сам вернётся к главной, когда она оживёт. Внутри подписки берётся сервер с меньшей задержкой. Ручной выбор сервера отключает этот порядок, пока не вернёте «Авто».' + : am === 'ping' + ? 'Forkozz сам замеряет пинг всех серверов и включает самый быстрый. Пока сервер отвечает — остаётся на нём и не прыгает туда-сюда (раз в 15 минут сверяет, нет ли заметно быстрее). Если сервер оборвался, Forkozz за 20–40 секунд переходит на самый быстрый из рабочих. Допуск ниже — на сколько быстрее должен быть другой сервер, чтобы уйти с работающего. Ручной выбор сервера отключает этот режим, пока не вернёте «Авто».' + : am === 'guard' + ? 'Как «По задержке», но Forkozz ещё каждые 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' }, 'Ссылки vless, vmess, trojan, ss, socks5, hysteria2 — можно сразу несколько, по одной на строку. Для серверов с XHTTP и VMess Forkozz сам поставит расширенный sing-box. После правок нажмите «Сохранить» внизу.')); + connCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Если серверов несколько, выбирать между ними — в карточке «Серверы» ниже.')); + } + else if (draft.mode === 'sub') { + connCard.appendChild(taSub); + connCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Ссылка на подписку от VPN-сервиса, обычно https://…/sub/…. Можно несколько — по одной на строку, серверы всех подписок соберутся в один список. Выбор сервера — в карточке «Серверы» ниже.')); + connCard.appendChild(row('Обновлять серверы', seg(SUB_IV, draft.sub_interval, function(v) { set('sub_interval', v); }))); + } + else { + var tl = (cfg && cfg.tunnels) || []; + if (draft.iface && !tl.some(function(t) { return t.name === draft.iface; })) tl = tl.concat([ { name: draft.iface, up: false, missing: true } ]); + if (tl.length) connCard.appendChild(E('div', { 'class': 'zm-nodes' }, tl.map(function(t) { + var up = t.up && !t.missing; + return node(t.name, t.missing ? 'не найден' : t.steer ? 'туннель Steer' : 'туннель', draft.iface === t.name, + function() { if (draft.iface !== t.name) set('iface', t.name); }, + E('span', { 'class': 'zm-lat ' + (up ? 'zm-lat-good' : 'zm-lat-bad') }, up ? 'поднят' : 'выключен')); + }))); + else connCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Туннелей WireGuard или AmneziaWG пока нет.')); + connCard.appendChild(E('p', { 'class': 'zm-hint' }, [ 'Создать туннель WARP или свой можно на вкладке ', E('a', { 'href': pageLink('awg') }, 'AmneziaWG'), '.' ])); + } + 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); + /* проверка через сервер дольше TCP — ждём её дольше */ + 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'); }); + } + + /* Выбор сервера: сразу показываем его выбранным (и в списке, и в карточке секции наверху), + * затем перечитываем у sing-box — список серверов и состояние секций. */ + 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)); }); + } + + /* Задержка сервера. Проверка «через сервер»: пока идёт — время TCP с «…»; порт открыт, а запрос + * через сервер не прошёл — «порт открыт, не пускает» (обычно сервер блокирует провайдер). */ + 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 && (sub.title || sub.used != null || sub.expire)) { + if (sub.title) srvCard.appendChild(row('Подписка', E('b', {}, [ subName(sub.title) ]))); + if (sub.used != null) srvCard.appendChild(row('Трафик', E('span', {}, fmtBytes(sub.used) + (sub.total ? ' из ' + fmtBytes(sub.total) : sub.unlimited ? ' · без лимита' : '')))); + if (sub.expire) srvCard.appendChild(row('Действует до', E('span', {}, fmtDate(sub.expire)))); + } + if (!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(); + })); + /* как проверять серверы: только TCP (по умолчанию) или через сервер */ + 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; } + + /* Домены списка пункта и «Найти сайт» — как на странице Steer: списки те же (каталог и списки + * сервисов), поэтому домены и поиск берутся у той же части роутера (steer_action svc_dom / svc_find). + * Включение — через карточку секции (те же правила «одна секция на сервис»), применение — «Сохранить». */ + 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'); } + /* домены, затем IP-подсети — частями, как читает роутер; в файле — два раздела с заголовками «#», + * их понимают и «Свой список», и большинство программ для списков */ + 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); }); + /* по адресу ищем, только если по домену ничего нет: сайты за Cloudflare и другими CDN делят адреса + * с тысячами чужих, и включать ради них «Cloudflare» — значит пустить через туннель всё подряд */ + 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', 'ничего') ])); + /* «Только для этих устройств» незаметен на вкладке «Устройства», а меняет всё: сервисы секции идут + * через VPN лишь с выбранных устройств, остальные открывают их напрямую. Показываем это прямо здесь. */ + 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' }, 'Один на строку, домен целиком: поддомены включаются сами (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-кэш или перезапустите браузер. Проверить, куда пойдёт сайт, можно в «Куда пойдёт запрос» на вкладке «Подключение».')); + } + + 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(); + })); + dnsCard.appendChild(sw(draft.block_doh, 'Не пускать приложения в обход DNS роутера', 'Прямые запросы к публичным DoH (Cloudflare, Google, Quad9, AdGuard, Яндекс и другим) будут закрыты — браузеры и приложения вернутся к DNS роутера, и их сайты пойдут по правилам Forkozz.', function() { set('block_doh', !draft.block_doh); })); + var ds = []; + if (draft.dns_ttl.trim() !== '60') ds.push('кэш ' + draft.dns_ttl.trim() + ' с'); + if (draft.dns_ecs.trim()) ds.push('ECS ' + draft.dns_ecs.trim()); + 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', '203.0.113.0/24', 220))); + box.appendChild(hint('Подсеть, которую DNS-сервер получит вместе с запросом, чтобы выдать ближайший к ней узел CDN. Обычно оставляют пустым.')); + })); + } + + 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', {}, 'Прочее')); + 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); })); + miscCard.appendChild(sw(draft.excl_ntp, 'Синхронизация времени — напрямую', 'Запросы NTP не пойдут через VPN: часы устройств сверяются с ближайшим сервером времени.', function() { set('excl_ntp', !draft.excl_ntp); })); + 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, has = !!st.singbox; + var curVar = vars.some(function(x) { return x.id === sv; }) ? sv : ''; + var restartNote = st.enabled ? 'Forkozz перезапустится — на полминуты выбранные сервисы пойдут напрямую.\n' : ''; + sbCard.appendChild(row('Версия', E('span', {}, has ? st.singbox + ' · ' + (SB_NAMES[sv] || 'установлен вручную') : 'не установлен'))); + sbCard.appendChild(row('Вариант', seg(vars, curVar, function(v) { + var it = vars.filter(function(x) { return x.id === v; })[0]; + 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); }); + }))); + 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' : 'Пакет скачается из репозитория OpenWrt заново.\n') + restartNote + 'Если не установится, останется прежний.').then(function(ok) { if (!ok) return; act('singbox_reinstall', curVar); }); + } }, curVar === 'extended' ? 'Переустановить (обновить сборку)' : 'Переустановить') ])); + } + 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 и сервер каждой секции.')); + /* как в Steer: строка на проверку — «ок / внимание / ошибка», что проверено и что делать */ + if (diag && diag.checks) { + var fails = diag.checks.filter(function(c) { return c.verdict === 'fail'; }).length, warns = diag.checks.filter(function(c) { return c.verdict === 'warn'; }).length; + checkCard.appendChild(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: 'ошибка' }; + diag.checks.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 : '') ]))); + }); + } + 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 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 + '|' + routeSource.trim()); }, + verdict: routeVerdict + }); + if (routeBox.parentNode === routeCard) return; + routeCard.innerHTML = ''; + routeCard.appendChild(E('h3', {}, 'Куда пойдёт запрос')); + routeCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Введите сайт или IP-адрес назначения. Если правила ограничены устройствами, укажите также IP устройства, с которого идёт запрос.')); + 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 { + /* тёмную тему определяем по настоящему цвету фона: bootstrap и другие темы объявляют свои + * переменные и в светлом, и в тёмном режиме, поэтому одного их наличия мало */ + 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; }; + /* фон бывает у обёртки темы, а не у body (argon и др.) — тогда выдаёт светлый цвет текста */ + 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; } +/* тёмные темы LuCI: те же значки, но цвета светлее — иначе зелёный и серый на тёмном фоне не читаются */ +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-grid-devices { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 9px; } +.zm-grid-devices .zm-tile { flex: none; min-width: 0; width: 100%; box-sizing: border-box; } + +.zm-tile { + flex: 0 1 auto; + min-width: 90px; + max-width: 100%; + border: 1px solid rgba(0,0,0,.1); + border-radius: 9px; + padding: 10px 16px; + cursor: pointer; + text-align: center; + font-size: 13px; + line-height: 1.35; + overflow-wrap: break-word; + transition: border-color .15s, background .15s, transform .1s; + background: var(--background-color-low, #fafafa); +} +html.zm-theme-dark .zm-tile:not(.zm-active):not(.zm-tile-off) { + background: #22272e; + border-color: rgba(255,255,255,.12); +} +.zm-tile:hover { border-color: #1a7f37; transform: translateY(-1px); } +.zm-tile.zm-active { + border-color: #1a7f37; background: rgba(26,127,55,.16); + font-weight: 700; color: #15803d; + box-shadow: 0 0 0 2px rgba(26,127,55,.35); +} +.zm-tile.zm-active::before { content: none; } + +.zm-tile.zm-tile-off { + border-color: rgba(207,34,46,.35); background: rgba(207,34,46,.08); + color: #cf222e; font-weight: 600; +} +.zm-tile.zm-tile-off::before { content: none; } +.zm-tile.zm-tile-off:hover { border-color: #cf222e; } +.zm-tile.zm-tile-pending { opacity: .55; border-style: dashed; cursor: not-allowed; } +.zm-tile.zm-tile-pending:hover { border-color: rgba(0,0,0,.1); transform: none; } + +.zm-log { + background: #0d1117; color: #e6edf3; + font-family: ui-monospace, "SF Mono", "Cascadia Code", Consolas, "Liberation Mono", monospace; + font-size: 13.5px; line-height: 1.7; + border: 1px solid rgba(255,255,255,.10); + border-radius: 10px; padding: 16px 18px; + white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; + max-height: 520px; min-height: 220px; + overflow-x: hidden; overflow-y: auto; + margin-top: 14px; display: none; + box-shadow: inset 0 0 0 1px rgba(0,0,0,.2); +} +.zm-log.zm-show { display: block; } +.zm-log { scrollbar-width: auto; scrollbar-color: auto; } +.zm-log::-webkit-scrollbar { width: 14px; } +.zm-log::-webkit-scrollbar-track { background: transparent; margin: 8px 0; } +.zm-log::-webkit-scrollbar-thumb { background: rgba(255,255,255,.2); border-radius: 10px; border: 4px solid transparent; background-clip: padding-box; min-height: 40px; } +.zm-log::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.34); background-clip: padding-box; } +@supports not selector(::-webkit-scrollbar) { .zm-log { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.25) transparent; } } +.zm-config-editor { scrollbar-width: auto; scrollbar-color: auto; } +.zm-config-editor::-webkit-scrollbar { width: 14px; height: 14px; } +.zm-config-editor::-webkit-scrollbar-button { display: none; } +.zm-config-editor::-webkit-scrollbar-track, .zm-config-editor::-webkit-scrollbar-corner, .zm-config-editor::-webkit-resizer { background: transparent; } +.zm-config-editor::-webkit-scrollbar-track { margin: 8px; } +.zm-config-editor::-webkit-scrollbar-thumb { background: rgba(255,255,255,.2); border-radius: 10px; border: 4px solid transparent; background-clip: padding-box; min-height: 40px; } +.zm-config-editor::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.34); background-clip: padding-box; } +@supports not selector(::-webkit-scrollbar) { .zm-config-editor { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.25) transparent; } } +.zm-log:empty::before { content: "Ожидание вывода..."; opacity: .4; } +.zm-log-bar { position: sticky; top: 16px; height: 0; z-index: 3; } +.zm-log-min { + position: absolute; right: -8px; top: -8px; width: 30px; height: 24px; padding: 0; margin: 0; + border: 1px solid rgba(255,255,255,.14); border-radius: 7px; background: rgba(255,255,255,.06); + color: #c9d1d9; font: 700 13px/1 ui-monospace, Consolas, monospace; cursor: pointer; + display: flex; align-items: center; justify-content: center; transition: background .15s, color .15s; +} +.zm-log-min:hover { background: rgba(255,255,255,.16); color: #fff; } +.zm-log.zm-log-collapsed { min-height: 0; max-height: none; overflow: hidden; padding-right: 48px; } +.zm-log.zm-log-collapsed > div:not(.zm-log-bar):not(:last-child) { display: none; } +.zm-log.zm-log-collapsed > div:last-child { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.zm-config-editor { + width: 100%; box-sizing: border-box; min-height: 420px; + background: #0d1117; color: #e6edf3; + font-family: ui-monospace, "SF Mono", "Cascadia Code", Consolas, "Liberation Mono", monospace; + font-size: 13px; line-height: 1.6; + border: 1px solid rgba(255,255,255,.1); border-radius: 10px; + padding: 14px 16px; margin: 10px 0; + white-space: pre; overflow: auto; resize: vertical; +} +html.zm-theme-dark .zm-config-editor { border-color: rgba(255,255,255,.14); } + +.zm-lists-intro { margin: 0 0 12px; } +.zm-list-card { padding: 0; margin-bottom: 12px; overflow: hidden; } +.zm-list-head { display: flex; align-items: center; gap: 14px; padding: 14px 20px; cursor: pointer; user-select: none; outline: none; transition: background .15s; } +@media (hover: hover) { .zm-list-head:hover { background: rgba(127,127,127,.06); } } +.zm-list-head:focus-visible { box-shadow: inset 0 0 0 2px rgba(34,158,217,.55); } +.zm-list-chev { flex: 0 0 22px; width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; background: rgba(127,127,127,.12); font-size: 16px; font-weight: 700; line-height: 1; transition: transform .2s; } +.zm-list-open .zm-list-chev { transform: rotate(90deg); } +.zm-list-titles { flex: 1 1 auto; min-width: 0; } +.zm-list-title { font-size: 15px; font-weight: 600; line-height: 1.3; } +.zm-list-path { margin-top: 3px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; opacity: .6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.zm-list-count { flex: 0 0 auto; min-width: 92px; justify-content: center; } +.zm-list-body { padding: 4px 20px 18px; border-top: 1px solid rgba(127,127,127,.15); } +.zm-list-hint { margin: 12px 0 10px; } +textarea.zm-list-editor { display: block; width: 100%; box-sizing: border-box; min-height: 320px; resize: vertical; white-space: pre; overflow-wrap: normal; overflow-x: auto; tab-size: 4; } +.zm-list-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 12px; } +.zm-list-actions .cbi-button { margin: 0; } +.zm-list-dirty { flex: 0 0 auto; font-size: 12px; white-space: nowrap; font-weight: 600; color: #9a6700; display: inline-flex; align-items: center; gap: 6px; } +.zm-list-dirty::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; } +html.zm-theme-dark .zm-list-dirty { color: #d29922; } + +.zm-cat { border: 1px solid rgba(127,127,127,.18); border-radius: 12px; margin-top: 10px; overflow: hidden; } +.zm-cat .zm-cat-head { padding: 12px 16px; } +.zm-cat-sub { margin-top: 2px; font-size: 12px; opacity: .6; } +.zm-cat-count { flex: 0 0 auto; } +.zm-cat-body { padding: 12px 16px 16px; border-top: 1px solid rgba(127,127,127,.15); } +.zm-cat-tools { display: flex; align-items: center; justify-content: flex-end; margin: -4px -6px 8px; } +.zm-cat-links { display: flex; gap: 4px; margin-left: auto; } +.zm-linkbtn { background: none; border: 0; padding: 6px 10px; border-radius: 8px; font: inherit; font-size: 13px; font-weight: 600; color: #2563eb; cursor: pointer; } +.zm-linkbtn:hover { background: rgba(37,99,235,.08); } +.zm-linkbtn[disabled] { opacity: .35; cursor: default; background: none; } +html.zm-theme-dark .zm-linkbtn { color: #6ea8fe; } +.zm-cat-grid { margin: 0; } +.zm-cat-src { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 16px; padding: 14px 16px; border: 1px dashed rgba(127,127,127,.3); border-radius: 12px; } +.zm-cat-src-info { flex: 1 1 260px; min-width: 0; } +.zm-cat-src-title { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 14px; } +.zm-cat-src-state { margin-top: 4px; font-size: 13px; opacity: .8; } +.zm-cat-src-url { margin-top: 2px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; opacity: .55; overflow-wrap: anywhere; } +.zm-cat-src-actions { margin: 0 !important; flex: 0 0 auto; } +@media (max-width: 600px) { .zm-cat-tools { justify-content: flex-start; } .zm-cat-src-actions { flex: 1 1 100%; } } +@media (max-width: 600px) { + .zm-list-head { padding: 12px 14px; gap: 10px; } + .zm-list-body { padding: 4px 14px 14px; } + .zm-list-count { min-width: 0; } + .zm-list-actions .cbi-button { flex: 1 1 100%; } + .zm-list-dirty { font-size: 0; gap: 0; } +} +@media (max-width: 600px) { + .zm-config-editor { font-size: 16px; min-height: 320px; } +} + +.zm-log-arrow { color: #56d4dd; font-weight: 700; } +.zm-log-msg-info { color: #e3c04a; } +.zm-log-msg-ok { color: #3fb950; font-weight: 600; } +.zm-log-msg-error { color: #ff7b72; font-weight: 600; } +.zm-log-msg-warn { color: #ffa657; } +.zm-log-code { color: #8b949e; } + +.zm-hint { font-size: 12px; opacity: .65; margin-top: 6px; line-height: 1.5; overflow-wrap: break-word; } + +#zm-toast-container { + position: fixed; top: 20px; right: 20px; z-index: 10000; + display: flex; flex-direction: column; gap: 14px; + max-width: 520px; overflow: hidden; pointer-events: none; +} +#zm-toast-container > .zm-toast { pointer-events: auto; flex-shrink: 0; } +.zm-toast { + display: flex; align-items: flex-start; gap: 14px; + background: #1c2128; color: #e6edf3; + padding: 24px 28px; border-radius: 16px; + box-shadow: 0 10px 40px rgba(0,0,0,.4); + font-size: 18px; line-height: 1.5; font-weight: 500; cursor: pointer; + opacity: 0; transform: translateX(24px); + transition: opacity .22s ease, transform .22s ease; + border-left: 6px solid #1a7f37; +} +.zm-toast-show { opacity: 1; transform: translateX(0); } +.zm-toast-error { border-left-color: #cf222e; } +.zm-toast-warning { border-left-color: #9a6700; } +.zm-toast-icon { flex-shrink: 0; width: 10px; height: 10px; border-radius: 50%; background: currentColor; align-self: center; } +.zm-toast-info .zm-toast-icon { color: #3fb950; } +.zm-toast-error .zm-toast-icon { color: #ff7b72; } +.zm-toast-warning .zm-toast-icon { color: #e3b341; } +.zm-toast-text { overflow-wrap: anywhere; } +.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 ? ', IPv6: ' + (st.ips6 || 0) : '') ]))); + var half = Math.ceil(items.length / 2); + var kids = [ E('div', { 'class': 'bt-cols' }, [ + E('div', { 'class': 'bt-col' }, items.slice(0, half)), + E('div', { 'class': 'bt-col' }, items.slice(half)) + ]) ]; + kids.push(E('div', { 'class': 'zm-actions' }, [ + cfg.enabled + ? btn('Выключить', 'cbi-button-remove', function() { + applyConfig({ enabled: 0 }, 'ByeTube выключен'); + }) + : btn('Включить', 'cbi-button-positive', function() { + applyConfig({ enabled: 1 }, 'ByeTube включён'); + }), + btn('Перезапустить', 'cbi-button-apply', function() { serviceAction('restart', 'Служба перезапущена'); }), + btn('Очистить наборы IP', '', function() { + if (guard()) return; + bt.flush().then(function() { + bt.toast('Наборы очищены. Перезапустите браузер или обновите DNS-кэш на клиентах.', 'info'); + refreshState(); + }); + }), + btn('Удалить', 'cbi-button-remove', removeByeTube) + ])); + fill(statusCard, kids); + + var quic = cfg.quic || 'block'; + if (!portDirty) portInput.value = String(cfg.byedpi_port || 1088); + + fill(optionsCard, [ + E('h3', {}, [ 'Настройки' ]), + row('QUIC (UDP/443)', E('div', { 'class': 'zm-grid' }, [ + tile('Блокировать', quic === 'block' ? 'zm-active' : '', function() { + if (quic !== 'block') applyConfig({ quic: 'block' }, 'QUIC блокируется'); + }), + tile('Через прокси', quic === 'proxy' ? 'zm-active' : '', function() { + if (quic !== 'proxy') applyConfig({ quic: 'proxy' }, 'QUIC идёт через прокси'); + }) + ])), + hint('Блокировка QUIC заставляет клиента быстро откатиться на TCP, который обрабатывает ByeDPI (рекомендуется).'), + row('IPv6', E('div', { 'class': 'zm-grid' }, [ + tile(cfg.ipv6 ? 'IPv6 включён' : 'IPv6 выключен', cfg.ipv6 ? 'zm-active' : 'zm-tile-off', function() { + applyConfig({ ipv6: cfg.ipv6 ? 0 : 1 }, cfg.ipv6 ? 'IPv6 выключен' : 'IPv6 включён'); + }) + ])), + row('Порт SOCKS5 ByeDPI', E('div', { 'class': 'zm-actions', 'style': 'margin:0' }, [ + portInput, + btn('Сохранить', 'cbi-button-positive', function() { + var v = String(portInput.value).trim(); + if (!/^\d+$/.test(v) || +v < 1 || +v > 65535) { + bt.toast('Порт должен быть числом от 1 до 65535', 'error'); + return; + } + portDirty = false; + applyConfig({ byedpi_port: v }, 'Порт сохранён'); + }) + ])), + hint('Локальный порт 127.0.0.1, отдельный экземпляр — штатная служба byedpi не затрагивается.') + ]); + } + + function currentPreset() { + return presets.find(cfg.byedpi_opts); + } + + function setStrategy(opts, label) { + if (guard()) return Promise.resolve(false); + busy = true; + bt.toast('Применяем стратегию', 'warning'); + return bt.setStrategy(opts).then(function(res) { + busy = false; + if (res.error) { bt.toast(res.error, 'error'); return false; } + bt.toast((label || 'Стратегия') + ' применена', 'info'); + refreshState(); + return true; + }, function() { busy = false; bt.toast('Роутер не ответил', 'error'); return false; }); + } + + var customTa = E('textarea', { + 'class': 'zm-config-editor', + 'spellcheck': 'false', + 'wrap': 'soft', + 'placeholder': 'Параметры ciadpi одной строкой', + 'style': 'min-height:130px;white-space:pre-wrap' + }, [ '' ]); + + function renderStrategy() { + var cur = currentPreset(); + fill(bannerEl, cfg.byedpi_opts + ? [ E('div', { 'class': 'bt-current-label' }, [ 'Сейчас применено' ]), E('div', { 'class': 'bt-current-cmd' }, cmdNodes(cfg.byedpi_opts)) ] + : [ E('span', {}, [ 'Стратегия ещё не выбрана' ]) ]); + bannerEl.className = 'zm-current-banner bt-current' + (cfg.byedpi_opts ? '' : ' zm-current-empty'); + + fill(presetsCard, [ + E('h3', {}, [ 'Готовые стратегии' ]), + E('div', { 'class': 'zm-grid' }, presets.list.map(function(p) { + return tile(p.label || p.name, cur && cur.id === p.id ? 'zm-active' : '', function() { + setStrategy(p.opts, p.label || p.name); + }, p.name + '\n' + p.opts); + })), + hint('Стратегия зависит от провайдера — лучшую под вашего провайдера найдёт вкладка «Тест стратегий».') + ]); + } + + var customBar = zm.saveBar({ saveLabel: 'Применить', onCancel: function() { customTa.value = ''; customBar.set(false); }, onSave: function() { + var v = presets.norm(customTa.value); + if (!v) { bt.toast('Стратегия не может быть пустой', 'error'); return 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 +# номер сборки — в common.js (сверка с build.txt) и в адрес style.css: новая сборка = новый адрес стилей +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'; + +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(); +} + +/* Жива ли сессия: rpcd мог на секунду не ответить (его перезапуск, перечитывание прав) — спрашиваем + * дважды с паузой, и только «сессии нет» в обоих ответах считаем истечением. */ +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; + /* «Нет доступа» бывает и при живой сессии — к отдельному объекту или пока rpcd перечитывает + * права (после установки и удаления пакетов). Выходим на вход, только если сессии правда нет. */ + 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) { + /* Web UI: меню LuCI тут ни при чём — «Обновить» перерисовывает раздел, без перезагрузки страницы и + * без нового входа. Плашка плавающая, как остальные. */ + 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: '', + palette: '', + refresh: '', + menu: '', + close: '', + external: '', + eye: '', + eyeOff: '', + user: '', + lock: '', + arrow: '', + alert: '', + fork: '', + route: '', + tunnel: '', + terminal: '', + anarchy: '', + rocket: '', + telegram: '' +}; + +function icon(name, cls) { + var s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + s.setAttribute('viewBox', '0 0 24 24'); + s.setAttribute('fill', 'none'); + s.setAttribute('stroke', 'currentColor'); + s.setAttribute('stroke-width', '1.8'); + s.setAttribute('stroke-linecap', 'round'); + s.setAttribute('stroke-linejoin', 'round'); + s.setAttribute('aria-hidden', 'true'); + s.setAttribute('class', 'zmw-i' + (cls ? ' ' + cls : '')); + s.innerHTML = ICONS[name] || ''; + return s; +} + +var logoSeq = 0; +function logo(cls) { + var id = 'zmwz' + (++logoSeq); + return E('div', { 'class': 'zmw-logo' + (cls ? ' ' + cls : '') }, [ + E('') + ]); +} + +function toast(message, kind, duration) { + var c = document.getElementById('zm-toast-container'); + if (!c) { c = E('div', { id: 'zm-toast-container' }); document.body.appendChild(c); } + var el = E('div', { 'class': 'zm-toast zm-toast-' + (kind || 'info') }, [ + E('span', { 'class': 'zm-toast-icon' }), + E('span', { 'class': 'zm-toast-text' }, [ message ]) + ]); + Array.prototype.slice.call(c.children).forEach(function (o) { if (o !== el && o._zmText === String(message)) o.remove(); }); + el._zmText = String(message); + c.appendChild(el); + if (window.zmToastFit) window.zmToastFit(); + requestAnimationFrame(function () { el.classList.add('zm-toast-show'); }); + var hide = function () { + el.classList.remove('zm-toast-show'); + setTimeout(function () { el.remove(); }, 250); + }; + el.addEventListener('click', hide); + setTimeout(hide, duration || (kind === 'error' ? 12000 : 6000)); +} + +var mqDark = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null; +var THEMES = [ + { id: 'micro', name: 'Светлая', ico: 'sun', fonts: 'Onest:wght@400;500;600;700', color: '#f4f5f8' }, + { id: 'light', name: 'Лёгкая', ico: 'sun' }, + { id: 'dark', name: 'Тёмная', ico: 'moon', dark: true }, + { id: 'ink', name: 'Контур', ico: 'ink' }, + { id: 'bento', name: 'Бенто', ico: 'bento', fonts: 'Onest:wght@400;500;600;700&family=Unbounded:wght@600;800', color: '#efebe4' }, + { id: 'minimal', name: 'Минимал', ico: 'minimal', fonts: 'IBM+Plex+Sans:wght@300;400;500', color: '#ffffff' }, + { id: 'retro', name: 'Ретро 98', ico: 'retro', color: '#008080' }, + { id: 'depth', name: 'Объём 3D', ico: 'depth', dark: true, fonts: 'Onest:wght@400;500;600;700&family=Unbounded:wght@600;800', color: '#0a0e18' } +]; +function themeById(id) { return THEMES.filter(function (x) { return x.id === id; })[0]; } +function themePref() { var t = sget(K_THEME); return themeById(t) ? t : 'auto'; } +var fontsFailed = false; +function themeFonts(t) { + if (fontsFailed || !t || !t.fonts || document.getElementById('zmw-font-' + t.id)) return; + var l = document.createElement('link'), done = false; + l.id = 'zmw-font-' + t.id; + l.rel = 'stylesheet'; + l.media = 'print'; + l.onload = function () { done = true; l.media = 'all'; }; + l.onerror = function () { done = true; fontsFailed = true; l.remove(); }; + l.href = 'https://fonts.googleapis.com/css2?family=' + t.fonts + '&display=swap'; + document.head.appendChild(l); + setTimeout(function () { if (!done) { fontsFailed = true; l.remove(); } }, 3000); +} +function themeEffective() { var p = themePref(); return p === 'auto' ? 'micro' : p; } +function applyTheme() { + var t = themeEffective(); + var h = document.documentElement; + var th = themeById(t) || THEMES[0]; + themeFonts(th); + h.setAttribute('data-theme', t); + h.classList.toggle('zm-theme-dark', !!th.dark); + h.setAttribute('data-zm-theme-checked', '1'); + var mc = document.querySelector('meta[name="theme-color"]'); + if (mc) mc.setAttribute('content', th.color || (t === 'dark' ? '#0a0c12' : t === 'ink' ? '#ffffff' : '#f3f5fa')); + var cur = th; + Array.prototype.forEach.call(document.querySelectorAll('.zmw-theme-toggle'), function (b) { + b.innerHTML = ''; + b.appendChild(icon('palette')); + b.title = 'Тема оформления: ' + cur.name; + b.setAttribute('aria-label', 'Тема оформления'); + b.setAttribute('aria-haspopup', 'menu'); + }); +} +var themeMenuEl = null; +function closeThemeMenu() { + if (!themeMenuEl) return; + themeMenuEl.remove(); + themeMenuEl = null; + document.removeEventListener('click', themeMenuOutside, true); + document.removeEventListener('keydown', themeMenuKey, true); +} +function themeMenuOutside(e) { if (themeMenuEl && !themeMenuEl.contains(e.target) && !(e.target.closest && e.target.closest('.zmw-theme-toggle'))) closeThemeMenu(); } +function themeMenuKey(e) { if (e.key === 'Escape') closeThemeMenu(); } +var themeDirty = false; +function themeSave(id) { + if (!sid || sid === NULL_SID) { themeDirty = true; return; } + ubus('zapret-manager', 'ui_theme_set', { theme: id }).then(function (r) { + themeDirty = !(r && r[0] === 0 && r[1] && r[1].ok); + if (r && r[0] === 6) toast('Тема не сохранилась на роутере — выйдите из панели и войдите снова', 'warning'); + }).catch(function () { themeDirty = true; }); +} +function themeSync() { + fetch('/zm/theme.txt?t=' + Date.now(), { cache: 'no-store', credentials: 'omit' }).then(function (r) { return r.ok ? r.text() : ''; }).then(function (t) { + t = String(t || '').trim(); + if (themeDirty) return; + if (!t) { if (sget(K_THEME)) themeSave(sget(K_THEME)); return; } + if (t !== 'auto' && !themeById(t)) return; + if (t === themePref() || (t === 'auto' && !sget(K_THEME))) return; + sset(K_THEME, t === 'auto' ? null : t, true); + applyTheme(); + }).catch(function () {}); +} +function setTheme(id) { + sset(K_THEME, id, true); + themeSave(id); + document.documentElement.classList.add('zmw-theme-anim'); + applyTheme(); + setTimeout(function () { document.documentElement.classList.remove('zmw-theme-anim'); }, 400); +} +if (mqDark && mqDark.addEventListener) mqDark.addEventListener('change', function () { if (themePref() === 'auto') applyTheme(); }); +function toggleTheme(ev) { + var btn = ev && ev.currentTarget; + if (themeMenuEl) { closeThemeMenu(); return; } + var cur = themeEffective(); + var items = [ E('div', { 'class': 'zmw-theme-group' }, [ 'Тема оформления' ]) ]; + THEMES.forEach(function (t) { items.push(themeOpt(t, cur)); }); + themeMenuEl = E('div', { 'class': 'zmw-theme-menu', 'role': 'menu' }, items); + function themeOpt(t, cur) { + return E('button', { + 'type': 'button', 'role': 'menuitemradio', 'aria-checked': t.id === cur ? 'true' : 'false', + 'class': 'zmw-theme-opt' + (t.id === cur ? ' zmw-on' : ''), + 'click': function (e) { e.stopPropagation(); closeThemeMenu(); if (t.id !== cur) setTheme(t.id); } + }, [ icon(t.ico), E('span', {}, [ t.name ]), E('span', { 'class': 'zmw-theme-mark' }) ]); + } + document.body.appendChild(themeMenuEl); + if (btn) { + var r = btn.getBoundingClientRect(); + themeMenuEl.style.top = Math.round(r.bottom + 8) + 'px'; + themeMenuEl.style.right = Math.max(8, Math.round(window.innerWidth - r.right)) + 'px'; + } + setTimeout(function () { + document.addEventListener('click', themeMenuOutside, true); + document.addEventListener('keydown', themeMenuKey, true); + }, 0); +} + +var ROUTES = [ + { id: 'dashboard', title: 'Дашборд', sub: 'Состояние всех компонентов', icon: 'dashboard', group: 'Обзор' }, + { id: 'strategy', title: 'Zapret', sub: 'Стратегии, тесты, YouTube, игры, Discord и списки', icon: 'shield', group: 'Обход блокировок', dot: 'zapret' }, + { id: 'zapret2', title: 'Zapret2', sub: 'Установка и управление Zapret2', icon: 'bolt', group: 'Обход блокировок', dot: 'zapret2' }, + { id: 'steer', title: 'Steer', sub: 'Выбранные сервисы через WARP или VPN', icon: 'route', group: 'Обход блокировок', dot: 'steer' }, + { id: 'forkozz', title: 'Forkozz', sub: 'Выбранные сервисы через ваш сервер, подписку или туннель', icon: 'fork', group: 'Обход блокировок', dot: 'forkop' }, + { id: 'bytetube', title: 'ByeTube', sub: 'YouTube через ByeDPI', icon: 'play', group: 'Обход блокировок', dot: 'bytetube' }, + { id: 'tgproxy', title: 'TG WS Proxy', sub: 'Прокси для Telegram', icon: 'send', group: 'Обход блокировок', dot: 'tg' }, + { id: 'mixomo', title: 'Mixomo', sub: 'Mihomo, MagiTrickle и WARP', icon: 'layers', group: 'Обход блокировок', dot: 'mixomo' }, + { id: 'hosts', title: 'Hosts', sub: 'Домены в hosts и списки GeoHide', icon: 'list', group: 'Сеть', dot: 'hosts' }, + { id: '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: 'terminal', title: 'Терминал', sub: 'Командная строка роутера в браузере (ttyd)', icon: 'terminal', group: 'Сеть', dot: 'term' }, + { id: 'system', title: 'Система', sub: 'Параметры роутера, зеркала и обслуживание', icon: 'cpu', group: 'Сервис' } +]; +var ROUTE_BY_ID = {}; +ROUTES.forEach(function (r) { ROUTE_BY_ID[r.id] = r; }); + +function currentRoute() { + var id = (location.hash || '').replace(/^#\/?/, '').split(/[/?]/)[0]; + return ROUTE_BY_ID[id] ? id : 'dashboard'; +} + +var root, shell = null, viewEl, titleEl, subEl, navLinks = {}, navDots = {}, deviceEl, verEl, updateEl, memEl; +var alertsEl = E('div', { 'class': 'zmw-alerts' }), alertsKey = ''; + +/* Терминал переживает переходы между разделами: его iframe живёт в viewEl постоянно и только прячется. + * Прячем без display:none и без смены размера — иначе ttyd сожмёт терминал до нуля колонок. */ +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() { + var n = viewEl.firstChild, next; + while (n) { next = n.nextSibling; if (n !== termKeep.host) viewEl.removeChild(n); n = next; } + viewEl.appendChild(alertsEl); +} + +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); + ROUTES.forEach(function (r) { + if (r.group !== lastGroup) { nav.appendChild(E('div', { 'class': 'zmw-nav-group' }, [ r.group ])); lastGroup = r.group; } + var dot = r.dot ? E('span', { 'class': 'zmw-nav-dot' }) : null; + var a = E('a', { 'class': 'zmw-nav-item', 'href': '#/' + r.id, 'data-route': r.id }, [ + E('span', { 'class': 'zmw-nav-ico' }, [ icon(r.icon) ]), + E('span', { 'class': 'zmw-nav-label' }, [ r.title ]), + dot + ]); + a.addEventListener('click', function () { closeDrawer(); }); + navLinks[r.id] = a; + if (dot) navDots[r.dot] = dot; + nav.appendChild(a); + }); + + /* одна строка: «Модель · OpenWrt 24.10.4»; архитектура и прочее — во всплывающей подсказке */ + 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, + E('div', { 'class': 'zmw-side-links' }, [ + E('a', { 'href': luciUrl(), 'target': '_blank', 'rel': 'noreferrer' }, [ icon('external'), 'Открыть LuCI' ]) + ]) + ]) + ]); + + titleEl = E('h1', { 'class': 'zmw-title' }, [ '' ]); + subEl = E('div', { 'class': 'zmw-sub' }, [ '' ]); + viewEl = E('main', { 'class': 'zmw-view', 'id': 'zmw-view' }); + + var themeBtn = E('button', { 'class': 'zmw-icon-btn zmw-theme-toggle', 'type': 'button', 'click': toggleTheme }); + var top = E('header', { 'class': 'zmw-top' }, [ + E('button', { 'class': 'zmw-icon-btn zmw-burger', 'type': 'button', 'aria-label': 'Меню', 'click': openDrawer }, [ icon('menu') ]), + E('div', { 'class': 'zmw-titles' }, [ titleEl, subEl ]), + E('div', { 'class': 'zmw-top-actions' }, [ + E('a', { 'class': 'zmw-icon-btn zmw-link-btn zmw-link-kvn', 'href': 'http://stresskvn.lol/', 'target': '_blank', 'rel': 'noreferrer', 'title': 'StressKVN — умный VPN для стабильного доступа в любых условиях.' }, [ + icon('rocket'), E('span', { 'class': 'zmw-lbl-full' }, [ E('span', { 'class': 'zmw-kvn-l1' }, [ 'StressKVN — лучший VPN' ]), E('span', { 'class': 'zmw-kvn-l2' }, [ 'обход белых списков · телефон, ПК, роутер' ]) ]), E('span', { 'class': 'zmw-lbl-short' }, [ 'StressKVN' ]) + ]), + E('a', { 'class': 'zmw-icon-btn zmw-link-btn zmw-link-tg', 'href': 'https://t.me/stressozz_manager', 'target': '_blank', 'rel': 'noreferrer', 'title': 'Сообщество Telegram' }, [ + icon('telegram'), E('span', { 'class': 'zmw-lbl-full' }, [ 'Сообщество Telegram' ]), E('span', { 'class': 'zmw-lbl-short' }, [ 'Telegram' ]) + ]), + E('span', { 'class': 'zmw-top-sep' }), + E('button', { 'class': 'zmw-icon-btn', 'type': 'button', 'title': 'Обновить страницу', 'click': function (ev) { + var b = ev.currentTarget; b.classList.add('zmw-spin'); setTimeout(function () { b.classList.remove('zmw-spin'); }, 700); + route(true); + } }, [ icon('refresh') ]), + themeBtn, + E('button', { 'class': 'zmw-icon-btn zmw-logout', 'type': 'button', 'title': 'Выйти', 'click': function () { logout(); } }, [ icon('logout'), E('span', {}, [ 'Выйти' ]) ]) + ]) + ]); + + shell = E('div', { 'class': 'zmw-shell' }, [ + side, + E('div', { 'class': 'zmw-main' }, [ top, viewEl, E('footer', { 'class': 'zmw-foot' }, [ '' ]) ]), + E('div', { 'class': 'zmw-scrim', 'click': closeDrawer }) + ]); + root.appendChild(shell); + applyTheme(); +} + +function luciUrl() { + return location.protocol + '//' + location.hostname + '/cgi-bin/luci/admin/services/zapret-manager/dashboard'; +} + +function openDrawer() { document.body.classList.add('zmw-drawer-open'); } +function closeDrawer() { document.body.classList.remove('zmw-drawer-open'); } + +var callStatus = declare({ object: 'zapret-manager', method: 'status', expect: {} }); +var callSysInfo = declare({ object: 'zapret-manager', method: 'system_info', expect: {} }); +var callHealth = declare({ object: 'zapret-manager', method: 'health', expect: {} }); +var callUpd = declare({ object: 'zapret-manager', method: 'zm_update_status', expect: {} }); +var callBoardInfo = declare({ object: 'system', method: 'info', expect: {} }); + +function parseSize(v) { + var m = String(v || '').trim().match(/^([\d.,]+)\s*([KMGT]?)i?B?$/i); + if (!m) return NaN; + var n = parseFloat(m[1].replace(',', '.')); + var mul = { '': 1, K: 1024, M: 1048576, G: 1073741824, T: 1099511627776 }[m[2].toUpperCase()]; + return n * mul; +} +function fmtSize(b) { + if (!isFinite(b)) return '—'; + var u = [ 'Б', 'КБ', 'МБ', 'ГБ', 'ТБ' ], i = 0; + while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; } + return (b >= 100 || i === 0 ? Math.round(b) : b.toFixed(1).replace(/\.0$/, '')) + ' ' + u[i]; +} +var memRows = {}; +function memRow(key, label, used, total) { + var pct = total > 0 ? Math.max(0, Math.min(100, used / total * 100)) : 0; + var r = memRows[key]; + if (!r) { + r = memRows[key] = { + el: E('div', { 'class': 'zmw-mem-row' }, [ + E('div', { 'class': 'zmw-mem-head' }, [ E('span', { 'class': 'zmw-mem-label' }, [ label ]), E('span', { 'class': 'zmw-mem-val' }) ]), + E('div', { 'class': 'zmw-mem-bar' }, [ E('i') ]) + ]) + }; + r.order = key === 'temp' ? -1 : key === 'ram' ? 0 : key === 'flash' ? 1 : 2; + var before = null; + Object.keys(memRows).forEach(function (k) { var o = memRows[k]; if (o !== r && o.el.parentNode && o.order > r.order && (!before || o.order < before.order)) before = o; }); + memEl.insertBefore(r.el, before ? before.el : null); + } + r.el.querySelector('.zmw-mem-val').textContent = fmtSize(used) + ' из ' + fmtSize(total); + var bar = r.el.querySelector('i'); + bar.style.width = pct.toFixed(1) + '%'; + r.el.classList.toggle('zmw-mem-hi', pct >= 85); + r.el.classList.toggle('zmw-mem-mid', pct >= 65 && pct < 85); + r.el.title = label + ': занято ' + Math.round(pct) + '%'; +} +function tempRow(t) { + var r = memRows.temp; + if (!r) { + r = memRows.temp = { + order: -1, + el: E('div', { 'class': 'zmw-mem-row' }, [ + E('div', { 'class': 'zmw-mem-head' }, [ E('span', { 'class': 'zmw-mem-label' }, [ 'Температура ЦП' ]), E('span', { 'class': 'zmw-mem-val' }) ]), + E('div', { 'class': 'zmw-mem-bar' }, [ E('i') ]) + ]) + }; + memEl.insertBefore(r.el, memEl.firstChild); + } + r.el.querySelector('.zmw-mem-val').textContent = t + ' °C'; + r.el.querySelector('i').style.width = Math.max(0, Math.min(100, t)).toFixed(0) + '%'; + r.el.classList.toggle('zmw-mem-hi', t >= 80); + r.el.classList.toggle('zmw-mem-mid', t >= 65 && t < 80); + r.el.title = 'Температура процессора: ' + t + ' °C'; +} + +function loadRow(pct) { + var r = memRows.load; + if (!r) { + r = memRows.load = { + order: -0.5, + el: E('div', { 'class': 'zmw-mem-row' }, [ + E('div', { 'class': 'zmw-mem-head' }, [ E('span', { 'class': 'zmw-mem-label' }, [ 'Нагрузка ЦП' ]), E('span', { 'class': 'zmw-mem-val' }) ]), + E('div', { 'class': 'zmw-mem-bar' }, [ E('i') ]) + ]) + }; + var before = null; + Object.keys(memRows).forEach(function (k) { var o = memRows[k]; if (o !== r && o.el.parentNode && o.order > r.order && (!before || o.order < before.order)) before = o; }); + memEl.insertBefore(r.el, before ? before.el : null); + } + pct = Math.max(0, Math.min(100, pct)); + r.el.querySelector('.zmw-mem-val').textContent = pct + '%'; + r.el.querySelector('i').style.width = pct + '%'; + r.el.classList.toggle('zmw-mem-hi', pct >= 90); + r.el.classList.toggle('zmw-mem-mid', pct >= 70 && pct < 90); + r.el.title = 'Нагрузка процессора: ' + pct + '%'; +} + +function 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); +} +/* Открыт Обзор — он и так спрашивает роутер раз в 10 секунд и делится ответом (zm:sysinfo, zm:health): + * боковая панель берёт цифры оттуда и свой опрос пропускает, чтобы не нагружать роутер вдвое. */ +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; + 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 = i.openwrt ? ' · OpenWrt ' + i.openwrt : ''; + }).catch(function () {}); + callUpd().then(function (u) { + if (!u || u.error) return; + if (u.current) verEl.textContent = 'by StressOzz · v' + u.current; + if (u.latest && u.current && u.latest !== u.current && u.newer !== false) { + updateEl.hidden = false; + updateEl.textContent = 'Доступна версия ' + u.latest; + } + }).catch(function () {}); +} + +var shellTimer = null; +var shellRefreshTimer = null; +function scheduleShellRefresh() { + clearTimeout(shellRefreshTimer); + shellRefreshTimer = setTimeout(function () { refreshShellStatus(); }, 1200); +} +window.addEventListener('zm:changed', scheduleShellRefresh); + +function startShellPolling() { + if (shellTimer) return; + /* состояние служб — раз в 30 секунд (и сразу после любых изменений — zm:changed), память и процессор — + * раз в 10; если открыт Обзор, он уже спросил то же самое — тогда свой опрос пропускаем */ + shellTimer = setInterval(function () { if (!document.hidden && Date.now() - sharedHealthAt > 25000) refreshShellStatus(); }, 30000); + setInterval(function () { if (!document.hidden) refreshMemory(); }, 10000); +} + +var routeToken = 0; + +function skeleton() { + return E('div', { 'class': 'zmw-skel-wrap' }, [ + E('div', { 'class': 'zmw-skel zmw-skel-bar' }), + E('div', { 'class': 'zmw-skel-grid' }, [ + E('div', { 'class': 'zmw-skel zmw-skel-card' }), + E('div', { 'class': 'zmw-skel zmw-skel-card' }), + E('div', { 'class': 'zmw-skel zmw-skel-card' }) + ]), + E('div', { 'class': 'zmw-skel zmw-skel-wide' }) + ]); +} + +function errorCard(err, retry) { + return E('div', { 'class': 'zmw-error' }, [ + E('div', { 'class': 'zmw-error-ico' }, [ icon('alert') ]), + E('div', {}, [ + E('h3', {}, [ 'Не удалось открыть страницу' ]), + E('p', {}, [ (err && err.message) || String(err) ]), + E('button', { 'class': 'cbi-button cbi-button-positive', 'click': retry }, [ 'Повторить' ]) + ]) + ]); +} + +function route(force) { + if (!shell || !sid) return; + var id = currentRoute(); + var r = ROUTE_BY_ID[id]; + var token = ++routeToken; + + Object.keys(navLinks).forEach(function (k) { navLinks[k].classList.toggle('zmw-active', k === id); }); + /* открытый раздел всегда виден в списке, даже если список прокручен */ + try { var al = navLinks[id], nv = al && al.parentNode; if (nv && nv.scrollHeight > nv.clientHeight) { var t = al.offsetTop - nv.offsetTop, h = al.offsetHeight; if (t < nv.scrollTop) nv.scrollTop = t - 8; else if (t + h > nv.scrollTop + nv.clientHeight) nv.scrollTop = t + h - nv.clientHeight + 8; } } catch (e) {} + titleEl.textContent = r.title; + subEl.textContent = 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.' + id) + .then(function (src) { return evaluate('view.zapret-manager.' + id, src); }) + .then(function (Cls) { + if (token !== routeToken) return; + var v = (typeof Cls === 'function' && Cls.__zmClass) ? new Cls() : Cls; + return Promise.resolve(v.load()).then(function (data) { + if (token !== routeToken) return; + return Promise.resolve(v.render(data)); + }).then(function (node) { + if (token !== routeToken || !node) return; + var page = E('div', { 'class': 'zmw-page' }, [ node ]); + 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 showLogin(note, noteKind) { + if (loginEl) return; + var user = E('input', { 'type': 'text', 'name': 'username', 'autocomplete': 'username', 'autocapitalize': 'off', 'spellcheck': 'false', 'value': sget(K_USER) || 'root', 'required': '' }); + var pass = E('input', { 'type': 'password', 'name': 'password', 'autocomplete': 'current-password', 'placeholder': '••••••••' }); + var eye = E('button', { 'type': 'button', 'class': 'zmw-eye', 'tabindex': '-1', 'aria-label': 'Показать пароль' }, [ icon('eye') ]); + eye.addEventListener('click', function () { + var show = pass.type === 'password'; + pass.type = show ? 'text' : 'password'; + eye.innerHTML = ''; eye.appendChild(icon(show ? 'eyeOff' : 'eye')); + pass.focus(); + }); + var remember = E('input', { 'type': 'checkbox', 'checked': '' }); + var errEl = E('div', { 'class': 'zmw-login-msg' + (note ? ' zmw-show zmw-' + (noteKind || 'info') : '') }, [ note || '' ]); + var btn = E('button', { 'type': 'submit', 'class': 'zmw-btn-primary' }, [ E('span', {}, [ 'Войти' ]), icon('arrow') ]); + + var card = E('form', { 'class': 'zmw-login-card', 'autocomplete': 'on' }, [ + logo('zmw-logo-lg'), + E('h1', {}, [ 'Zapret Manager' ]), + E('p', { 'class': 'zmw-login-sub' }, [ 'Панель управления роутером ', E('b', {}, [ location.hostname ]) ]), + E('label', { 'class': 'zmw-field' }, [ E('span', { 'class': 'zmw-field-label' }, [ 'Пользователь' ]), E('div', { 'class': 'zmw-input' }, [ icon('user'), user ]) ]), + E('label', { 'class': 'zmw-field' }, [ E('span', { 'class': 'zmw-field-label' }, [ 'Пароль' ]), E('div', { 'class': 'zmw-input' }, [ icon('lock'), pass, eye ]) ]), + E('label', { 'class': 'zmw-check' }, [ remember, E('span', { 'class': 'zmw-check-box' }), E('span', {}, [ 'Запомнить на этом устройстве' ]) ]), + errEl, + btn, + E('div', { 'class': 'zmw-login-foot' }, [ 'Логин и пароль те же, что и для LuCI' ]) + ]); + + function fail(msg) { + errEl.className = 'zmw-login-msg zmw-show zmw-error'; + errEl.textContent = msg; + card.classList.remove('zmw-shake'); void card.offsetWidth; card.classList.add('zmw-shake'); + btn.disabled = false; + btn.classList.remove('zmw-loading'); + } + + card.addEventListener('submit', function (ev) { + ev.preventDefault(); + if (btn.disabled) return; + btn.disabled = true; + btn.classList.add('zmw-loading'); + errEl.className = 'zmw-login-msg'; + ubus('session', 'login', { username: user.value.trim(), password: pass.value, timeout: 3600 }, NULL_SID).then(function (res) { + if (res[0] === 6 || !res[1] || !res[1].ubus_rpc_session) { fail('Неверный логин или пароль'); return; } + var s = res[1].ubus_rpc_session; + var acl = res[1].acls && res[1].acls.ubus; + if (acl && !acl['zapret-manager'] && !acl['*']) { fail('У пользователя нет доступа к Zapret Manager'); return; } + return ubus('zapret-manager', 'status', {}, s).then(function (st) { + if (st[0] === 3 || st[0] === 4) { fail('rpcd-плагин zapret-manager не найден — переустановите панель'); return; } + sid = s; + L.env.sessionid = s; + sset(K_SID, s, remember.checked); + sset(K_USER, user.value.trim(), true); + closeLogin(); + startApp(); + }); + }).catch(function (e) { + if (e && e.rpcCode === -32000 && /Object not found/i.test(e.message || '')) fail('Служба rpcd на роутере ещё запускается — подождите минуту и нажмите «Войти» снова'); + else fail(e && e.noUbus ? e.message : ('Роутер не отвечает: ' + ((e && e.message) || e))); + }); + }); + + loginEl = E('div', { 'class': 'zmw-login' }, [ + E('div', { 'class': 'zmw-orbs', 'aria-hidden': 'true' }, [ E('i'), E('i'), E('i') ]), + E('div', { 'class': 'zmw-grid-bg', 'aria-hidden': 'true' }), + card, + E('button', { 'type': 'button', 'class': 'zmw-icon-btn zmw-login-theme zmw-theme-toggle', 'click': toggleTheme }) + ]); + document.body.classList.add('zmw-locked'); + root.appendChild(loginEl); + applyTheme(); + setTimeout(function () { (user.value ? pass : user).focus(); }, 60); +} + +function closeLogin() { + if (!loginEl) return; + var el = loginEl; + loginEl = null; + authLostShown = false; + el.classList.add('zmw-leave'); + document.body.classList.remove('zmw-locked'); + setTimeout(function () { el.remove(); }, 380); +} + +function authLost() { + if (authLostShown || loginEl) return; + authLostShown = true; + sid = null; + sset(K_SID, null); + poll._reset(); + 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(); + + 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; +} +/* Неподвижны: заголовок, сведения о роутере, датчики, «Открыть LuCI». Прокручивается только список разделов */ +.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; } +/* название: «Zapret» цветом текста, «Manager» — градиентом; в тёмных темах градиент светлее */ +.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-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: 9px; padding: 14px 6px 0; margin-top: 12px; border-top: 1px solid var(--border); } +.zmw-pill { + display: flex; align-items: center; gap: 10px; + padding: 8px 12px; border-radius: 12px; text-decoration: none; + font-weight: 650; font-size: 13px; + border: 1px solid var(--border); + background: var(--surface-2); +} +.zmw-pill-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; background: var(--off); } +.zmw-pill-ok { color: var(--ok); background: var(--ok-bg); border-color: transparent; } +.zmw-pill-ok .zmw-pill-dot { background: var(--ok-dot); animation: zmw-pulse 2.2s 3; } +.zmw-pill-bad { color: var(--bad); background: var(--bad-bg); border-color: transparent; } +.zmw-pill-bad .zmw-pill-dot { background: var(--bad-dot); } +.zmw-pill-off { color: var(--muted); } +@keyframes zmw-pulse { + 0% { box-shadow: 0 0 0 0 rgba(16,185,129,.55); } + 70% { box-shadow: 0 0 0 8px rgba(16,185,129,0); } + 100% { box-shadow: 0 0 0 0 rgba(16,185,129,0); } +} +.zmw-device { padding: 2px 6px 9px; border-bottom: 1px dashed var(--border); 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-side-links { display: flex; gap: 6px; } +.zmw-side-links a { + flex: 1; box-sizing: border-box; height: 32px; padding: 0 10px !important; display: inline-flex; align-items: center; justify-content: center; gap: 6px; + border-radius: 9px; border: 1px solid var(--border); background: transparent; + font-size: 12px; font-weight: 600; line-height: 1; white-space: nowrap; text-decoration: none; color: var(--text-2); + transition: background .15s, color .15s, border-color .15s; +} +.zmw-side-links a:hover { background: var(--surface-2); border-color: var(--border-2); color: var(--text); } +.zmw-side-links .zmw-i { width: 14px; height: 14px; flex-shrink: 0; } +.zmw-mem { display: flex; flex-direction: column; gap: 9px; padding: 2px 6px; } +.zmw-mem:empty { display: none; } +.zmw-mem-head { display: flex; justify-content: space-between; gap: 8px; font-size: 11.5px; margin-bottom: 4px; } +.zmw-mem-label { color: var(--muted); font-weight: 600; } +.zmw-mem-val { color: var(--text-2); font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; } +.zmw-mem-bar { height: 6px; border-radius: 6px; background: var(--surface-3); overflow: hidden; } +.zmw-mem-bar i { display: block; height: 100%; width: 0; border-radius: 6px; background: var(--grad); transition: width .6s cubic-bezier(.2,.8,.2,1); } +.zmw-mem-mid .zmw-mem-bar i { background: linear-gradient(90deg, #f59e0b, #fbbf24); } +.zmw-mem-hi .zmw-mem-bar i { background: linear-gradient(90deg, #ef4444, #f87171); } +#zmw-view .zm-meter .zmw-mem-head { font-size: 13px; margin-bottom: 6px; align-items: baseline; } +#zmw-view .zm-meter .zmw-mem-label { font-weight: 500; } +.zmw-credit { font-size: 11.5px; color: var(--muted); text-align: center; opacity: .7; } + +.zmw-main { flex: 1; min-width: 0; display: flex; flex-direction: column; } + +/* Заголовок страницы прилипает к верху. Под заголовком и подписью фон сплошной (иначе при прокрутке + * текст страницы просвечивал сквозь них), а ниже — короткий переход в прозрачность. */ +.zmw-top { + --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); } +/* пульс — несколько раз при появлении, не бесконечно: иначе экран за стеклянными карточками с размытием + * фона перерисовывается 60 раз в секунду, и на открытом Обзоре браузер грузил процессор */ +#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 { border-color: var(--border-2, var(--border)); background: var(--surface-2); } +#zmw-view .zm-cat.zm-list-open { background: transparent; } +#zmw-view .zm-cat-body { border-top-color: var(--border); } +#zmw-view .zm-linkbtn { color: var(--a1); } +#zmw-view .zm-linkbtn:hover:not([disabled]) { background: var(--surface-2); } +#zmw-view .zm-cat-src { border-color: var(--border-2, var(--border)); } +#zmw-view .zm-cat-src-title { color: var(--text); } +#zmw-view .zm-grid-devices { gap: 10px; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); } +#zmw-view .zm-tile { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 13px; + padding: 11px 16px; + font-size: 13.5px; font-weight: 550; color: var(--text-2); + transition: border-color .15s, background .15s, transform .12s, box-shadow .15s, color .15s; +} +html.zm-theme-dark #zmw-view .zm-tile:not(.zm-active):not(.zm-tile-off) { background: var(--surface-2); border-color: var(--border); } +#zmw-view .zm-tile:hover { border-color: rgba(124,92,255,.55); color: var(--text); transform: translateY(-2px); box-shadow: 0 10px 20px -14px rgba(99,102,241,.7); } +html.zm-theme-dark #zmw-view .zm-tile:not(.zm-active):not(.zm-tile-off):hover { border-color: rgba(124,92,255,.55); } +#zmw-view .zm-tile.zm-active { + background: var(--grad-soft); + border-color: rgba(124,92,255,.65); + color: var(--text); font-weight: 700; + box-shadow: 0 0 0 1px rgba(124,92,255,.45) inset, 0 10px 24px -14px rgba(99,102,241,.9); +} +#zmw-view .zm-tile.zm-active::before { content: none; } +html[data-theme="dark"] #zmw-view .zm-tile.zm-active::before { color: #a594ff; } +#zmw-view .zm-tile.zm-tile-off { background: var(--bad-bg); border-color: rgba(239,68,68,.35); color: var(--bad); } +#zmw-view .zm-tile.zm-tile-pending { opacity: .5; } + +#zmw-view .zm-log { + background: var(--console); + color: #dfe6f3; + border: 1px solid var(--console-border); + border-radius: 16px; + padding: 0 20px 18px; + font: 13px/1.75 var(--mono); + box-shadow: inset 0 1px 0 rgba(255,255,255,.04), var(--shadow); + position: relative; + background-image: none; + overflow-x: hidden; overflow-y: auto; + scrollbar-width: auto; scrollbar-color: auto; +} +#zmw-view .zm-log::before { + content: ''; display: block; + position: sticky; top: 0; z-index: 2; + height: 40px; margin: 0 -20px 6px; + background: + radial-gradient(circle at 20px 20px, #ff5f57 5px, transparent 5.5px), + radial-gradient(circle at 38px 20px, #febc2e 5px, transparent 5.5px), + radial-gradient(circle at 56px 20px, #28c840 5px, transparent 5.5px), + var(--console); + background-repeat: no-repeat; + opacity: 1; +} +#zmw-view .zm-log:empty::before { content: ''; opacity: 1; } +#zmw-view .zm-log:empty::after { content: "Ожидание вывода..."; color: #8b949e; } +#zmw-view .zm-log.bt-panel { padding-top: 16px; } +#zmw-view .zm-log.bt-panel::before { display: none; } +#zmw-view .zm-log::-webkit-scrollbar { width: 14px; height: 14px; } +#zmw-view .zm-log::-webkit-scrollbar-track { background: transparent; margin: 44px 0 12px; } +#zmw-view .zm-log.bt-panel::-webkit-scrollbar-track { margin: 12px 0; } +#zmw-view .zm-log::-webkit-scrollbar-thumb { + background: rgba(255,255,255,.18); border-radius: 10px; + border: 4px solid transparent; background-clip: padding-box; min-height: 40px; +} +#zmw-view .zm-log::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.32); background-clip: padding-box; } +#zmw-view .zm-log::-webkit-scrollbar-corner { background: transparent; } +@supports not selector(::-webkit-scrollbar) { + #zmw-view .zm-log { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.25) transparent; } +} +#zmw-view .zm-log-arrow { color: #67e8f9; } +#zmw-view .zm-log-msg-info { color: #fcd34d; } +#zmw-view .zm-log-msg-ok { color: #4ade80; } +#zmw-view .zm-log-msg-error { color: #fb7185; } +#zmw-view .zm-log-msg-warn { color: #fdba74; } +#zmw-view .zm-log-code { color: #94a3b8; } + +#zmw-view .zm-config-editor { + background: var(--console); color: #e2e8f0; + border: 1px solid var(--console-border); border-radius: 16px; + padding: 16px 18px; font: 13px/1.65 var(--mono); + outline: none; + transition: border-color .15s, box-shadow .15s; +} +#zmw-view .zm-config-editor:focus { border-color: var(--a1); box-shadow: var(--ring); } + +#zmw-view .zm-refresh-banner, .zm-refresh-banner { + background: var(--warn-bg); border: 1px solid rgba(245,158,11,.35); + color: var(--warn); border-radius: var(--radius); padding: 14px 18px; + font-size: 14px; font-weight: 600; margin-top: 0; +} +#zmw-view .zm-current-banner { + background: var(--grad-soft); border: 1px solid rgba(124,92,255,.28); + border-radius: 14px; padding: 12px 16px; font-size: 13.5px; color: var(--text); +} +#zmw-view .zm-current-banner.zm-current-empty { background: var(--surface-2); border-color: var(--border); color: var(--muted); } +#zmw-view .zm-current-banner.zm-current-top { margin: 14px 0 0 !important; } +#zmw-view .zm-current-banner.zm-current-top + .zm-card { margin-top: 18px !important; } +#zmw-view .bt-current-cmd { background: var(--console); color: #86efac; border-radius: 12px; font-family: var(--mono); } + +#zmw-view .zm-tg-link-card { background: var(--ok-bg); border: 1px solid rgba(16,185,129,.28); border-radius: 16px; } +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-qr-box img { border-radius: 14px; box-shadow: var(--shadow); } + +#zmw-view .zm-ab-panel { gap: 18px; } +#zmw-view .zm-ab-tile { background: var(--surface-2); border-color: var(--border); } +#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-panel > .zm-card { margin: 0 !important; } +#zmw-view .zm-ab-hero { background: var(--surface); position: relative; overflow: hidden; } +#zmw-view .zm-ab-hero::after { + content: ""; position: absolute; right: -80px; top: -80px; width: 240px; height: 240px; border-radius: 50%; + background: radial-gradient(circle, rgba(26,163,255,.16), transparent 70%); pointer-events: none; +} +#zmw-view .zm-ab-hero h3::before, #zmw-view .zm-ab-title h3::before { display: none; } +#zmw-view .zm-ab-foot { border-top-color: var(--border); color: var(--muted); opacity: 1; } +#zmw-view .zm-ab-sum b { color: var(--text); } +#zmw-view .zm-ab-sum-ok b { color: var(--ok); } +#zmw-view .zm-ab-note-warn { background: var(--warn-bg); color: var(--warn); border-color: rgba(245,158,11,.35); } +#zmw-view .zm-ab-step { color: var(--muted); opacity: 1; } +#zmw-view .zm-ab-step::before { background: var(--border-2); } +#zmw-view .zm-ab-step-dot { background: var(--surface-2); border: 1px solid var(--border); } +#zmw-view .zm-ab-step.zm-ab-done { color: var(--text-2); } +#zmw-view .zm-ab-step.zm-ab-done .zm-ab-step-dot { background: var(--ok-dot); border-color: transparent; color: #fff; } +#zmw-view .zm-ab-step.zm-ab-done::before, #zmw-view .zm-ab-step.zm-ab-now::before { background: var(--grad); } +#zmw-view .zm-ab-step.zm-ab-now { color: var(--text); } +#zmw-view .zm-ab-step.zm-ab-now .zm-ab-step-dot { background: var(--grad); border-color: transparent; color: #fff; } +#zmw-view .zm-ab-svc-item, html.zm-theme-dark #zmw-view .zm-ab-svc-item { background: var(--surface-2); border-color: var(--border); border-radius: 13px; } +#zmw-view .zm-ab-svc-item.zm-ab-svc-bad { border-color: rgba(239,68,68,.35); } +#zmw-view .zm-awg-if, html.zm-theme-dark #zmw-view .zm-awg-if { background: var(--surface-2); border-color: var(--border); border-radius: 14px; } +#zmw-view .zm-ab-svc-name { color: var(--text); } +#zmw-view .zm-ab-player video { border-radius: 14px; box-shadow: var(--shadow); } +#zmw-view .zm-ab-logcard .zm-log { margin-top: 0; } + +#zmw-view .zm-st-stat, html.zm-theme-dark #zmw-view .zm-st-stat { background: var(--surface-2); border-color: var(--border); border-radius: 14px; } +#zmw-view .zm-st-stat-label { color: var(--muted); opacity: 1; } +#zmw-view .zm-st-stat-value { color: var(--text); } +#zmw-view .zm-st-ok { color: var(--ok); } #zmw-view .zm-st-warn { color: var(--warn); } #zmw-view .zm-st-bad { color: var(--bad); } #zmw-view .zm-st-off { color: var(--muted); opacity: 1; } +#zmw-view .zm-st-promo > div { background: var(--grad-soft); border-color: rgba(124,92,255,.25); border-radius: 14px; } +#zmw-view .zm-st-promo b { color: var(--text); } #zmw-view .zm-st-promo span { color: var(--muted); opacity: 1; } +#zmw-view .zm-svc, html.zm-theme-dark #zmw-view .zm-svc { background: var(--surface-2); border-color: var(--border); border-radius: 14px; } +#zmw-view .zm-svc:hover { border-color: rgba(124,92,255,.55); box-shadow: 0 10px 20px -14px rgba(99,102,241,.7); } +#zmw-view .zm-svc.zm-svc-on, html.zm-theme-dark #zmw-view .zm-svc.zm-svc-on { background: var(--grad-soft); border-color: rgba(124,92,255,.6); } +#zmw-view .zm-svc-name { color: var(--text); } #zmw-view .zm-svc-sub { color: var(--muted); opacity: 1; } +#zmw-view .zm-switch { background: var(--border-2); } +#zmw-view .zm-switch.zm-switch-on { background: var(--grad); } +#zmw-view .zm-st-note-ok { background: var(--ok-bg); color: var(--ok); border-color: rgba(16,185,129,.3); } +#zmw-view .zm-st-note-bad { background: var(--bad-bg); color: var(--bad); border-color: rgba(239,68,68,.3); } +#zmw-view .zm-st-check { color: var(--text); } #zmw-view .zm-st-check-why { color: var(--muted); opacity: 1; } + +#zmw-view .zm-credits-grid { gap: 12px; } +#zmw-view .zm-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-av { border-color: var(--surface-solid); } +#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 .zm-credit-tile, html.zm-theme-dark #zmw-view .zm-credit-tile { + background: var(--surface-2); border: 1px solid var(--border); border-radius: 14px; padding: 14px; + transition: border-color .15s, transform .15s; +} +#zmw-view .zm-credit-tile:hover { border-color: var(--border-2); transform: translateY(-1px); } +#zmw-view .zm-credit-tile.zm-credit-self { background: var(--grad-soft); border-color: rgba(124,92,255,.35); } +#zmw-view .zm-credit-author { color: var(--muted); opacity: 1; } + +#zmw-view .bt-table td { border-top-color: rgba(255,255,255,.08); } +#zmw-view .bt-chip { border-radius: 7px; } + +#zmw-view iframe { border: 1px solid var(--border-2) !important; border-radius: 16px !important; background: #fff; box-shadow: var(--shadow); } +#zmw-view .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; } + #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-savebar-spin, 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; } +} +#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-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-ts-more p { color: var(--muted); opacity: 1; } +.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-fact > span { color: var(--muted); opacity: 1; } +#zmw-view .zm-quota { background: var(--surface-3); } +#zmw-view .zm-quota > i { background: var(--grad); } +#zmw-view .zm-quota.zm-quota-high > i { background: var(--bad-dot); } +#zmw-view .zm-seg { background: var(--surface-2); border-color: var(--border); border-radius: 12px; } +#zmw-view .zm-seg-item { border-radius: 9px; color: var(--text-2); } +#zmw-view .zm-seg-item.zm-active { background: var(--grad); color: #fff; box-shadow: 0 6px 16px -10px rgba(99,102,241,.9); } +@media (max-width: 600px) { + #zmw-view .zm-nodes { grid-template-columns: repeat(2, minmax(0, 1fr)); } + #zmw-view .zm-sub-meta { flex-direction: column; align-items: flex-start; } +} +#zmw-view .zm-log-bar { top: 46px; } +#zmw-view .zm-log-min { top: -38px; right: -10px; border-radius: 8px; border-color: rgba(255,255,255,.12); background: rgba(255,255,255,.05); color: #cbd5e1; } +#zmw-view .zm-log-min:hover { background: rgba(124,92,255,.35); border-color: rgba(124,92,255,.6); color: #fff; } +#zmw-view .zm-log.zm-log-collapsed { padding-bottom: 12px; } +.zmw-logo, .zmw-logo-lg { background: none; box-shadow: none; border-radius: 0; } +.zmw-logo svg { filter: drop-shadow(0 2px 3px rgba(0,120,220,.25)); } +.zmw-logo-lg::after { opacity: .55; } +html[data-theme="dark"] .zmw-logo svg { filter: drop-shadow(0 0 1px rgba(255,255,255,.55)) drop-shadow(0 0 10px rgba(0,182,255,.45)); } + +.zmw-theme-menu { + position: fixed; z-index: 1000; min-width: 184px; padding: 6px; + display: flex; flex-direction: column; gap: 2px; + background: var(--surface-solid); border: 1px solid var(--border-2); border-radius: 14px; + box-shadow: var(--shadow-lg); animation: zmw-menu-in .14s ease-out; +} +@keyframes zmw-menu-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } } +.zmw-theme-opt { + display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 10px; + border: 0; border-radius: 10px; background: none; color: var(--text); font: inherit; font-size: 14px; + cursor: pointer; text-align: left; +} +.zmw-theme-opt:hover { background: var(--surface-2); } +.zmw-theme-opt.zmw-on { background: var(--grad-soft); font-weight: 650; } +.zmw-theme-opt .zmw-i { width: 18px; height: 18px; flex-shrink: 0; } +.zmw-theme-opt > span:nth-of-type(1) { flex: 1; } +.zmw-theme-mark { color: var(--a1); width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } +.zmw-theme-opt.zmw-on .zmw-theme-mark { background: currentColor; } + +html[data-theme="ink"] { + --a1: #0a9cff; --a2: #0a9cff; --a3: #00c0ff; + --grad: linear-gradient(180deg, #00c0ff, #0a9cff); + --grad-soft: #d6f0ff; + --ring: 0 0 0 3px rgba(10,156,255,.35); + --radius: 6px; --radius-sm: 4px; + --bg: #ffffff; --bg-glow-1: transparent; --bg-glow-2: transparent; + --surface: #ffffff; --surface-solid: #ffffff; --surface-2: #e8f6ff; --surface-3: #d6f0ff; + --side-bg: #e8f6ff; + --border: #0a0a0a; --border-2: #0a0a0a; + --text: #0a0a0a; --text-2: #1f2a36; --muted: #3d4a57; + --shadow: 5px 5px 0 #0a0a0a; --shadow-lg: 8px 8px 0 #0a0a0a; + --input-bg: #ffffff; --console: #0a0a0a; --console-border: #0a0a0a; + --ok: #0a0a0a; --ok-bg: #19c45a; --ok-dot: #0a0a0a; + --bad: #0a0a0a; --bad-bg: #ff4d3d; --bad-dot: #0a0a0a; + --warn: #0a0a0a; --warn-bg: #ffc21a; --warn-dot: #0a0a0a; + --off: #0a0a0a; --off-bg: #eeeeee; + color-scheme: light; +} +html[data-theme="ink"] ::selection { background: #00c0ff; color: #0a0a0a; } +html[data-theme="ink"] body.zmw-body { background-image: none; } +html[data-theme="ink"] .zmw-orbs, html[data-theme="ink"] .zmw-grid-bg { display: none; } +html[data-theme="ink"] .zmw-top { --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, html[data-theme="ink"] #zmw-view .zm-credit-tile, html[data-theme="ink"] #zmw-view .zm-ab-svc-item { + background: #ffffff; border: 2px solid #0a0a0a; border-radius: 6px; box-shadow: 3px 3px 0 #0a0a0a; +} +html[data-theme="ink"] #zmw-view .zm-node.zm-active, html[data-theme="ink"] #zmw-view .zm-svc.zm-svc-on { background: #0a9cff; border-color: #0a0a0a; } +html[data-theme="ink"] #zmw-view .zm-node:hover { transform: none; } +html[data-theme="ink"] #zmw-view .zm-seg { border: 2px solid #0a0a0a; border-radius: 6px; background: #ffffff; } +html[data-theme="ink"] #zmw-view .zm-seg-item { border-radius: 3px; color: #0a0a0a; } +html[data-theme="ink"] #zmw-view .zm-seg-item.zm-active { background: #0a9cff; color: #0a0a0a; box-shadow: none; outline: 2px solid #0a0a0a; } +html[data-theme="ink"] #zmw-view .zm-sub-meta { background: #e8f6ff; border: 2px solid #0a0a0a; border-radius: 6px; } +html[data-theme="ink"] #zmw-view .zm-quota { border: 2px solid #0a0a0a; height: 10px; background: #ffffff; } +html[data-theme="ink"] #zmw-view .zm-quota > i { background: #0a9cff; border-radius: 0; } +html[data-theme="ink"] #zmw-view .zm-switch { border: 2px solid #0a0a0a; background: #ffffff; } +html[data-theme="ink"] #zmw-view .zm-switch > span { border: 2px solid #0a0a0a; box-shadow: none; top: 1px; left: 1px; } +html[data-theme="ink"] #zmw-view .zm-switch.zm-switch-on { background: #0a9cff; } +html[data-theme="ink"] #zmw-view a:not(.cbi-button), html[data-theme="ink"] .zm-dock a:not(.cbi-button), 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-mem-bar { height: 16px; border-radius: 0; background: #ffffff; border-top: 1px solid #808080; border-left: 1px solid #808080; border-right: 1px solid #ffffff; border-bottom: 1px solid #ffffff; padding: 2px; box-sizing: border-box; } +html[data-theme="retro"] .zmw-mem-bar i { border-radius: 0; background: repeating-linear-gradient(90deg, #000080 0 8px, transparent 8px 10px); } +html[data-theme="retro"] .zmw-mem-mid .zmw-mem-bar i, html[data-theme="retro"] .zmw-mem-hi .zmw-mem-bar i { background: repeating-linear-gradient(90deg, #a00000 0 8px, transparent 8px 10px); } +html[data-theme="retro"] #zmw-view .zm-card { + background: #c0c0c0; border-radius: 0; backdrop-filter: none; -webkit-backdrop-filter: none; + border-top: 2px solid #ffffff; border-left: 2px solid #ffffff; border-right: 2px solid #000000; border-bottom: 2px solid #000000; + box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf; +} +html[data-theme="retro"] #zmw-view .zm-card { padding: 0 14px 14px; } +html[data-theme="retro"] #zmw-view .zm-card h3 { margin: 3px -11px 14px; padding: 5px 8px; background: linear-gradient(90deg, #000080, #1084d0); color: #ffffff; font-size: 13px; font-weight: 700; } +html[data-theme="retro"] #zmw-view .zm-card h3::before { display: none; } +html[data-theme="retro"] #zmw-view .cbi-button, html[data-theme="retro"] .zmw-icon-btn, html[data-theme="retro"] .zmw-btn-primary, html[data-theme="retro"] .zm-dock .cbi-button, 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-view .zm-credit-tile.zm-credit-all { grid-column: 1 / -1; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; } +.zmw-theme-menu { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px 4px; width: 340px; max-width: calc(100vw - 16px); } +.zmw-theme-group { grid-column: 1 / -1; padding: 8px 10px 4px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); } +.zmw-theme-group:first-child { padding-top: 4px; } +.zmw-theme-opt { min-width: 0; } +.zmw-theme-opt > span:nth-of-type(1) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +html[data-theme="micro"] { + --font: "Onest", system-ui, sans-serif; + --a1: #2d5bff; --a2: #2d5bff; --a3: #2d5bff; + --grad: #2d5bff; --grad-soft: #e6ecff; --ring: 0 0 0 3px #2d5bff55; + --radius: 20px; --radius-sm: 20px; + --bg: #f4f5f8; --bg-glow-1: transparent; --bg-glow-2: transparent; + --surface: #ffffff; --surface-solid: #ffffff; --surface-2: #f7f8fb; --surface-3: #eceff5; + --side-bg: #ffffff; --border: #e3e6ee; --border-2: #e3e6ee; + --text: #151a26; --text-2: #151a26; --muted: #5a6377; + --shadow: 0 1px 2px rgba(0,0,0,.04); --shadow-lg: 0 30px 70px -30px rgba(0,0,0,.45); + --input-bg: #f7f8fb; + --ok: #11643e; --ok-bg: #dcf5e9; --ok-dot: #11643e; + --warn: #7a4d00; --warn-bg: #fff1d6; --warn-dot: #7a4d00; + --bad: #9a1f1f; --bad-bg: #ffe1e1; --bad-dot: #9a1f1f; + --off: #4a5266; --off-bg: #eceff5; + color-scheme: light; +} +html[data-theme="micro"] body.zmw-body { background: #f4f5f8; } +html[data-theme="micro"] .zmw-orbs, html[data-theme="micro"] .zmw-grid-bg { display: none; } +html[data-theme="micro"] .zmw-top { --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, html[data-theme="micro"] .zmw-side-links a { color: #5d6679; } +html[data-theme="micro"] .zmw-nav-item { color: #151a26; border-radius: 12px; } +html[data-theme="micro"] .zmw-nav-item:hover { background: #f0f3fa; color: #151a26; } +html[data-theme="micro"] .zmw-nav-item.zmw-active { background: #2d5bff; color: #ffffff; box-shadow: none; } +html[data-theme="micro"] .zmw-nav-item.zmw-active::before { display: none; } +html[data-theme="micro"] .zmw-nav-item.zmw-active .zmw-nav-ico { color: #ffffff; } +html[data-theme="micro"] .zmw-mem-bar { background: #e3e6ee; border-radius: 6px; } +html[data-theme="micro"] .zmw-mem-bar i { background: #2d5bff; border-radius: inherit; } +html[data-theme="micro"] #zmw-view .zm-card { background: #ffffff; border: 1px solid #e3e6ee; border-radius: 20px; box-shadow: 0 1px 2px rgba(0,0,0,.04); backdrop-filter: none; -webkit-backdrop-filter: none; color: #151a26; } +html[data-theme="micro"] #zmw-view .zm-card:hover { border-color: #e3e6ee; } +html[data-theme="micro"] #zmw-view .zm-card h3 { color: #151a26; } html[data-theme="micro"] #zmw-view .zm-card h3::before { background: #2d5bff; } +html[data-theme="micro"] #zmw-view .zm-hint, html[data-theme="micro"] #zmw-view p.zm-hint, html[data-theme="micro"] #zmw-view .zm-label, html[data-theme="micro"] .zm-dock .zm-hint, html[data-theme="micro"] .zm-dock p.zm-hint, 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-credit-tile, html.zm-theme-dark[data-theme="micro"] #zmw-view .zm-credit-tile { background: #f7f8fb; border-color: #e3e6ee; color: #151a26; } +html[data-theme="micro"] #zmw-view .zm-credit-tile.zm-credit-self { background: #e6ecff; } +html[data-theme="micro"] #zmw-view .zm-switch.zm-switch-on { background: #2d5bff; } +html[data-theme="micro"] .zmw-check input:checked + .zmw-check-box { background: #2d5bff; } html[data-theme="micro"] .zmw-check input:checked + .zmw-check-box::after { border-color: #ffffff; } +html[data-theme="micro"] .zmw-login-card, html[data-theme="micro"] .zmw-modal, html[data-theme="micro"] .zmw-theme-menu { background: #ffffff; color: #151a26; border: 1px solid #e3e6ee; backdrop-filter: none; -webkit-backdrop-filter: none; } +html[data-theme="micro"] .zmw-theme-opt { color: #151a26; } html[data-theme="micro"] .zmw-theme-opt:hover { background: #f7f8fb; } +html[data-theme="micro"] .zmw-theme-opt.zmw-on { background: #2d5bff; color: #ffffff; } html[data-theme="micro"] .zmw-theme-opt.zmw-on .zmw-theme-mark, html[data-theme="micro"] .zmw-theme-opt.zmw-on .zmw-i { color: #ffffff; } +html[data-theme="micro"] .zmw-theme-group { color: #5a6377; } +html[data-theme="micro"] #zmw-view .cbi-button, html[data-theme="micro"] .zmw-icon-btn, html[data-theme="micro"] .zmw-btn-primary, html[data-theme="micro"] .zm-dock .cbi-button, 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); } +#zmw-view .zm-savebar-spin, .zm-dock .zm-savebar-spin, .zmw-modal .zm-savebar-spin { width: 16px; height: 16px; box-sizing: border-box; border-radius: 50%; border: 2px solid rgba(124,92,255,.25); border-top-color: rgba(124,92,255,.95); animation: zmw-spin .8s linear infinite; flex-shrink: 0; } +#zmw-view .zm-savebar-sub, .zm-dock .zm-savebar-sub, .zmw-modal .zm-savebar-sub { display: block; margin-top: 2px; font-weight: 400; font-size: 12.5px; color: var(--text-2, inherit); opacity: .85; } +.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-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; +} +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 +echo '*/2 * * * * /opt/zapret-manager-luci/backend.sh zm_watch >/dev/null 2>&1 # zm-rpcd-watch' >> /etc/crontabs/root 2>/dev/null || true +# поиск новых версий «раз в день» (так по умолчанию): задание около 04:00, минута своя у каждого роутера +ZM_NO_CRON_RESTART=1 /opt/zapret-manager-luci/backend.sh versions_cron_sync >/dev/null 2>&1 || true +# Сторож панели запускается раз в 2 минуты — без этого cron пишет строку о каждом запуске в системный журнал. +# Уровень 9 — только ошибки cron. Свой уровень, если его уже выставили, не трогаем. +# Пусто, 5 (так в стандартном /etc/config/system OpenWrt) или 8 (умолчание busybox) — это не ваш выбор, +# а заводское значение: при нём crond пишет «cron.err … USER root pid … cmd …» о каждом успешном запуске. +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 + +/opt/zapret-manager-luci/backend.sh redbtn_panel_gone >/dev/null 2>&1 || true +rm -f /opt/zapret-manager-luci/state/steer.vpnprobe 2>/dev/null || true + +_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 ]; 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 grep -q '^159\.194\.200\.33 .*riotgames\.com' /etc/hosts 2>/dev/null; then + grep -vxF -e '#Riotgames' \ + -e '159.194.200.33 auth.riotgames.com authenticate.riotgames.com entitlements.auth.riotgames.com clientconfig.rpg.riotgames.com' \ + -e '159.194.200.33 playerpreferences.riotgames.com login.riotgames.com riotgames.com www.riotgames.com account.riotgames.com' \ + -e '159.194.200.33 recovery.riotgames.com riot-client.riotgames.com playvalorant.com leagueoflegends.com' /etc/hosts > /etc/hosts.zmtmp 2>/dev/null || true + if [ -s /etc/hosts.zmtmp ]; then cat /etc/hosts.zmtmp > /etc/hosts; /etc/init.d/dnsmasq restart >/dev/null 2>&1 || true; fi + rm -f /etc/hosts.zmtmp +fi +/opt/zapret-manager-luci/backend.sh hosts_ai_migrate >/dev/null 2>&1 || true +# блокировка рекламы: серверы DNS Forkozz и DoH теперь в исключениях — пересобираем список (из скачанного, +# без загрузки), когда панель запустится; если блокировка выключена, ничего не делается +# сторож сразу, не дожидаясь cron: после удаления панели с сохранением настроек он снова включает блокировку рекламы +( 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