diff --git a/ZapretManager_LuCI.sh b/ZapretManager_LuCI.sh index 694a6b4..b734bb3 100644 --- a/ZapretManager_LuCI.sh +++ b/ZapretManager_LuCI.sh @@ -1,10 +1,36 @@ #!/bin/sh -# Version: 2.33 +# Version: 2.41 set -e GREEN="\033[1;32m"; CYAN="\033[1;36m"; YELLOW="\033[1;33m"; MAGENTA="\033[1;35m"; BLUE="\033[0;34m"; NC="\033[0m"; DGRAY="\033[38;5;244m" -echo -e "\n${MAGENTA}Устанавливаем Zapret Manager для LuCI${NC}" +ZM_NEW_VER="2.41" +_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 +} + +echo -e "\n${MAGENTA}Zapret Manager $ZM_NEW_VER для LuCI — установка${NC}\n" +_zmi_say "Проверяем, что панель сейчас ничем не занята" if [ -f /tmp/zapret-manager-luci/redbtn_deep.pid ]; then _zm_kill_tree() { @@ -25,6 +51,8 @@ for _zm_pid in /tmp/zapret-manager-luci/*.pid; do fi done +_zmi_ok "Можно ставить" +_zmi_say "Убираем файлы прежней версии панели" rm -rf \ /usr/lib/zapret-manager* \ /etc/zapret_manager_expert_mode* \ @@ -42,7 +70,8 @@ rm -f \ /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' @@ -50,7 +79,7 @@ cat > '/opt/zapret-manager-luci/backend.sh.zm-new' << 'ZM_INSTALLER_EOF' umask 022 CONF="/etc/config/zapret" -ZM_VERSION="2.33" +ZM_VERSION="2.41" 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" @@ -448,7 +477,20 @@ ZM_REBOOT_HINT="$JOBS_DIR/reboot.hint" _zm_reboot_hint() { mkdir -p "$JOBS_DIR" - printf '%s\n' "$1" > "$ZM_REBOOT_HINT" + 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() { @@ -491,7 +533,7 @@ _zm_after_remove() { else _zm_reboot_hint "$1 удалён, но интернет на роутере не отвечает. Перезагрузите роутер." fi - echo " Желательно перезагрузить роутер — кнопка «Перезагрузить роутер» появилась вверху страницы" + echo " Желательно перезагрузить роутер — кнопка «Перезагрузить роутер» появилась вверху страницы на 2 минуты" } system_reboot() { @@ -588,13 +630,37 @@ _zm_net_prepare() { 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; } 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 @@ -615,11 +681,68 @@ _fk_heal() { local s first=1 for s in $(_fk_uc secnames 2>/dev/null); do case "$s" in *[!A-Za-z0-9_]*|'') continue ;; esac + if [ "$(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 +) + _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)" @@ -1089,22 +1212,23 @@ do_install_zapret_full() { } do_remove_zapret() { + local rc=0 echo "==> Останавливаем Zapret" _zm_svc_off zapret for p in $(pgrep -f "/opt/zapret/" 2>/dev/null); do kill -9 "$p" 2>/dev/null; done nft delete table inet zapret >/dev/null 2>&1 echo " ✓ Процессы nfqws остановлены, правила файрвола сняты" echo "==> Удаляем пакеты Zapret" - $DELETE luci-i18n-zapret-ru luci-app-zapret zapret + _zm_pkg_wipe luci-i18n-zapret-ru luci-app-zapret zapret || rc=1 echo "==> Удаляем файлы, стратегии и списки" - rm -rf /opt/zapret "$CONF" /etc/init.d/zapret /etc/rc.d/*[0-9]zapret /etc/firewall.zapret /etc/hotplug.d/*/*zapret \ - /tmp/zapret /tmp/zapret.* "$YV_OFF_FLAG" "$DV_OFF_FLAG" "$JOBS_DIR/flowseal_strategies.txt" "$JOBS_DIR/youtube_strategies.txt" "$ZO_RKN_FLAG" - 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" && + 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 - echo "==> Готово, Zapret удалён полностью" + _zm_rm_done "$rc" "Zapret" } zapret_action() { local action="$1" @@ -1200,19 +1324,20 @@ _do_install_zapret2_core() { } do_remove_zapret2() { + local rc=0 echo "==> Останавливаем Zapret2" _zm_svc_off zapret2 for p in $(pgrep -f "/opt/zapret2/" 2>/dev/null); do kill -9 "$p" 2>/dev/null; done nft delete table inet zapret2 >/dev/null 2>&1 echo " ✓ Процессы остановлены, правила файрвола сняты" echo "==> Удаляем пакеты Zapret2" - $DELETE luci-i18n-zapret2-ru luci-app-zapret2 zapret2 + _zm_pkg_wipe luci-i18n-zapret2-ru luci-app-zapret2 zapret2 || rc=1 echo "==> Удаляем файлы и настройки" - rm -rf /etc/config/zapret2 /opt/zapret2 /etc/init.d/zapret2 /etc/rc.d/*zapret2 /etc/hotplug.d/*/*zapret2 /tmp/zapret2* - 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 - echo "==> Готово, Zapret2 удалён полностью" + _zm_rm_done "$rc" "Zapret2" } zapret2_action() { local action="$1" @@ -2404,7 +2529,7 @@ system_uninstall_panel() { cat > "$script" << 'ZM_UNINSTALL_EOF' sleep 1 /opt/zapret-manager-luci/backend.sh redbtn_panel_gone >/dev/null 2>&1 -rm -rf /opt/zapret-manager-luci /usr/libexec/rpcd/zapret-manager \ +ZM_PATHS="/opt/zapret-manager-luci /usr/libexec/rpcd/zapret-manager \ /usr/share/luci/menu.d/luci-app-zapret-manager.json \ /usr/share/rpcd/acl.d/luci-app-zapret-manager.json \ /www/luci-static/resources/view/zapret-manager \ @@ -2412,7 +2537,17 @@ rm -rf /opt/zapret-manager-luci /usr/libexec/rpcd/zapret-manager \ /www/luci-static/resources/bytetube \ /www/luci-static/resources/view/bytetube \ /tmp/zapret-manager-luci /tmp/luci-indexcache* /tmp/luci-modulecache/* \ - /www/zm /www/zm-webui.html /etc/zm-warp-own.conf 2>/dev/null + /www/zm /www/zm-webui.html /etc/zm-warp-own.conf" +zm_try=0 +while :; do + rm -rf $ZM_PATHS 2>/dev/null + zm_left=0 + for zm_f in $ZM_PATHS; do [ -e "$zm_f" ] || [ -L "$zm_f" ] && zm_left=1; done + [ "$zm_left" = 0 ] && break + [ "$zm_try" -ge 4 ] && break + zm_try=$((zm_try + 1)) + sleep 1 +done [ -s /etc/zm-steer/owned ] || rm -rf /usr/share/zm-redbtn 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 @@ -2859,26 +2994,91 @@ _zm_pkg_files() { else opkg files "$1" 2>/dev/null | grep '^/'; fi } -_zm_pkg_purge() { - local p="$1" out files f - _pkg_is_installed "$p" || return 0 - files="$(_zm_pkg_files "$p")" - echo "==> Удаляем пакет $p" - $DELETE "$p" - if _pkg_is_installed "$p"; then - echo "==> Менеджер пакетов отказался — удаляем $p принудительно" - if [ "$PKG" = apk ]; then - _zm_run 180 apk del --force-broken-world "$p" >/dev/null 2>&1 || _zm_run 180 apk del --force "$p" >/dev/null 2>&1 - else - _zm_run 180 opkg remove --force-depends --force-remove "$p" >/dev/null 2>&1 +_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 - for f in $files; do [ -f "$f" ] || [ -L "$f" ] && rm -f "$f"; done - if _pkg_is_installed "$p"; then - echo "!! Пакет $p всё ещё числится в системе, но его файлы удалены" - return 1 +} + +_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 [ "$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 ]; then + echo "==> Готово: $2 удалён полностью, остатков нет" + return 0 fi - return 0 + echo "ОШИБКА: $2 удалён не полностью — что осталось, написано выше. Перезагрузите роутер и нажмите «Удалить» ещё раз" + return 1 } _zm_left() { @@ -3167,10 +3367,9 @@ do_tg_remove_mtproto() { _zm_stop_svc tg-ws-proxy tg-ws-proxy _zm_pkg_purge tg-ws-proxy || rc=1 echo "==> Чистим файлы и настройки" - rm -rf /etc/init.d/tg-ws-proxy /usr/bin/tg-ws-proxy /etc/tg-ws-proxy /etc/tg-ws-proxy.conf /etc/tg-ws-proxy.conf-opkg /etc/config/tg-ws-proxy /etc/rc.d/*tg-ws-proxy + _zm_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_left /etc/init.d/tg-ws-proxy /usr/bin/tg-ws-proxy || return 1 - [ "$rc" = 0 ] && echo "==> Готово: TG WS Proxy MTProto удалён" || echo "==> Готово: файлы удалены, можно ставить заново" + _zm_rm_done "$rc" "TG WS Proxy MTProto" } do_tg_install_socks5() { @@ -3209,10 +3408,10 @@ do_tg_remove_socks5() { echo "==> Удаляем TG WS Proxy SOCKS5" _zm_stop_svc tg-ws-proxy-go tg-ws-proxy-go echo "==> Удаляем программу, службу и настройки" - rm -f "$TG_BIN_GO" "$TG_BIN_GO.new" "$TG_INIT_GO" "$TG_VER_GO_FILE" /usr/bin/tg-ws-proxy-go.ver /etc/rc.d/*tg-ws-proxy-go + 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_left "$TG_BIN_GO" "$TG_INIT_GO" || return 1 - echo "==> Готово: TG WS Proxy SOCKS5 удалён" + _zm_rm_done "$rc" "TG WS Proxy SOCKS5" } do_tg_install_rust() { @@ -3264,10 +3463,10 @@ do_tg_remove_rust() { echo "==> Удаляем TG WS Proxy Rust" _zm_stop_svc tg-ws-proxy-rs tg-ws-proxy-rs echo "==> Удаляем программу, службу и настройки" - rm -f "$TG_BIN_RS" "$TG_INIT_RS" "$TG_SECRET_RS_FILE" "$TG_VER_RS_FILE" /usr/bin/tg-ws-proxy-rs.ver /etc/rc.d/*tg-ws-proxy-rs + 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_left "$TG_BIN_RS" "$TG_INIT_RS" || return 1 - echo "==> Готово: TG WS Proxy Rust удалён" + _zm_rm_done "$rc" "TG WS Proxy Rust" } tg_action() { @@ -3409,11 +3608,10 @@ do_tgws_remove() { nft delete table inet tgws >/dev/null 2>&1 _zm_pkg_purge tgws || rc=1 echo "==> Чистим файлы" - rm -rf /etc/*tgws* /var/lib/*tgws* /var/lock/*tgws* /etc/rc.d/*tgws* /etc/init.d/*tgws* /usr/sbin/*tgws* /usr/bin/*tgws* /etc/config/*tgws* + _zm_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" - echo " ✓ Файлы, правила файрвола и настройки удалены" - _zm_left /etc/init.d/tgws /usr/sbin/tgws || return 1 - [ "$rc" = 0 ] && echo "==> Готово: sTGWS удалён" || echo "==> Готово: файлы удалены, можно ставить заново" + _zm_rm_done "$rc" "sTGWS" } do_tgws_restart() { @@ -3922,7 +4120,7 @@ zapret_opt_set() { case "$val" in on) _zo_rkn_on && { mkdir -p "$ZM_STATE_DIR"; touch "$ZO_RKN_FLAG"; status; return 0; } - _zo_rkn_fits || { echo '{"error":"текущая стратегия не подходит для списков РКН — выберите одну из v1–v10"}'; return 1; } + _zo_rkn_fits || { echo '{"error":"текущая стратегия не подходит для списков РКН — выберите другую"}'; return 1; } tmp="$ZO_RKN_FILE.zmdl" mkdir -p "$(dirname "$ZO_RKN_FILE")" rm -f "$tmp" @@ -4275,7 +4473,7 @@ _zm_update_fetch() { _zm_job_label() { case "$1" in - steer) echo "Steer" ;; awg) echo "AmneziaWG" ;; forkop) echo "Forkozz" ;; + 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" ;; @@ -4720,12 +4918,12 @@ do_mixomo_install() { } do_mixomo_remove() { - local fw_section + local fw_section rc=0 echo "==> Останавливаем Mihomo" _zm_svc_off mihomo killall mihomo >/dev/null 2>&1 echo "==> Удаляем Mihomo, его настройки, подписку и веб-панель" - rm -f /etc/init.d/mihomo /etc/rc.d/*mihomo "$MIHOMO_BIN" + _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" @@ -4733,19 +4931,19 @@ do_mixomo_remove() { rm -f "$MIXOMO_WARP_CONF" fi fi - rm -rf "$MIHOMO_DIR" /tmp/mihomo* - echo " ✓ Файлы Mihomo удалены" + _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 - $DELETE hev-socks5-tunnel + _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 - rm -rf /etc/hev-socks5-tunnel - uci -q delete hev-socks5-tunnel.@instance[0] - uci commit hev-socks5-tunnel 2>/dev/null + _zm_wipe /etc/hev-socks5-tunnel || rc=1 echo "==> Удаляем интерфейс Mihomo и зону файрвола" uci -q delete network.Mihomo @@ -4766,13 +4964,13 @@ do_mixomo_remove() { echo "==> Удаляем MagiTrickle и его списки" _zm_svc_off magitrickle nft delete table inet magitrickle >/dev/null 2>&1 - $DELETE magitrickle - rm -rf /etc/magitrickle /etc/init.d/magitrickle /etc/rc.d/*magitrickle + _zm_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 - echo "==> Mixomo удалён полностью" _zm_after_remove "Mixomo" + _zm_rm_done "$rc" "Mixomo" } mixomo_action() { local action="$1" @@ -5413,7 +5611,7 @@ health() { [ -f "$CONF" ] && [ "$(_ipv6_enabled_in_zapret)" = false ] && ip -6 route show default 2>/dev/null | grep -q . && v6=true printf '{"zapret":%s,"zapret2":%s,"bytetube":%s,"tg":%s,"mixomo":%s,"doh":%s,"hosts":%s,"steer":%s,"steer_off":%s,"steer_exit":"%s","awg":%s,"forkop":%s,"flow_warn":%s,"ipv6_warn":%s,"reboot_hint":"%s"}\n' \ "$zr" "$zr2" "$bt" "$tg" "$mx" "$doh" "$hs" "$sr" \ - "$([ -f /etc/zm-steer/stopped ] && echo true || echo false)" "$sx" "$(_awg_health)" "$(_fk_health)" "$fw" "$v6" "$(esc "$(head -n1 "$ZM_REBOOT_HINT" 2>/dev/null)")" + "$([ -f /etc/zm-steer/stopped ] && echo true || echo false)" "$sx" "$(_awg_health)" "$(_fk_health)" "$fw" "$v6" "$(esc "$(_zm_reboot_hint_get)")" } VERSIONS_CACHE="$ZM_STATE_DIR/versions.json" @@ -6929,18 +7127,19 @@ do_bytetube_uninstall() { for d in $(dnsmasq_confdirs 2>/dev/null || dnsmasq_confdir); do rm -f "$d/bytetube.conf"; done fi echo "==> Удаляем файлы и настройки ByeTube" - rm -rf /etc/config/bytetube /etc/init.d/bytetube /etc/rc.d/*bytetube /etc/hotplug.d/firewall/90-bytetube \ + 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 + /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 - echo " ✓ ByeTube удалён" [ "$ZM_NO_AFTER" = 1 ] || _zm_after_remove "ByeTube" + _zm_rm_done "$rc" "ByeTube" } do_bytetube_purge() { ZM_NO_AFTER=1 do_bytetube_uninstall @@ -7068,14 +7267,14 @@ do_doh_install() { } do_doh_remove() { - local r + local r rc=0 echo "==> Останавливаем DNS over HTTPS — dnsmasq возвращается к прежним серверам" _zm_svc_off https-dns-proxy killall https-dns-proxy >/dev/null 2>&1 echo "==> Удаляем пакеты" - $DELETE luci-i18n-https-dns-proxy-ru luci-app-https-dns-proxy https-dns-proxy + _zm_pkg_wipe luci-i18n-https-dns-proxy-ru luci-app-https-dns-proxy https-dns-proxy || rc=1 echo "==> Удаляем настройки и правила перехвата DNS" - rm -f /etc/config/https-dns-proxy /etc/config/https-dns-proxy-opkg /etc/init.d/https-dns-proxy /etc/rc.d/*https-dns-proxy "$DOH_OFF_FLAG" "$DOH_FORCE_MODE_FILE" "$DOH_BOOT_FILE" + _zm_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" || rc=1 _zm_dns_scrub '^127\.0\.0\.1#50[0-9][0-9]$' for r in $(uci -q show firewall | sed -n "s/^firewall\.\([^.=]*\)\.name='[^']*[Hh]ttps[-_ ]dns[-_ ]proxy[^']*'\$/\1/p"); do uci -q delete "firewall.$r"; done uci -q commit firewall @@ -7083,7 +7282,7 @@ do_doh_remove() { _zm_cron_drop 'https-dns-proxy' "DoH" echo "==> Перезапускаем DNS роутера" /etc/init.d/dnsmasq restart >/dev/null 2>&1 - echo "==> Готово, DNS over HTTPS удалён полностью" + _zm_rm_done "$rc" "DNS over HTTPS" } doh_remove() { job_start doh_remove do_doh_remove @@ -7556,9 +7755,10 @@ stl_engine_remove() { for p in "$@"; do case "$p" in steer-core|steer|steer-extended|libsteer|libsteer-wolfssl) core="$core $p" ;; *) mods="$mods $p" ;; esac; done [ -n "$mods$core" ] || return 0 _stl_say "Удаляем ядро steer" - [ -n "$mods" ] && $DELETE $mods >&2 - [ -n "$core" ] && $DELETE $core >&2 - return 0 + local rc=0 + [ -n "$mods" ] && { _zm_pkg_wipe $mods >&2 || rc=1; } + [ -n "$core" ] && { _zm_pkg_wipe $core >&2 || rc=1; } + return $rc } stl_leftovers() { @@ -7867,12 +8067,12 @@ function jfield(j, k, 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, hp) { +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) - if (f == "shadowsocks" && index(r, "@") == 0) { j = b64d(r); if (index(j, "@")) r = j } 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 = "" @@ -9014,7 +9214,7 @@ _st_cat_build() { p = $1; v = $2 if (p == "version" || p == "base_url" || p == "generated_at") { top[p] = v; next } n = split(p, a, ".") - if (a[1] != "domain_lists" && a[1] != "categories") next + 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 @@ -9024,17 +9224,18 @@ _st_cat_build() { base = top["base_url"]; sub(/\/+$/, "", base) for (j = 1; j <= cnt; j++) { e = order[j]; id = f[e, "id"]; if (id == "") continue - if (index(skip, " " tolower(id) " ")) { bad[e] = 1; continue } - if (f[e, "source_name"] == "" && f[e, "source"] == "") { bad[e] = 1; continue } + 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/ ? "s" : "d") > idx; sets++ } + 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 !~ /^domain_lists/ || bad[e] || f[e, "id"] == "") continue + 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]) @@ -9048,8 +9249,10 @@ _st_cat_build() { } function line(e, st, nm, grp, u, b) { nm = clean(f[e, "name_ru"]); if (nm == "") nm = f[e, "id"] - grp = clean(f[e, "source_name"]); if (grp == "") return - if (f[e, "source"] == "itdoginfo/allow-domains") { + 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 (!fo[e] && f[e, "source"] == "itdoginfo/allow-domains") { u = f[e, "url"]; b = u; sub(/.*\//, "", b); sub(/\.srs$/, "", b) if (index(bsets, " " b " ") || index(bnames, "|" tolower(nm) "|")) return } @@ -10006,7 +10209,7 @@ do_steer_warp_recreate() { do_steer_remove() { _st_phase remove _rb_say "Удаляем Steer и туннель WARP" - local foreign="" p pkgs="" + local foreign="" p pkgs="" rc=0 [ "$(_st_blocker)" = splify2 ] && foreign=splify2 [ -z "$foreign" ] && foreign="$(stl_busy_by)" if [ -n "$foreign" ]; then @@ -10037,20 +10240,19 @@ do_steer_remove() { fi _st_vpn_zone off for p in $(sed -n 's/^pkg \(steer[a-z0-9-]*\)$/\1/p' "$ST_OWNED" 2>/dev/null); do pkgs="$pkgs $p"; done - [ -n "$pkgs" ] && stl_engine_remove $pkgs - _st_owns "pkg conntrack" && $DELETE conntrack >&2 + [ -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" && $DELETE "$p" >&2 + _st_owns "pkg $p" && { _zm_pkg_purge "$p" || rc=1; } done fi _rb_rpcd_ensure - rm -rf "$ST_DIR" "$ST_RUN" - rm -f "$ST_TGWS_WARP" + _zm_wipe "$ST_DIR" "$ST_RUN" "$ST_TGWS_WARP" || rc=1 [ -n "$foreign" ] || stl_leftovers - _rb_say "Steer удалён" _zm_after_remove "Steer" + _zm_rm_done "$rc" "Steer" } steer_status() { @@ -10509,7 +10711,7 @@ _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 + sed -i "/^fw $ST_VPN_ZONE\$/d" "$ST_OWNED" 2>/dev/null fi } @@ -10565,10 +10767,12 @@ _st_need_ext() { } # Модули ядра для протоколов подписки ФАЙЛ (hysteria2 и прокси ставятся отдельными пакетами) _st_need_mods() { - local f + local f names="" f="$(stl_sub_mods "$1")" _st_feats_ok $f && return 0 - _rb_say "В подписке есть $(printf '%s' "$f" | sed 's/vpn//; s/hy2/Hysteria2/; s/proxy/Trojan, Shadowsocks, SOCKS, HTTP или VMess/; s/^ *//; s/ \\{1,\\}/, /g') — ставим модули ядра steer для них" + 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; } @@ -11291,6 +11495,23 @@ _awg_zone_of() { 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 }') @@ -11306,7 +11527,8 @@ _awg_health() { a="$(_awg_hs_age "$i")" [ -n "$a" ] && [ "$a" -lt 180 ] && up=$((up + 1)) done - if [ "$n" = 0 ]; then st=5 + 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 @@ -11332,8 +11554,8 @@ _awg_iface_json() { desc="$(uci -q get "network.$p.description")" [ "$(uci -q get "network.$p.public_key")" = "$AWG_WARP_PEER" ] && warp=true _awg_is_steer "$i" && owner=steer - printf '{"name":"%s","owner":"%s","up":%s,"enabled":%s,"address":"%s","mtu":"%s","endpoint":"%s","hs_age":"%s","rx":%s,"tx":%s,"zone":"%s","route_all":%s,"warp":%s,"desc":"%s"}' \ - "$i" "$owner" "$up" "$([ "$auto" = 0 ] && echo false || echo true)" "$(esc "$addr")" "$(esc "$mtu")" "$(esc "$ep")" "$age" \ + 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")" } @@ -11365,27 +11587,48 @@ do_awg_install() { } do_awg_remove() { - local p i + 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_is_steer "$i" || grep -qxF "$i" "$AWG_DIR/owned" 2>/dev/null || continue - _awg_say "Удаляем интерфейс $i с его пиром и зоной файрвола" - _awg_delete "$i" + _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 - $DELETE "$p" + _zm_pkg_purge "$p" || rc=1 done _awg_say "Выгружаем модуль ядра и чистим файлы" rmmod amneziawg >/dev/null 2>&1 [ -d /sys/module/amneziawg ] && _zm_reboot_hint "AmneziaWG удалён, но его модуль ядра ещё загружен. Перезагрузите роутер, чтобы он выгрузился." sed -i -E '/^pkg (kmod-amneziawg|amneziawg-tools|luci-proto-amneziawg|luci-app-amneziawg|luci-i18n-amneziawg-ru)$/d' "$ST_OWNED" 2>/dev/null - rm -rf "$AWG_DIR" 2>/dev/null + _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 - _awg_say "Готово: AmneziaWG удалён полностью" + if [ "$ZM_NO_NETCHECK" = 1 ]; then : + elif _zm_inet_wait 9; then echo " ✓ Интернет на роутере работает" + else echo "!! Интернет на роутере пока не отвечает — подождите минуту или перезагрузите роутер"; fi + _zm_rm_done "$rc" "AmneziaWG" } _awg_valid_ep() { printf '%s' "$1" | grep -Eq '^(\[[0-9A-Fa-f:]+\]|[A-Za-z0-9.-]+):[0-9]{1,5}$'; } @@ -12074,7 +12317,7 @@ _awg_export() { _awg_delete() { local i="$1" p z - ifdown "$i" >/dev/null 2>&1 + _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 @@ -12116,11 +12359,12 @@ awg_action() { if _awg_is_steer "$i"; then [ -s "$ST_OWNED" ] && { echo '{"error":"туннели WARP для Steer ещё настроены — сначала удалите Steer на его странице"}'; return 1; } else - grep -qxF "$i" "$AWG_DIR/owned" 2>/dev/null || ep="$ep $i" + _awg_owned "$i" || ep="$ep $i" fi done - [ -n "$ep" ] && { printf '{"error":"%s"}\n' "$(esc "интерфейсы${ep} созданы не панелью — удалите их сами (Сеть → Интерфейсы), затем удалите AmneziaWG")"; return 1; } - job_start awg do_awg_remove + [ -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; } @@ -12203,7 +12447,8 @@ awg_action() { up) uci -q delete "network.$mode.auto"; uci commit network; ifup "$mode" >/dev/null 2>&1 ;; down) _awg_is_steer "$mode" && { echo '{"error":"туннели Steer выключаются на странице Steer"}'; return 1; } - uci set "network.$mode.auto=0"; uci commit network; ifdown "$mode" >/dev/null 2>&1 ;; + _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' ;; @@ -12241,6 +12486,7 @@ awg_action() { 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' ;; @@ -12252,6 +12498,166 @@ awg_action() { 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" @@ -12261,6 +12667,7 @@ 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 ]; } @@ -12270,7 +12677,7 @@ _fk_version() { sed -n 's/^NETSHIFT_VERSION="\([^"]*\)".*/\1/p' /usr/lib/netshif _fk_enabled() { ls /etc/rc.d/S*netshift >/dev/null 2>&1; } _fk_up() { nft list table inet "$FK_NFT" >/dev/null 2>&1 && pidof sing-box >/dev/null 2>&1; } _fk_sb_ver() { command -v sing-box >/dev/null 2>&1 && sing-box version 2>/dev/null | head -n1 | awk '{print $NF}'; } -_fk_sb_pkg() { local p; for p in sing-box-tiny sing-box sing-box-extended; do _pkg_is_installed "$p" && { echo "$p"; return 0; }; done; return 1; } +_fk_sb_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)" @@ -12303,6 +12710,14 @@ _fk_ucj() { 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")" @@ -12436,8 +12851,9 @@ _fk_sb_pkg_switch() { [ -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 sing-box sing-box-tiny sing-box-extended; do + for p in $FK_SB_PKGS; do [ "$p" = "$pkg" ] && continue _pkg_is_installed "$p" && $DELETE "$p" >/dev/null 2>&1 done @@ -12457,6 +12873,7 @@ _fk_sb_pkg_switch() { [ -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 } @@ -12467,7 +12884,7 @@ _fk_sb_pkg_switch() { # остаётся на месте. _fk_sb_drop_pkgs() { local p had="" k="$JOBS_DIR/fk-sb-keep" - for p in sing-box sing-box-tiny sing-box-extended; do _pkg_is_installed "$p" && had="$had $p"; done + 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 @@ -12531,7 +12948,7 @@ _fk_sb_purge() { fi v="$(_fk_sb_ver)" var="$(_fk_sb_var)" - for p in sing-box-extended sing-box sing-box-tiny; do _pkg_is_installed "$p" && had="$had $p"; done + 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 ]; then _fk_say "sing-box не установлен — пропускаем" return 0 @@ -12551,15 +12968,14 @@ _fk_sb_purge() { for p in $had; do _zm_pkg_purge "$p"; done if [ -e /usr/bin/sing-box ]; then echo " → Удаляем оставшийся файл /usr/bin/sing-box" - rm -f /usr/bin/sing-box + rm -f /usr/bin/sing-box 2>/dev/null fi echo " → Удаляем настройки и кэш sing-box" - rm -rf /etc/sing-box /etc/config/sing-box /var/run/sing-box /tmp/sing-box /usr/share/sing-box - rm -f /usr/lib/libcronet.so /usr/libexec/sing-box-core /etc/sing-box-version.cache /usr/lib/libcronet.so.forkop-backup.* /usr/bin/sing-box.forkop-backup.* 2>/dev/null - [ -e /etc/init.d/sing-box ] && ! _fk_sb_pkg >/dev/null && rm -f /etc/init.d/sing-box - rm -f /etc/rc.d/*sing-box "$FK_SB_OWN" - if [ -z "$(_fk_sb_ver)" ] && [ ! -e /usr/bin/sing-box ]; then - echo " ✓ sing-box удалён" + 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 /usr/lib/libcronet.so.forkop-backup.* /usr/bin/sing-box.forkop-backup.* \ + /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 удалить до конца не получилось" @@ -12705,7 +13121,7 @@ _fk_legacy_remove() { [ -x /usr/bin/forkop ] && _zm_run 60 /usr/bin/forkop restore_dnsmasq >/dev/null 2>&1 nft delete table inet ForkopTable >/dev/null 2>&1 nft delete table inet ForkopTableDpiGuard >/dev/null 2>&1 - for p in luci-i18n-forkop-ru luci-app-forkop forkop; do _pkg_is_installed "$p" && $DELETE "$p" >/dev/null 2>&1; done + _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* \ @@ -12861,7 +13277,8 @@ do_fk_install() { echo " ✓ Настройки перенесены" restored=1 ;; *) - echo "ОШИБКА: настройки Forkop не перенеслись: $(printf '%s' "$notes" | jsonfilter -e '@.error' 2>/dev/null)" + 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" @@ -12871,6 +13288,7 @@ do_fk_install() { _fk_legacy_remove rm -rf "$lconf" fi + _fk_device_setup || return 1 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 @@ -12894,45 +13312,147 @@ do_fk_install() { 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 оставляем на роутере, но останавливаем — без Forkozz ему нечего делать" + [ -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 " ✓ sing-box остановлен и убран из автозапуска" +} + do_fk_remove() { - local p + 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 - _zm_run 90 /etc/init.d/netshift stop >/dev/null 2>&1 /etc/init.d/netshift disable >/dev/null 2>&1 + _zm_run 90 /etc/init.d/netshift stop >/dev/null 2>&1 + fi + if pidof sing-box >/dev/null 2>&1 && [ -z "$(_fk_sb_users)" ]; then + [ -x /etc/init.d/sing-box ] && /etc/init.d/sing-box stop >/dev/null 2>&1 + pidof sing-box >/dev/null 2>&1 && { killall sing-box >/dev/null 2>&1; sleep 1; killall -9 sing-box >/dev/null 2>&1; } fi echo " ✓ Forkozz остановлен" - _fk_say "Возвращаем DNS роутера (dnsmasq) к прежним настройкам" - _fk_dnsmasq_clean - _fk_say "Убираем маршруты и таблицу маршрутизации Forkozz" - _fk_ip_clean - _fk_say "Удаляем правила файрвола (nftables: $FK_NFT)" + _fk_say "Снимаем перехват трафика (правила файрвола и маршруты Forkozz)" nft delete table inet "$FK_NFT" >/dev/null 2>&1 - nft list table inet "$FK_NFT" >/dev/null 2>&1 && echo "!! Таблица $FK_NFT не удалилась" || echo " ✓ Правила файрвола убраны" + nft list table inet "$FK_NFT" >/dev/null 2>&1 && echo "!! Правила файрвола Forkozz не снялись — они пропадут после перезагрузки роутера" || echo " ✓ Правила файрвола убраны" + _fk_ip_clean + _fk_say "Возвращаем DNS роутера к обычной работе" + _fk_dnsmasq_clean + /etc/init.d/dnsmasq restart >/dev/null 2>&1 + echo " ✓ DNS перезапущен — интернет идёт напрямую" + [ -n "$tun" ] && _fk_tunnels_off "$tmode" $tun _fk_legacy && _fk_legacy_remove - for p in luci-i18n-netshift-ru luci-app-netshift netshift; do _pkg_is_installed "$p" && $DELETE "$p"; done + _zm_pkg_wipe luci-i18n-netshift-ru luci-app-netshift netshift || rc=1 _fk_say "Удаляем файлы, настройки и кэш Forkozz" - rm -rf /usr/lib/netshift /usr/lib/netshift.zm-old /etc/netshift /tmp/netshift* /tmp/sing-box/rulesets /tmp/sing-box/subscription* "$JOBS_DIR/netshift-src" \ - /www/luci-static/resources/view/netshift /usr/share/luci/menu.d/luci-app-netshift.json /usr/share/rpcd/acl.d/luci-app-netshift.json - rm -f /usr/bin/netshift /etc/init.d/netshift /etc/rc.d/*netshift /etc/config/netshift /etc/config/netshift-070 /var/run/netshift* \ - "$FK_SAVE" "$FK_MARK" "$ZM_STATE_DIR/latest.netshift" "$ZM_STATE_DIR"/fk.sub.* "$ZM_STATE_DIR"/fk.fell* "$ZM_STATE_DIR/fk.dnsfo" "$JOBS_DIR/netshift.conf.bak" + _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.dnsfo" "$ZM_STATE_DIR/fk.watch" "$FK_EXT_FAIL" "$JOBS_DIR/netshift.conf.bak" && + echo " ✓ Файлы и настройки удалены, остатков нет" || rc=1 sed -i '/netshift/d' /etc/sysupgrade.conf 2>/dev/null - echo " ✓ Файлы и настройки удалены" if grep -q 'netshift' "$CRON_FILE" 2>/dev/null; then _fk_say "Убираем задания Forkozz из расписания (cron)" sed -i '/netshift/d' "$CRON_FILE" /etc/init.d/cron restart >/dev/null 2>&1 echo " ✓ Расписание очищено" fi - _fk_sb_purge || true - _fk_say "Перезапускаем DNS роутера" - /etc/init.d/dnsmasq restart >/dev/null 2>&1 - echo " ✓ dnsmasq перезапущен" + 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 - if [ -e /usr/bin/sing-box ]; then _fk_say "Forkozz удалён полностью — вместе с настройками и правилами (sing-box оставлен)" - else _fk_say "Forkozz удалён полностью — вместе с настройками, правилами и sing-box"; fi _zm_after_remove "Forkozz" + _zm_rm_done "$rc" "Forkozz" } do_fk_sb_remove() { @@ -13010,6 +13530,7 @@ do_fk_service() { _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 @@ -13050,7 +13571,13 @@ do_fk_service() { _fk_lists_report _fk_lists_errors _fk_ext_auto all "$lx" + _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 @@ -13129,7 +13656,7 @@ _fk_ext_auto() { _rb_warn "${why}, а расширенный sing-box поставить не удалось — такие серверы пока пропускаются" return 0 fi - _fk_say "${why} — облегчённый sing-box их не умеет, ставим расширенный" + _fk_say "${why} — нынешний sing-box их не умеет, ставим расширенный" if do_fk_singbox extended "$3"; then rm -f "$FK_EXT_FAIL" else mkdir -p "$ZM_STATE_DIR"; touch "$FK_EXT_FAIL"; _rb_warn "Расширенный sing-box не встал — Forkozz работает на прежнем, такие серверы пропускаются"; fi return 0 @@ -13316,8 +13843,9 @@ steer_explain() { } fk_route_check() { - local q addr="" how="" verdict=direct tr rule="" ob="" ac="" sec="" s isip=0 caught=0 - q="$(_zm_route_target "$1")" || { echo '{"error":"введите домен (например, youtube.com) или IP-адрес"}'; return 1; } + local q addr="" how="" verdict=direct tr rule="" ob="" ac="" sec="" s isip=0 caught=0 arg="$1" source="" err + 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; } if printf '%s' "$q" | grep -Eq '^[0-9.]+$|:'; then addr="$q"; isip=1; else addr="$(_zm_resolve_any "$q")"; fi @@ -13329,10 +13857,12 @@ fk_route_check() { nft get element inet "$FK_NFT" "$s" "{ $addr }" >/dev/null 2>&1 && { caught=1; how=subnet; break; } done ;; esac - if command -v timeout >/dev/null 2>&1; then tr="$(timeout 40 ucode "$FK_UC" route "$q" "$addr" 2>/dev/null)" - else tr="$(_fk_uc route "$q" "$addr" 2>/dev/null)"; fi + if command -v timeout >/dev/null 2>&1; then tr="$(timeout 40 ucode "$FK_UC" route "$q" "$addr" "$source" 2>/dev/null)" + else tr="$(_fk_uc route "$q" "$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)" @@ -13340,14 +13870,17 @@ fk_route_check() { ;; 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 - printf '{"target":"%s","verdict":"%s","how":"%s","addr":"%s","ip":%s,"rule":"%s","outbound":"%s","action":"%s","section":"%s"}\n' \ - "$(esc "$q")" "$verdict" "$how" "$(esc "$addr")" "$([ "$isip" = 1 ] && echo true || echo false)" "$(esc "$rule")" "$(esc "$ob")" "$(esc "$ac")" "$(esc "$sec")" + printf '{"target":"%s","verdict":"%s","how":"%s","addr":"%s","ip":%s,"rule":"%s","outbound":"%s","action":"%s","section":"%s","source":"%s"}\n' \ + "$(esc "$q")" "$verdict" "$how" "$(esc "$addr")" "$([ "$isip" = 1 ] && echo true || echo false)" "$(esc "$rule")" "$(esc "$ob")" "$(esc "$ac")" "$(esc "$sec")" \ + "$(esc "$source")" } forkop_action() { @@ -13357,7 +13890,8 @@ forkop_action() { b="$(_fk_blocker)" [ -n "$b" ] && { printf '{"error":"%s"}\n' "$(esc "$(_fk_blocker_text "$b")")"; return 1; } job_start forkop do_fk_install "$a" ;; - remove) job_start forkop do_fk_remove ;; + 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 ;; @@ -13709,12 +14243,17 @@ case "$cmd" in 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" ;; 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" ;; @@ -13917,14 +14456,25 @@ function catalog_items(known_list) { 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 = []; + 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) ]; + 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: grp, targets: uniq(t) }); + push(items, { id, name: s(f[1]) || id, group: itd ? "" : grp, cat: itd && cat, targets: uniq(t) }); } return items; } @@ -14087,6 +14637,17 @@ function subnet_lines(m) { 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), ...words(m.fully_routed_ips) ]); +} + function url_ext(u) { let m = match(lc(replace(s(u), /[?#].*$/, "")), /\.([a-z0-9]+)$/); return m ? m[1] : ""; @@ -14134,7 +14695,8 @@ function sec_summary(c, sec, main) { 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)) + domains: length(domain_lines(m)), subnets: length(subnet_lines(m)), own_off: own_off(m), + full: device_ips(m) }; } @@ -14200,6 +14762,34 @@ function valid_ip(v) { 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 == "" || match(v, /[ \t@|,;'"<>]/)) return false; if (t == "udp") return valid_ip(replace(v, /:[0-9]+$/, "")) && index(v, "/") < 0; @@ -14245,6 +14835,110 @@ function set_list(c, sec, key, list) { else c.delete(CFG, sec, key); } +function write_device_lists(c, sec, full, excl) { + set_list(c, sec, "zm_rule_source_ips", full); + c.delete(CFG, sec, "fully_routed_ips"); + if (!c.get(CFG, "settings")) c.set(CFG, "settings", "settings"); + 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; +} + +function cmd_device_setup() { + let patched = patch_device_engine(), c = cursor(), migrated = false; + let legacy = filter(sections(c, "section"), (x) => is_conn(x) && length(words(x.fully_routed_ips))); + if (length(legacy)) { + let backup = STATE + "/netshift.before-device-rules.conf"; + if (!fs.access(backup)) { + let original = fs.readfile("/etc/config/" + CFG); + if (original == null || fs.writefile(backup, original) == null || system("chmod 0600 " + q(backup)) != 0) + fail("не удалось сохранить настройки перед переносом устройств"); + } + for (let x in legacy) { + set_list(c, x[".name"], "zm_rule_source_ips", device_ips(x)); + c.delete(CFG, x[".name"], "fully_routed_ips"); + } + c.save(CFG); + c.commit(CFG); + migrated = true; + } + out({ ok: true, changed: patched || migrated, 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); @@ -14340,6 +15034,8 @@ function kw_list(v) { 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)), @@ -14348,6 +15044,8 @@ function sec_ext(m) { 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), @@ -14441,7 +15139,7 @@ function set_flag(c, sec, key, on) { /* Дополнительные опции секции. 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" ]; + 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) || {}); @@ -14456,6 +15154,7 @@ function write_sec_ext(c, sec, mode, x) { 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"); @@ -14475,9 +15174,12 @@ function write_sec_ext(c, sec, mode, x) { 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" ]); + 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 для туннеля"); @@ -14603,10 +15305,11 @@ function cmd_get(want) { jsons: mode == "json" ? 1 : 0, refs: { c: rf.c, s: rf.s, r: rf.r }, catalog: { items: catalog_items(services_list()), meta: cat_meta() }, - domains: domain_lines(m), - subnets: subnet_lines(m), - lists: rf.l, - full: uniq(words(mm.fully_routed_ips)), + 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), excl: uniq(words(st.routing_excluded_ips)), extra: p.extra, dns: dns_info(st), @@ -14684,16 +15387,22 @@ function cmd_set() { for (let x in subnets) if (!valid_ip(x)) fail("это не похоже на IP или подсеть: " + x); let lists = uniq(tokens(d.lists)); for (let x in lists) if (!valid_url(x)) fail("внешний список должен быть ссылкой https://…: " + x); + 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 = uniq(tokens(d.full)), excl = uniq(tokens(d.excl)); + 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)); for (let x in [ ...full, ...excl ]) if (!valid_ip(x)) fail("неверный адрес устройства: " + x); - excl = filter(excl, (x) => index(full, x) < 0); - if (!length(services) && !length(rsets) && !length(rsubs) && !length(domains) && !length(subnets) && !length(lists) && !(is_main && length(full))) - fail(is_main ? "выберите хотя бы один сервис, домен или устройство" : "выберите для секции хотя бы один сервис или домен"); + if (!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; + 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)); @@ -14756,12 +15465,15 @@ function cmd_set() { write_sec_ext(c, sec, mode, d.x); set_lists(c, sec, { s: rsets, r: rsubs, l: lists }, services, domains, subnets); - if (is_main || d.full != null) { - set_list(c, mainsec, "fully_routed_ips", full); - if (!c.get(CFG, "settings")) c.set(CFG, "settings", "settings"); - set_list(c, "settings", "routing_excluded_ips", excl); + if (own_on) { + 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); } - if (!is_main) c.delete(CFG, sec, "fully_routed_ips"); + write_device_lists(c, sec, full, excl); if (bp) { if (length(bdom) || length(bsub)) { @@ -14835,6 +15547,125 @@ function cmd_set() { /* ---------- 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 = {}; @@ -14846,13 +15677,16 @@ function sec_names(c, sec) { } } 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; - return { name: s(names[t] || t), country: "", type: s(p.type), now: s(p.now || ""), delay: h ? int(h.delay || 0) : -1, - members: lc(s(p.type)) == "urltest" && type(p.all) == "array" ? p.all : null }; + 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) { @@ -14931,13 +15765,17 @@ function cmd_servers(sec) { let px = proxies(); if (!px) fail("sing-box не отвечает — Forkozz выключен или ещё запускается"); let g = px[group_tag(sec)]; - if (!g || type(g.all) != "array") fail("список серверов ещё не готов — примените настройки"); + let 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 = {}; - for (let t in g.all) { + 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]) nodes[m] = node_info(px, names, m); + 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 = ""; } @@ -14948,8 +15786,8 @@ function cmd_servers(sec) { for (let t in (type(lat.tested) == "array" ? lat.tested : [])) if (nodes[t]) nodes[t].delay = int(dl[t] || 0); if (lrun) for (let t in (type(lat.pending) == "array" ? lat.pending : [])) if (nodes[t]) nodes[t].delay = -2; } - out({ group: group_tag(sec), now: s(g.now), list: g.all, nodes, sub: sec_mode(xm) == "sub" ? sub_info(sec) : null, - hidden: length(xv.h), exclude: s(xm.zm_exclude), hide_names: xv.h, fell, lat: lat ? { running: lrun, at: int(lat.at || 0), error: s(lat.error) } : 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: lat ? { running: lrun, at: int(lat.at || 0), error: s(lat.error) } : null }); } /* ---------- точная проверка сервера (как делают клиенты вроде Happ) ---------- @@ -14959,6 +15797,7 @@ function cmd_servers(sec) { 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; @@ -14996,9 +15835,8 @@ function lat_parse(raw) { /* пачка серверов параллельно (не больше LAT_PAR за раз — слабый роутер иначе сам даёт ложные таймауты) */ function lat_batch(ep, tags, url, ms) { let res = {}; - let dir = STATE + "/lat.tmp." + time(); - try { fs.mkdir(STATE); } catch (e) {} - try { fs.mkdir(dir); } catch (e) {} + 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++) @@ -15085,6 +15923,247 @@ 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]; + } + let ranked = sort(filter(keys(delays), (t) => delays[t] > 0), (a, b) => delays[a] - delays[b]), best = ""; + 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 : node_alive(ep, ranked[i], urls)) { best = ranked[i]; break; } + } + let target = cur; + if (!cur_ok) target = best; + else if (best != "" && best != cur && delays[cur] > 0 && delays[cur] > delays[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; } @@ -15095,8 +16174,7 @@ function cmd_heal(sec, act) { 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) }); return; } - let pickt = null; - for (let t in g.all) if (px[t] && lc(s(px[t].type)) == "urltest") { pickt = t; break; } + let pickt = auto_tag(sec, px, g); if (!pickt) for (let t in g.all) { if (t == now) continue; @@ -15131,13 +16209,16 @@ function cmd_latency(sec) { * автовыбор подписки) получает лучшую задержку своих серверов. */ function latwork(sec) { let px = proxies(), g = px ? px[group_tag(sec)] : null, seen = {}, order = [], groups = []; - let add = (t) => { if (!seen[t]) { seen[t] = true; push(order, t); } }; - if (g && type(g.all) == "array") - for (let t in g.all) { - let p = px[t]; - if (p && type(p.all) == "array") { push(groups, t); for (let m in p.all) add(m); } - else add(t); - } + 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 delays = {}, done = {}, err = g ? "" : "группа серверов секции ещё не готова — примените настройки"; let save = (running) => { for (let t in groups) { @@ -15186,8 +16267,20 @@ function cmd_latwork(sec) { function cmd_select(sec, tag) { if (s(tag) == "") fail("не выбран сервер"); + 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) fail("этого сервера нет в секции"); + 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) fail("не удалось сохранить ручной выбор сервера"); + } let j = jcmd(BIN + " clash_api set_group_proxy " + q(group_tag(sec)) + " " + q(tag)); if (type(j) != "object" || j.error || j.success === false) fail(s((j && (j.message || j.error)) || "сервер не переключился")); + let px = proxies(); + let fallback = fallback_info(sec, px); + if ((fallback.direct || fallback.override != "") && !select_proxy(fallback_tag(sec), group_tag(sec))) fail("сервер выбран, но резервный выход секции не переключился"); + 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) {} out({ ok: true }); } @@ -15230,7 +16323,7 @@ function cmd_hide(sec) { } else { let px = proxies(), g = px ? px[group_tag(sec)] : null; if (g && type(g.all) == "array") { - let real = filter(g.all, (t) => !(px[t] && lc(s(px[t].type)) == "urltest")); + let real = vpn_members(px, group_tag(sec)); if (length(real) && !length(filter(real, (t) => !excl_hit(t, f)))) fail("нельзя скрыть все серверы — тогда Forkozz работать не сможет"); } } @@ -15328,20 +16421,21 @@ function cmd_secstate(probe) { let g = px[group_tag(x.name)]; if (!g || type(g.all) != "array") e.state = "wait"; else { - let names = sec_names(c, x.name), now = s(g.now), cur = now; - if (px[now] && lc(s(px[now].type)) == "urltest") { e.auto = true; cur = s(px[now].now); } - e.count = length(filter(g.all, (t) => !(px[t] && lc(s(px[t].type)) == "urltest"))); + let 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(group_tag(x.name), 4000, 2); + 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 = ms > 0 ? "ok" : "bad"; + e.state = e.fallback_direct ? "direct" : ms > 0 ? "ok" : "bad"; if (ms > 0) e.delay = ms; } push(r, e); @@ -15351,24 +16445,6 @@ function cmd_secstate(probe) { /* ---------- route check: replays sing-box route rules for a domain or an IP ---------- */ -function ip4n(a) { - let m = match(s(a), /^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)$/); - return m ? ((int(m[1]) * 256 + int(m[2])) * 256 + int(m[3])) * 256 + int(m[4]) : null; -} - -function cidr_hit(ip, cidrs) { - let n = ip4n(ip); - if (n == null) return false; - for (let c in (type(cidrs) == "array" ? cidrs : [ cidrs ])) { - let p = split(s(c), "/"), b = ip4n(p[0]), bits = length(p) > 1 ? int(p[1]) : 32; - if (b == null || bits < 0 || bits > 32) continue; - let div = 1; - for (let i = bits; i < 32; i++) div *= 2; - if (int(n / div) == int(b / div)) return true; - } - return false; -} - function as_arr(v) { return v == null ? [] : type(v) == "array" ? v : [ v ]; } function dom_hit(host, r) { @@ -15404,9 +16480,10 @@ function rs_hit(tag, target, sets) { return !!match(r, /(^|\n)[^\n]*match rules/); } -function rule_hit(r, host, ip, sets) { - if (r.source_ip_cidr != null || r.protocol != null || r.port != null || r.inbound == null && r.outbound == null) return false; +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; @@ -15418,10 +16495,12 @@ function rule_hit(r, host, ip, sets) { if (host != "" && rs_hit(t, host, sets)) return true; if (ip != "" && rs_hit(t, ip, sets)) return true; } - return false; + return !has && r.source_ip_cidr != null; } -function cmd_route(target, addr) { +function cmd_route(target, addr, source) { + 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") { out({ action: "", outbound: "", section: "", rule: "" }); return; } @@ -15431,11 +16510,16 @@ function cmd_route(target, addr) { let sets = {}; for (let rs in as_arr(cfgj.route.rule_set)) sets[s(rs.tag)] = rs; let c = cursor(), p = pick(c), labels = {}; - for (let x in sec_list(c, p)) labels[group_tag(x.name)] = x; + for (let 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)) continue; + if (!rule_hit(r, host, ip, sets, source)) continue; + if (r.source_ip_cidr != null && source == "") { + out({ action: "source_required", outbound: "", section: "", rule: "" }); + return; + } if (s(r.action) == "reject") { let bs = null; for (let rt in as_arr(r.rule_set)) @@ -15449,6 +16533,10 @@ function cmd_route(target, addr) { let ob = s(r.outbound); if (ob == "direct-out") { out({ 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) { + out({ action: "fallback_direct", outbound: "direct-out", section: x.name, rule: x.label }); + return; + } out({ action: x ? "connection" : "outbound", outbound: ob, section: x ? x.name : "", rule: x ? x.label : ob }); return; } @@ -15599,9 +16687,12 @@ function cmd_stats() { let mode = ARGV[0] || ""; let sec = ARGV[1] || ""; if (mode != "import" && mode != "route" && sec != "" && !match(sec, /^[A-Za-z0-9_]+$/)) fail("неверное имя секции"); -if (sec == "" && index([ "get", "set", "secdel", "import", "route", "diag", "secnames", "secstate", "stats" ], mode) < 0) sec = pick(cursor()).sec; +if (sec == "" && index([ "get", "set", "secdel", "import", "route", "diag", "secnames", "secstate", "stats", "fallback_config", "guard" ], mode) < 0) sec = pick(cursor()).sec; -if (mode == "get") cmd_get(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(); @@ -15611,12 +16702,13 @@ else if (mode == "latwork") cmd_latwork(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 == "route") cmd_route(ARGV[1], ARGV[2]); +else if (mode == "route") cmd_route(ARGV[1], ARGV[2], ARGV[3]); else if (mode == "import") cmd_import(sec); else fail("неизвестная команда"); ZM_INSTALLER_EOF @@ -15719,6 +16811,8 @@ list_methods() { json_add_object "versions"; json_add_string "action" "string"; json_close_object json_add_object "awg_status"; json_close_object json_add_object "awg_action"; json_add_string "action" "string"; json_add_string "mode" "string"; json_close_object + json_add_object "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 "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 @@ -15824,6 +16918,8 @@ call_method() { versions) json_get_var action action; "$BACKEND" versions "$action" ;; awg_status) "$BACKEND" awg_status ;; awg_action) json_get_var action action; json_get_var mode mode; printf '%s' "$mode" | "$BACKEND" awg_action "$action" @stdin ;; + 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 ;; 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" ;; @@ -15898,6 +16994,7 @@ cat > '/usr/share/rpcd/acl.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF' "health", "versions", "awg_status", + "term_status", "steer_status", "forkop_status", "forkop_config_get", @@ -15974,6 +17071,7 @@ cat > '/usr/share/rpcd/acl.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF' "mixomo_warp_config_set", "bytetube_action", "awg_action", + "term_action", "steer_action", "forkop_config_set", "forkop_action", @@ -16045,6 +17143,11 @@ cat > '/usr/share/luci/menu.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF "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, @@ -16102,6 +17205,8 @@ function zmDeclare(o) { 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 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: {} }); @@ -16427,13 +17532,19 @@ function renderLog(logEl, text) { var msgSpan = document.createElement('span'); var msg = m[2]; if (/ОШИБКА|error|не удалось/i.test(msg)) msgSpan.className = 'zm-log-msg-error'; - else if (/Готово|успешно|примен[её]н|установлен|удал[её]н|сгенерирован|сохран[её]н|переключен|обновлен|завершен/i.test(msg)) msgSpan.className = 'zm-log-msg-ok'; + else 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; @@ -16651,6 +17762,8 @@ function pollJob(job, logEl, onDone, onTick) { 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() { @@ -16807,6 +17920,67 @@ function swRow(on, label, hint, click, disabled) { ]); } +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)); + } + }); + 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, + 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); + }); +} + function saveBar(o) { o = o || {}; var el = E('div', { 'class': 'zm-savebar', 'style': 'display:none' }); @@ -16930,7 +18104,11 @@ function rebootSlot() { el.refresh = function() { callHealth().then(function(h) { el.innerHTML = ''; - if (h && h.reboot_hint) el.appendChild(rebootBanner(h.reboot_hint)); + clearTimeout(el._t); + if (h && h.reboot_hint) { + el.appendChild(rebootBanner(h.reboot_hint)); + el._t = setTimeout(el.refresh, 125000); + } }).catch(function() {}); }; el.refresh(); @@ -17494,6 +18672,8 @@ return baseclass.extend({ health: callHealth, awgStatus: callAwgStatus, awgAction: bigArg2(callAwgAction), + termStatus: callTermStatus, + termAction: bigArg2(callTermAction), steerStatus: callSteerStatus, versions: callVersions, steerAction: bigArg2(callSteerAction), @@ -17507,6 +18687,7 @@ return baseclass.extend({ riCovers: riCovers, riNote: riNote, swRow: swRow, + dialog: dialog, saveBar: saveBar, exclParse: exclParse, exclNorm: exclNorm, @@ -17591,7 +18772,8 @@ return view.extend({ var items = []; items.push(row('Zapret', zm.stateBadge(zrSt))); - if (zrSt !== 0 && d.strategy) items.push(row('Стратегия', E('span', {}, [ String(d.strategy) ]))); + 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' }), 'по спискам РКН' ]) : '' ]))); 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 ]); }; @@ -17611,8 +18793,8 @@ return view.extend({ items.push(row('Mixomo', zm.stateBadge(st(h, 'mixomo', 0)))); items.push(row('DNS over HTTPS', dohNode)); var awgSt = st(h, 'awg', 0); - items.push(row('AmneziaWG', awgSt === 1 ? zm.badge(true, h.awg_up ? 'туннелей работает: ' + h.awg_up : 'установлен', '') - : awgSt === 3 ? warnBadge('работает ' + (h.awg_up || 0) + ' из ' + (h.awg_total || 0)) + 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, '', 'не установлен'))); @@ -18236,9 +19418,21 @@ return view.extend({ job('update', '', 'Переустанавливаем AmneziaWG'); } }, 'Переустановить')); acts.push(E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { - var own = (data.ifaces || []).filter(function(f) { return !/^zmwarp\d*$/.test(f.name); }).map(function(f) { return f.name; }); - if (!confirm('Удалить AmneziaWG с роутера полностью?\n\n' + (own.length ? 'Вместе с ним удалятся интерфейсы: ' + own.join(', ') + ' — с их пирами и зонами файрвола.\n' : '') + 'Будут удалены пакеты, модуль ядра и сохранённые конфиги.')) return; - job('remove', '', 'Удаляем AmneziaWG'); + 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)); @@ -18272,6 +19466,7 @@ return view.extend({ 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 ]); @@ -18325,8 +19520,16 @@ return view.extend({ }); } }, open[f.name] === 'conf' ? 'Скрыть конфиг' : 'Изменить конфиг')); if (!steer) acts.push(E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { - if (!confirm('Удалить интерфейс ' + f.name + '?\n\nЕго зона firewall, созданная здесь, тоже удалится.')) return; - quick('delete', f.name, f.name + ' удалён'); + 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' @@ -18550,6 +19753,141 @@ return view.extend({ }); 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'; + +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:10px 0' }); + 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 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(addr()); + return; + } + if (!show) { viewEl.style.display = 'none'; return; } + if (!frame) { + frame = E('iframe', { 'src': addr(), 'style': 'width:100%; height:480px; border:0; border-radius:8px; background:#000' }); + viewEl.innerHTML = ''; + viewEl.appendChild(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() { + if (!confirm('Удалить терминал?\n\nБудет остановлен и удалён пакет ttyd вместе с его настройками.')) return; + 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' @@ -18942,8 +20280,8 @@ return view.extend({ } }); } var remote = list.filter(function(s) { return !!s.group; }); - var svcs = list.filter(function(s) { return !s.group && CATEGORY_IDS.indexOf(s.id) < 0; }); - var cats = list.filter(function(s) { return !s.group && CATEGORY_IDS.indexOf(s.id) >= 0; }); + 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('h4', { 'style': 'margin:0' }, 'Сервисы')); listCard.appendChild(zm.svcGrid(svcs.map(tile))); if (cats.length) { @@ -19049,6 +20387,13 @@ return view.extend({ 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; } + 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 после установки') @@ -20945,6 +22290,8 @@ return view.extend({ 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 { currentBanner.appendChild(E('span', {}, 'Стратегия ещё не выбрана')); } @@ -20998,8 +22345,8 @@ return view.extend({ extraCard.appendChild(E('h3', {}, 'Дополнения к стратегии')); if (status.zapret !== 'installed') { extraCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Станут доступны после установки Zapret.')); return; } var rknHint = status.rkn && !status.rkn_on - ? '⚠ Текущая стратегия не подходит: списки РКН работают со стратегиями v1–v10 и включатся сами, когда вы выберете одну из них.' - : 'Основная стратегия будет обходить блокировку только для сайтов из списка РКН' + (status.rkn_on && status.rkn_count > 0 ? ' (' + status.rkn_count + ' доменов)' : '') + ' и вашего списка хостов, а не для всех сайтов, кроме исключений. Помогает, если стратегия мешает обычным сайтам. Список скачивается из проекта zapret4rocket, работает со стратегиями v1–v10 и сохраняется при их смене.'; + ? '⚠ Текущая стратегия не подходит для списков РКН. Они включатся сами, когда вы выберете подходящую.' + : 'Основная стратегия будет обходить блокировку только для сайтов из списка РКН' + (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)); extraCard.appendChild(zm.swRow(!!status.wssize, extraBusy === 'wssize' ? 'Блок --wssize 1:6 — применяем…' : 'Блок --wssize 1:6', 'Отдельный блок в конце стратегии: для HTTPS уменьшает окно TCP в начале соединения. Помогает с некоторыми блокировками, но может снизить скорость и стабильность. Сохраняется при смене стратегии.', function() { toggleExtra('wssize', !status.wssize); }, !!extraBusy)); } @@ -21013,7 +22360,7 @@ return view.extend({ status = res; renderExtra(); renderBanner(); renderV(); if (lastFlowseal) renderFlowseal(lastFlowseal); - zm.toast(what === 'rkn' ? (on ? (res.rkn_on ? 'Списки РКН включены' : 'Списки РКН включатся со стратегией v1–v10') : 'Списки РКН выключены') : (on ? 'Блок --wssize добавлен' : 'Блок --wssize убран'), 'info'); + zm.toast(what === 'rkn' ? (on ? (res.rkn_on ? 'Списки РКН включены' : 'Списки РКН включатся с подходящей стратегией') : 'Списки РКН выключены') : (on ? 'Блок --wssize добавлен' : 'Блок --wssize убран'), 'info'); refreshAll(); }).catch(function() { extraBusy = ''; zm.toast('Роутер не ответил', 'error'); renderExtra(); }); } @@ -23059,6 +24406,28 @@ html.zm-theme-dark .zm-fold { border-top-color: rgba(255,255,255,.1); } .zm-hide-q.zm-hide-q-on small { text-decoration: none; } .zm-hide-q:disabled { opacity: .5; cursor: default; } +.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; } +.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; } +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_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/style.css' @@ -24036,8 +25405,8 @@ function dnsBoots(t, servers) { return out.slice(0, 4); } -var TABS = [ { id: 'conn', label: 'Подключение' }, { id: 'svc', label: 'Сервисы' }, { id: 'byp', label: 'Исключения', common: true }, { id: 'dev', label: 'Устройства', common: true }, { id: 'set', label: 'Настройки', common: true } ]; -function isSecTab(id) { return id === 'conn' || id === 'svc'; } +var 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' } ]; @@ -24048,7 +25417,7 @@ 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_group: 'off', sub_plen: '2', sub_incl: '', sub_insecure: false, sub_ua: '', ut_iv: '3m', ut_tol: '50', ut_url: UT_URL_DEF, +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: '' }; @@ -24181,7 +25550,7 @@ function fromCfg(c) { 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, domains: (c.domains || []).join('\n'), subnets: (c.subnets || []).join('\n'), + items: pick.items, sel: pick.sel, own_on: c.own_on !== false, domains: (c.domains || []).join('\n'), subnets: (c.subnets || []).join('\n'), lists: pick.lists.join('\n'), full: (c.full || []).slice(), excl: (c.excl || []).slice(), dns: { type: dns.type || (dns.server ? 'udp' : 'dot'), servers: (dns.servers && dns.servers.length ? dns.servers : [ dns.server || '9.9.9.9' ]).slice(), bootstraps: (dns.bootstraps && dns.bootstraps.length ? dns.bootstraps : [ dns.bootstrap || '9.9.9.9' ]).slice(), detour: !!dns.detour }, @@ -24205,18 +25574,18 @@ function validCfg(x) { 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), + 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.excl), d.mode === 'iface' ? '' : String(d.exclude || ''), d.dns.type, d.dns.servers, d.dns.bootstraps, d.dns.detour, d.quic_off, d.list_interval, d.lists_via, String(d.label || '').trim(), d.isMain || d.enabled, tl(d.byDomains), tl(d.bySubnets) ]); } -var GLOBAL_KEYS = [ 'byDomains', 'bySubnets', 'full', 'excl', 'dns', 'quic_off', 'list_interval', 'lists_via' ].concat(Object.keys(SET_EXT)); +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.full = []; x.excl = []; x.quic_off = true; x.list_interval = '1d'; x.lists_via = false; + x.byDomains = x.bySubnets = ''; x.excl = []; x.quic_off = true; x.list_interval = '1d'; x.lists_via = false; return sig(x); } @@ -24247,6 +25616,23 @@ 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 ''; } } @@ -24258,7 +25644,8 @@ function newCfg(c) { var n = ((c && c.sections) || []).length + 1; x.sec = 'new'; x.is_main = false; x.exists = false; x.enabled = true; x.label = warp && /^zmwarp/.test(warp.name) && !((c && c.sections) || []).some(function(s) { return s.label === 'WARP'; }) ? 'WARP' : 'Секция ' + n; x.mode = 'iface'; x.iface = warp ? warp.name : ''; x.links = []; x.sub = ''; x.sub_interval = '12h'; x.exclude = ''; x.hide_names = []; - x.refs = { c: [], s: [], r: [] }; x.domains = []; x.subnets = []; x.lists = []; x.subs = []; x.x = {}; + x.refs = { c: [], s: [], r: [] }; x.own_on = true; x.domains = []; x.subnets = []; x.lists = []; x.subs = []; x.x = {}; + x.full = []; return x; } @@ -24301,7 +25688,7 @@ return view.extend({ var connCard = E('div', { 'class': 'zm-card zm-kv' }); var srvCard = E('div', { 'class': 'zm-card zm-kv' }); var checkCard = E('div', { 'class': 'zm-card zm-kv' }); - var routeCard = E('div', { 'class': 'zm-card' }), routeBox = null; + var 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' }); @@ -24466,7 +25853,7 @@ return view.extend({ 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), domains: draft.domains, + 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, 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 @@ -24508,8 +25895,10 @@ return view.extend({ if (secs().some(function(s) { return s.name !== draft.sec && s.label.toLowerCase() === ln.toLowerCase(); })) return [ 'conn', 'Секция «' + ln + '» уже есть — выберите другое название' ]; } var rf = refsOf(draft); - if (!rf.c.length && !rf.s.length && !rf.r.length && !rf.l.length && !textLines(draft.domains).length && !textLines(draft.subnets).length && !textLines(draft.lists).length && !(draft.isMain && draft.full.length)) - return [ 'svc', draft.isMain ? 'Выберите хотя бы один сервис' : 'Выберите, что пускать через секцию «' + secName() + '»' ]; + var 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.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]; @@ -24593,7 +25982,7 @@ return view.extend({ dirty = sig(draft) !== savedSig; servers = null; srvErr = ''; srvRetry = 0; hideMode = false; syncAreas(); - if (tab !== 'conn' && tab !== 'svc') tabTo('conn'); + if (!isSecTab(tab)) tabTo('conn'); renderAll(); if (st.running && secLive()) loadServers(); }).catch(function() { secBusy = false; renderSections(); zm.toast('Роутер не ответил', 'error'); }); @@ -24601,7 +25990,7 @@ return view.extend({ function deleteSec() { if (busy || saving) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } - if (!confirm('Удалить секцию «' + secName() + '»?\n\nЕё сервисы пойдут через другие секции, если они там выбраны, иначе — напрямую.')) return; + if (!confirm('Удалить секцию «' + secName() + '»?\n\nЕё правила перестанут действовать. Правила остальных секций и их выбор устройств сохранятся.')) return; zm.forkopAction('secdel', draft.sec).then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return; } rememberSec(cfg.main || ''); @@ -24625,19 +26014,20 @@ return view.extend({ function open(name) { if (common) tabTo('conn'); switchSec(name); } secCard.appendChild(E('h3', {}, [ 'Секции ', badge(list.length > 1 || isNew ? 'zm-ok' : 'zm-off', nn(list.length + (isNew ? 1 : 0), 'секция', 'секции', 'секций')) ])); secCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, common - ? 'Исключения, устройства и настройки действуют для всех секций сразу. Нажмите на секцию, чтобы вернуться к её подключению и сервисам.' - : 'Каждая секция ведёт свои сервисы через своё подключение: например, YouTube — через сервер, а ChatGPT — через WARP. Нажмите на секцию, чтобы настроить её на вкладках «Подключение» и «Сервисы».')); + ? 'Исключения, список «Мимо Forkozz» и настройки действуют для всех секций сразу. Нажмите на секцию, чтобы настроить её подключение, сервисы и устройства.' + : 'Каждая секция ведёт свои сервисы через своё подключение. На вкладке «Устройства» можно ограничить её правила выбранными устройствами.')); var tiles = list.map(function(s) { - var on = s.name === draft.sec, n = secSel(cfg, s), extra = (s.domains || 0) + (s.subnets || 0); + 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, 'устройство', 'устройства', 'устройств'); return node(s.label, secConn(s), on, function() { open(s.name); }, - E('span', { 'class': 'zm-lat ' + (!s.enabled ? 'zm-lat-bad' : n || extra ? 'zm-lat-good' : 'zm-lat-none') }, s.enabled ? what : 'выключена'), + E('span', { 'class': 'zm-lat ' + (!s.enabled ? 'zm-lat-bad' : n || extra || f ? 'zm-lat-good' : 'zm-lat-none') }, s.enabled ? what : 'выключена'), 'zm-sec' + (s.enabled ? '' : ' zm-node-dead')); }); if (isNew) tiles.push(node(secName(), 'ещё не сохранена', true, function() {}, E('span', { 'class': 'zm-lat zm-lat-mid' }, 'черновик'), 'zm-sec')); else if (list.length < SEC_MAX) tiles.push(E('div', { 'class': 'zm-node zm-sec zm-sec-add', 'title': 'Свои списки через другой VPN', 'click': function() { open('new'); } }, [ E('span', { 'class': 'zm-sec-add-text' }, 'Создать новую секцию') ])); secCard.appendChild(E('div', { 'class': 'zm-nodes' }, tiles)); - if (list.length > 1 && !common) secCard.appendChild(E('p', { 'class': 'zm-hint' }, (mainName() === 'Основная' ? 'Основная секция' : 'Основная секция — «' + mainName() + '»') + ': через неё идут устройства «Всё через Forkozz», DNS и скачивание списков. Секции проверяются по порядку слева направо: если сайт попал в две, сработает левая. Один сервис можно включить только в одной секции. Исключения действуют на все секции сразу.')); + if (list.length > 1 && !common) secCard.appendChild(E('p', { 'class': 'zm-hint' }, (mainName() === 'Основная' ? 'Основная секция' : 'Основная секция — «' + mainName() + '»') + ': через неё идут DNS и скачивание списков. Правила секции действуют только для её устройств; если устройства не выбраны — для всех. Запрос подходит сразу нескольким секциям — сработает та, что левее. Один сервис можно включить в разных секциях, если у них разные устройства. Исключения общие для всех секций.')); } function tabTo(id) { @@ -24650,12 +26040,31 @@ return view.extend({ function srvState() { if (!servers) return null; var ns = servers.nodes || {}, list = servers.list || []; - var autoTag = list.filter(function(t) { return ns[t] && ns[t].members; })[0], auto = autoTag ? ns[autoTag] : null; + var 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 = isAuto ? auto.now : servers.now; + 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 ''; @@ -24677,8 +26086,8 @@ return view.extend({ function routeValue(c) { if (!c || !c.main_exists) return [ '—', 'ничего не выбрано' ]; - var n = 0, d = 0, f = (c.full || []).length, extra = [], bp = c.bypass || {}, bn = (bp.domains || []).length + (bp.subnets || []).length; - (c.sections || []).forEach(function(s) { if (s.enabled) { n += secSel(c, s); d += s.domains || 0; } }); + 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); @@ -24716,8 +26125,10 @@ return view.extend({ E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Forkozz не установлен, а sing-box остался. Без Forkozz он не нужен — его можно удалить вместе с настройками. Если поставить Forkozz, он возьмёт этот sing-box.') ]), E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { - if (!confirm('Удалить sing-box ' + st.singbox + ' (' + sbn + ')?\n\nБудут удалены пакет, файл программы, её настройки и кэш.')) return; - act('singbox_remove'); + 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') ]); } @@ -24742,10 +26153,13 @@ return view.extend({ 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, 'устройства', 'устройств', 'устройств')); return parts.join(' · ') || 'ничего не выбрано'; } 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 + ' мс'; @@ -24761,6 +26175,7 @@ return view.extend({ 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', 'не работает'); @@ -24783,8 +26198,8 @@ return view.extend({ } function commonBox() { - var bp = cfg.bypass || {}, bn = (bp.domains || []).length + (bp.subnets || []).length, f = (cfg.full || []).length, e = (cfg.excl || []).length; - var dev = [ f ? 'всё через Forkozz: ' + f : '', e ? 'мимо Forkozz: ' + e : '' ].filter(Boolean).join(' · ') || 'устройства по спискам'; + var bp = cfg.bypass || {}, bn = (bp.domains || []).length + (bp.subnets || []).length, e = (cfg.excl || []).length; + var dev = e ? 'мимо Forkozz: ' + e : 'без исключённых устройств'; var sb = st.singbox ? 'sing-box ' + st.singbox.replace(/-extended.*$/, '') : 'sing-box не установлен'; var dn = cfg.dns ? dnsLabel(cfg.dns) : '—', dt = cfg.dns ? (DNS_TYPES[cfg.dns.type] || '') + (cfg.dns.detour ? ' · через VPN' : '') : ''; return E('div', { 'class': 'zm-st-stat zm-fk-common' }, [ @@ -24895,14 +26310,58 @@ return view.extend({ } }, 'Включить')); } if (st.newer) b.push(E('button', { 'class': 'cbi-button cbi-button-action', 'click': function() { act('update'); } }, 'Обновить до ' + st.latest)); - b.push(E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { - if (!confirm('Удалить Forkozz полностью?\n\nБудут удалены Forkozz, его настройки, правила файрвола и маршрутизации, задания в cron и sing-box. Трафик пойдёт напрямую.')) return; - act('remove'); - } }, 'Удалить')); + 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; @@ -24932,7 +26391,7 @@ return view.extend({ 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('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)); } @@ -25008,6 +26467,7 @@ return view.extend({ 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('отбор по словам'); @@ -25015,6 +26475,9 @@ return view.extend({ } 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()); @@ -25039,15 +26502,31 @@ return view.extend({ } if (draft.mode !== 'iface') { box.appendChild(E('h4', {}, 'Автовыбор сервера')); - box.appendChild(hint('Работает, когда серверов несколько и в карточке «Серверы» выбран автовыбор: Forkozz сам проверяет серверы и держит самый быстрый.', 0)); - box.appendChild(row('Проверять', seg(UT_IV, draft.ut_iv, function(v) { set('ut_iv', v); }))); + 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 туннеля')); @@ -25178,9 +26657,11 @@ return view.extend({ 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; } @@ -25216,12 +26697,14 @@ return view.extend({ 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 (servers.fell && (isAuto || nodeName(cur) !== servers.fell)) srvCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show', 'role': 'note' }, [ + 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) { @@ -25229,12 +26712,12 @@ return view.extend({ else if (curTag) pickServer(curTag, nodeName(cur)); }))); srvCard.appendChild(row('Сейчас', E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap' }, [ - E('b', {}, [ curTag ? nodeName(cur) : '—' ]), - cur.type ? E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, [ String(cur.type).toLowerCase() ]) : E([]), - cur.delay >= 0 ? E('span', { 'class': 'zm-lat ' + (cur.delay === 0 ? 'zm-lat-bad' : latClass(cur.delay)) }, latText(cur.delay)) : E([]) + 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 ? E('span', { 'class': 'zm-lat ' + (cur.delay === 0 ? 'zm-lat-bad' : latClass(cur.delay)) }, latText(cur.delay)) : E([]) ]))); - var tags = (isAuto ? (auto.members || []) : manual).filter(function(t) { return !(ns[t] && ns[t].hidden); }); + var 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; @@ -25264,7 +26747,7 @@ return view.extend({ renderServers(); if (sortPing && !tested && !latBusy) testLatency(); })); - srvCard.appendChild(E('p', { 'class': 'zm-hint' }, isAuto ? 'Авто: Forkozz сам выбирает лучший из этих серверов. Нажмите на сервер, чтобы закрепить его.' : 'Нажмите на сервер, чтобы переключиться. Выбор держится до перезагрузки роутера.')); + srvCard.appendChild(E('p', { 'class': 'zm-hint' }, isAuto ? servers.priority ? 'Авто: используется первая доступная подписка в порядке ссылок. После её восстановления Forkozz возвращается к ней, даже если резервная быстрее. Нажатие на сервер включает ручной выбор.' : servers.ping ? 'Авто по пингу: включён самый быстрый сервер. Если он оборвётся, Forkozz за 20–40 секунд перейдёт на самый быстрый из рабочих. Нажмите на сервер, чтобы закрепить его.' : 'Авто: Forkozz сам выбирает лучший из этих серверов. Нажмите на сервер, чтобы закрепить его.' : servers.priority ? 'Нажмите на сервер, чтобы переключиться. Чтобы снова использовать автоматический порядок подписок, выберите «Авто».' : 'Нажмите на сервер, чтобы переключиться. Выбор держится до перезагрузки роутера.')); srvCard.appendChild(hidePick); } @@ -25316,17 +26799,18 @@ return view.extend({ var takenMemo = null; function takenMap() { - var key = cfg ? JSON.stringify([ draft.sec, secs() ]) : ''; + 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 (i < cur && p.sel.russia_inside) ri.label = ri.label || s.label; + 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 }; @@ -25340,7 +26824,7 @@ return view.extend({ var shadowed = !by && ra && !s.group && s.id !== 'russia_inside' && zm.riCovers(s.id); if (shadowed && !draft.sel[s.id]) return zm.svcCard({ name: s.name, key: s.id, on: false, sub: 'входит в «Всё сразу» секции «' + ra + '»', cls: 'zm-svc-taken', title: s.name + ' уже идёт через «Всё сразу» секции «' + ra + '»', click: function() { - zm.toast('«' + s.name + '» входит в «Всё сразу» секции «' + ra + '». Та секция выше и срабатывает первой — чтобы пустить «' + s.name + '» здесь, выключите «Всё сразу» там.', 'warning'); + zm.toast('«' + s.name + '» входит в «Всё сразу» секции «' + ra + '». Чтобы пустить «' + s.name + '» здесь, выключите «Всё сразу» там.', 'warning'); } }); if (by && !draft.sel[s.id]) return zm.svcCard({ name: s.name, key: s.id, on: false, sub: 'в секции «' + by + '»', cls: 'zm-svc-taken', title: s.name + ' уже идёт через секцию «' + by + '»', click: function() { @@ -25362,8 +26846,8 @@ return view.extend({ if (many) svcCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, [ 'Подключение этой секции: ', E('b', {}, draft.mode === 'iface' ? (draft.iface ? (/^zmwarp/.test(draft.iface) ? 'WARP · ' : 'туннель ') + draft.iface : 'туннель не выбран') : draft.mode === 'sub' ? 'подписка' : nn(textLines(draft.links).length, 'сервер', 'сервера', 'серверов')), '. Сервисы, выбранные в других секциях, помечены их названием.' ])); svcCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Нажмите на пункт, чтобы включить или выключить его, и затем «Сохранить». Списки те же, что в Steer: берутся из каталога списков (itdoginfo/allow-domains, b4geoip и другие) и обновляются сами.')); var remote = list.filter(function(s) { return !!s.group; }); - var svcs = list.filter(function(s) { return !s.group && CATEGORY_IDS.indexOf(s.id) < 0; }); - var cats = list.filter(function(s) { return !s.group && CATEGORY_IDS.indexOf(s.id) >= 0; }); + 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); }); svcCard.appendChild(E('h4', { 'style': 'margin:0' }, 'Сервисы')); svcCard.appendChild(zm.svcGrid(svcs.map(svcTile))); if (cats.length) { @@ -25405,8 +26889,17 @@ return view.extend({ } ownCard.innerHTML = ''; - ownCard.appendChild(E('h3', {}, many ? 'Свои домены и адреса · «' + secName() + '»' : 'Свои домены и адреса')); - ownCard.appendChild(E('h4', { 'style': 'margin:4px 0 8px' }, 'Домены')); + 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 ? 'Свои домены, адреса и внешние списки идут через подключение секции — так же, как выбранные сервисы.' + : 'Выключено: свои домены, адреса и внешние списки сохранены, но идут напрямую. Включите — и они снова пойдут через 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-адреса и подсети')); @@ -25440,33 +26933,44 @@ return view.extend({ bypCard.appendChild(E('p', { 'class': 'zm-hint' }, 'После сохранения устройства могут ещё пару минут помнить старый адрес сайта — если сайт всё ещё идёт через VPN, очистите DNS-кэш или перезапустите браузер. Проверить, куда пойдёт сайт, можно в «Куда пойдёт запрос» на вкладке «Подключение».')); } - function devCard(card, key, other, title, hint) { + function devCard(card, key, title, hint) { card.innerHTML = ''; - var list = draft[key], devs = (cfg && cfg.devices) || [], known = {}; + var list = draft[key], devs = (cfg && cfg.devices) || [], known = {}, assigned = {}; + secs().forEach(function(s) { + (s.name === draft.sec ? draft.full : s.full || []).forEach(function(ip) { + if (!assigned[ip]) assigned[ip] = []; + assigned[ip].push(s.label); + }); + }); card.appendChild(E('h3', {}, [ title + ' ', list.length ? badge(key === 'full' ? 'zm-ok' : 'zm-warn', nn(list.length, 'устройство', 'устройства', 'устройств')) : E([]) ])); card.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, hint)); function toggle(ip) { var l = draft[key].slice(), i = l.indexOf(ip); if (i >= 0) l.splice(i, 1); - else { - l.push(ip); - var o = draft[other].indexOf(ip); - if (o >= 0) { draft[other] = draft[other].slice(); draft[other].splice(o, 1); } - } + 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 === 'full' ? ' · мимо Forkozz' : ''); + } devs.forEach(function(d) { known[d.ip] = true; - grid.appendChild(node(d.name || d.ip, d.name ? d.ip : 'без имени', list.indexOf(d.ip) >= 0, function() { toggle(d.ip); })); + grid.appendChild(node(d.name || d.ip, foot(d.ip, d.name ? d.ip : 'без имени'), list.indexOf(d.ip) >= 0, function() { toggle(d.ip); })); }); - list.forEach(function(ip) { if (!known[ip]) grid.appendChild(node(ip, 'добавлен вручную', true, function() { toggle(ip); })); }); - if (!devs.length && !list.length) grid.appendChild(E('p', { 'class': 'zm-hint', 'style': 'grid-column: 1 / -1; margin: 0' }, 'Устройства не найдены — добавьте адрес вручную.')); + 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(); - if (!/^\d{1,3}(\.\d{1,3}){3}(\/\d{1,2})?$/.test(v)) { zm.toast('Введите IP-адрес, например 192.168.1.100', 'warning'); return; } + 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(); } }); @@ -25474,8 +26978,9 @@ return view.extend({ } function renderDev() { - devCard(fullCard, 'full', 'excl', 'Всё через Forkozz', 'Весь интернет устройства идёт через подключение' + (secs().length > 1 ? ' секции «' + mainName() + '»' : '') + '. Удобно для ТВ и приставок. Исключения действуют и здесь.'); - devCard(exclCard, 'excl', 'full', 'Мимо Forkozz', 'Устройство всегда ходит напрямую.'); + devCard(fullCard, 'full', 'Устройства для правил секции «' + secName() + '»', 'Через подключение этой секции идут только её сервисы, домены и адреса — и только с выбранных устройств. Всё остальное проверяется по другим секциям, а потом идёт напрямую. Ничего не выбрано — правила действуют для всех устройств. Одно устройство можно выбрать в нескольких секциях. «Мимо Forkozz» и исключения важнее правил секции. Чтобы устройство не потерялось, закрепите за ним IP-адрес в DHCP.'); + if (!draft.isMain && !draft.enabled) fullCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Секция выключена: её правила не действуют. Выбор устройств в остальных секциях сохраняется.')); + devCard(exclCard, 'excl', 'Мимо Forkozz', 'Общий список для всех секций: устройство всегда ходит напрямую. Этот выбор имеет приоритет над правилами секций и не меняет их списки устройств.'); } function dnsUniq(t, list) { @@ -25683,7 +27188,7 @@ return view.extend({ 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, 'Веб-панель (YACD)', 'Соединения, трафик и серверы всех секций в реальном времени, с ручным выбором сервера. При первом запуске панель скачивается с GitHub.', function() { set('yacd', !on); })); + 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; @@ -25712,7 +27217,7 @@ return view.extend({ 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, даже с устройств «Всё через Forkozz». Некоторые сервисы блокируют подписку, заметив торренты.', function() { set('excl_bt', !draft.excl_bt); })); + 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.')); @@ -25779,15 +27284,19 @@ return view.extend({ 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 ]); if (r.verdict === 'bypass') { return { tone: 'warn', cls: 'zm-warn', label: 'напрямую · исключение', title: 'адрес в исключениях — идёт мимо VPN', lines: lines, - note: 'Он есть на вкладке «Исключения». Чтобы он снова шёл через VPN, уберите его оттуда и сохраните.' }; + note: 'Проверьте вкладки «Исключения» и «Устройства → Мимо Forkozz». Чтобы запрос снова шёл через VPN, уберите соответствующее исключение и сохраните.' }; } if (r.verdict === 'off') return { tone: 'off', cls: 'zm-off', label: 'Forkozz не работает', title: 'запрос пойдёт напрямую', note: 'Запустите Forkozz и проверьте ещё раз.' }; if (r.addr && !r.ip) lines.push([ 'Адрес', r.how === 'fakeip' ? [ r.addr, ' (подменный — домен есть в списках Forkozz)' ] : r.addr ]); if (r.how === 'subnet') lines.push([ 'Совпадение', 'адрес входит в подсети Forkozz' ]); if (sl.length > 1 && rs) lines.push([ 'Секция', rs.label ]); else if (r.rule) lines.push([ 'Правило', r.rule ]); + if (r.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 ]); @@ -25815,13 +27324,16 @@ return view.extend({ routeCard.style.display = show ? '' : 'none'; if (!show) return; if (!routeBox) routeBox = zm.routeCheck({ - call: function(v) { return zm.forkopAction('route', v); }, + 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-адрес — Forkozz покажет, пойдёт он через VPN или напрямую.')); + 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); } @@ -27253,6 +28765,7 @@ return view.extend({ 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)" { @@ -27281,23 +28794,25 @@ _zm_rpcd_wait() { 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 + 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 - echo -e "${YELLOW}rpcd не подхватил панель — если страница покажет «Object not found», выполните: /etc/init.d/rpcd restart${NC}" - fi + 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 -if command -v apk >/dev/null 2>&1; then PM="apk"; INSTALL="apk add" -else PM="opkg"; INSTALL="opkg install"; fi -command -v curl >/dev/null 2>&1 || $INSTALL curl >&2 || true -command -v unzip >/dev/null 2>&1 || $INSTALL unzip >&2 || true +_zmi_say "Проверяем пакеты, которые нужны панели" +_zmi_pkg curl "Без него панель не сможет ничего скачивать." || true +_zmi_pkg unzip "Без него не установятся Zapret и Mixomo." || true +_zmi_say "Записываем файлы Web UI" mkdir -p /www/zm @@ -27790,6 +29305,7 @@ var ICONS = { fork: '', route: '', tunnel: '', + terminal: '', anarchy: '', rocket: '', telegram: '' @@ -27965,6 +29481,7 @@ var ROUTES = [ { id: 'hosts', title: 'Hosts', sub: 'Домены в hosts и списки GeoHide', icon: 'list', group: 'Сеть', dot: 'hosts' }, { id: 'doh', title: 'DNS over HTTPS', sub: 'Шифрованный DNS для всей сети', icon: 'globe', group: 'Сеть', dot: 'doh' }, { id: 'awg', title: 'AmneziaWG', sub: 'Туннели AmneziaWG и WARP: установка, ключи, интерфейсы', icon: 'anarchy', group: 'Сеть', dot: 'awg' }, + { id: 'terminal', title: 'Терминал', sub: 'Командная строка роутера в браузере (ttyd)', icon: 'terminal', group: 'Сеть' }, { id: 'system', title: 'Система', sub: 'Параметры роутера, зеркала и обслуживание', icon: 'cpu', group: 'Сервис' } ]; var ROUTE_BY_ID = {}; @@ -27978,6 +29495,40 @@ function currentRoute() { 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 = 'max-width:1100px; margin:0 0 16px', TERM_HIDE = 'max-width:1100px; margin:0; height:0; overflow:hidden; visibility:hidden'; + +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) { + 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', { 'src': src, 'style': 'display:block; width:100%; height:480px; border:0; border-radius:8px; background:#000' }); + termKeep.host = E('div', { 'class': 'zmw-term-keep', 'style': TERM_HIDE }, [ 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); @@ -28200,6 +29751,8 @@ function refreshShellStatus() { 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 = 'установлен не полностью — откройте вкладку и нажмите «Доустановить»'; setDot(navDots[k], h[k], title); }); }).catch(function () {}).then(function () { statusBusy = false; }); @@ -28281,8 +29834,8 @@ function route(force) { poll._reset(); ui.hideModal(); refreshShellStatus(); - viewEl.innerHTML = ''; - viewEl.appendChild(alertsEl); + termHide(); + viewReset(); viewEl.appendChild(skeleton()); if (!force) window.scrollTo(0, 0); @@ -28297,16 +29850,14 @@ function route(force) { }).then(function (node) { if (token !== routeToken || !node) return; var page = E('div', { 'class': 'zmw-page' }, [ node ]); - viewEl.innerHTML = ''; - viewEl.appendChild(alertsEl); + viewReset(); viewEl.appendChild(page); }); }) .catch(function (err) { if (token !== routeToken || (err && err.authLost)) return; console.error(err); - viewEl.innerHTML = ''; - viewEl.appendChild(alertsEl); + viewReset(); viewEl.appendChild(errorCard(err, function () { route(true); })); }); } @@ -28402,6 +29953,7 @@ function authLost() { sid = null; sset(K_SID, null); poll._reset(); + termDrop(); freshLogin('Сессия истекла — войдите снова.', 'warning'); } @@ -28437,6 +29989,7 @@ function logout() { sset(K_SID, null); var done = function () { poll._reset(); + termDrop(); if (viewEl) viewEl.innerHTML = ''; freshLogin('Вы вышли из панели.', 'info'); }; @@ -29985,14 +31538,20 @@ 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 -if [ ! -e /usr/lib/uhttpd_ubus.so ]; then - echo -e "${CYAN}Устанавливаем ${NC}uhttpd-mod-ubus${CYAN} для Web UI${NC}" - $INSTALL uhttpd-mod-ubus >&2 || { $PM update >&2; $INSTALL uhttpd-mod-ubus >&2; } || true +ZMW_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 ! uci -q get uhttpd.zmweb >/dev/null; then +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' @@ -30012,10 +31571,20 @@ if ! uci -q get uhttpd.zmweb >/dev/null; then 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 + ZMW_PORT="$(uci -q get uhttpd.zmweb.listen_http | tr ' ' '\n' | head -n1 | sed 's/.*://')" ZMW_IP="$(/opt/zapret-manager-luci/backend.sh lan_ip 2>/dev/null || true)" [ -n "$ZMW_IP" ] || ZMW_IP="192.168.1.1" -echo -e "Zapret Manager ${GREEN}для ${NC}LuCI ${GREEN}установлен!${NC}" -echo -e "\n${CYAN}Web UI: ${NC}http://${ZMW_IP}:${ZMW_PORT:-7788}${NC}\n" echo "sh <(wget -q -O - https://raw.githubusercontent.com/StressOzz/Zapret-Manager/main/Zapret-Manager.sh)" > /usr/bin/zms; chmod +x /usr/bin/zms +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 $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