diff --git a/ZapretManager_LuCI.sh b/ZapretManager_LuCI.sh index e81083d..2b2d2f3 100644 --- a/ZapretManager_LuCI.sh +++ b/ZapretManager_LuCI.sh @@ -1,5 +1,5 @@ #!/bin/sh -# Version: 1.96 +# Version: 2.05 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" @@ -50,7 +50,7 @@ cat > '/opt/zapret-manager-luci/backend.sh.zm-new' << 'ZM_INSTALLER_EOF' umask 022 CONF="/etc/config/zapret" -ZM_VERSION="1.96" +ZM_VERSION="2.05" 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" @@ -258,6 +258,7 @@ _zm_pkg_fmt() { /^\([0-9]+\/[0-9]+\) (Purging|Deleting) / { print " · удаляем " $3; next } /^OK: [0-9]+ distinct packages available/ { print " · доступно пакетов: " $2; next } /^fetch / { next } + /ubus call service delete .*Not found/ { next } /[Ee]rror|ERROR|Collected errors|^ \* |[Uu]nknown package|not found|No such|unable|cannot|failed|Failed|unsatisfiable|breaks:/ { l = $0; sub(/^[ *]+/, "", l); if (l != "" && !seen[l]++) print " ! " l; next } ' } @@ -313,8 +314,11 @@ _zm_pkg_add() { return $rc } +_zm_ca_have() { ls /etc/ssl/certs/*.crt >/dev/null 2>&1; } + _zm_pkg_del() { - local rc p have="" names + local rc p have="" names ca=0 + _zm_ca_have && ca=1 for p; do case "$p" in -*) have="$have $p"; continue ;; esac _pkg_is_installed "$p" && have="$have $p" @@ -329,9 +333,19 @@ _zm_pkg_del() { _zm_pkg_unlock if [ "$rc" = 0 ]; then echo " ✓ Удалено: $names" else echo " ✗ Не удалось удалить: $names"; fi + [ "$ca" = 1 ] && _zm_ca_keep return $rc } +_zm_ca_keep() { + _zm_ca_have && return 0 + echo " → Возвращаем корневые сертификаты (ca-bundle) — без них не работает HTTPS" + _zm_run 180 $PKG_ADD ca-bundle >/dev/null 2>&1 || { _zm_run 150 $PKG_UPD >/dev/null 2>&1; _zm_run 180 $PKG_ADD ca-bundle >/dev/null 2>&1; } + if _zm_ca_have; then echo " ✓ Сертификаты на месте" + else echo " ! Не удалось поставить ca-bundle — установите его вручную, иначе HTTPS-загрузки не заработают"; fi + return 0 +} + _job_running() { local pid="$JOBS_DIR/$1.pid" [ -f "$pid" ] && kill -0 "$(cat "$pid" 2>/dev/null)" 2>/dev/null || return 1 @@ -580,6 +594,7 @@ _fk_watch() { if _fk_up; then rm -f "$f" _job_running forkop || ! grep -qE "zm_exclude|zm_hide" /etc/config/forkop 2>/dev/null || _fk_uc fixsel >/dev/null 2>&1 + _job_running forkop || _fk_heal return 0 fi _job_running forkop && return 0 @@ -596,11 +611,32 @@ _fk_watch() { return 0 } +_fk_heal() { + local f="$JOBS_DIR/fk.heal" r n=0 name to + r="$(_fk_uc heal 2>/dev/null)" + [ "$(printf '%s' "$r" | jsonfilter -e '@.dead' 2>/dev/null)" = true ] || { rm -f "$f"; return 0; } + _zm_inet_ok || return 0 + n="$(cat "$f" 2>/dev/null)"; n=$(( ${n:-0} + 1 )) + echo "$n" > "$f" + [ "$n" -ge 2 ] || return 0 + [ "$n" -gt 2 ] && [ $((n % 15)) -ne 0 ] && return 0 + r="$(_fk_uc heal "" switch 2>/dev/null)" + if [ "$(printf '%s' "$r" | jsonfilter -e '@.changed' 2>/dev/null)" = true ]; then + name="$(printf '%s' "$r" | jsonfilter -e '@.name' 2>/dev/null)" + to="$(printf '%s' "$r" | jsonfilter -e '@.to' 2>/dev/null)" + logger -t zapret-manager "Forkozz: сервер «$name» не отвечает — переключились на ${to:-автовыбор}" + rm -f "$f" + fi + return 0 +} + zm_watch() { rpcd_watch _zm_net_orphan && _zm_net_restore force >/dev/null 2>&1 _fk_installed && _fk_v6_patch /usr/lib/forkop _fk_watch + _st_vpn_watch + _tg_watch return 0 } @@ -1157,7 +1193,7 @@ strategy_v9() { printf '%s\n' "#v9" "--filter-tcp=443" "--hostlist-exclude=/op strategy_v10() { printf '%s\n' "#v10" "--filter-tcp=443" "--hostlist-exclude=/opt/zapret/ipset/zapret-hosts-user-exclude.txt" "--dpi-desync=fake,split2" "--dpi-desync-split-pos=2" "--dpi-desync-fake-tls=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin" "--dpi-desync-hostfakesplit-mod=host=maxcdn.bootstrapcdn.com" "--dpi-desync-fake-tls-mod=rnd,sni=maxcdn.bootstrapcdn.com" "--dpi-desync-fooling=ts"; } NOCHANGE_RX='#[[:space:]]*nochange' -NOCHANGE_MSG="стратегия помечена #nochange — панель её не меняет. Уберите строку #nochange в «Редактировать текущую стратегию», чтобы разрешить изменения" +NOCHANGE_MSG="включено «Не изменять стратегию» (#nochange) — панель её не меняет. Выключите этот переключатель в «Редактировать текущую стратегию», чтобы разрешить изменения" _nochange_in() { [ -f "$1" ] && grep -qiE "$NOCHANGE_RX" "$1"; } _nochange_strategy() { _nfq_opt_body | grep -qiE "$NOCHANGE_RX"; } @@ -2525,9 +2561,52 @@ _zl_json_text() { _zl_count() { local n; [ -f "$1" ] && n=$(grep -cvE '^[[:space:]]*(#.*)?$' "$1"); echo "${n:-0}"; } _zl_info() { local f; f="$(_zl_path "$1")" - printf '{"id":"%s","path":"%s","exists":%s,"count":%s,"size":%s,"mtime":%s}' "$1" "$f" \ + printf '{"id":"%s","path":"%s","exists":%s,"count":%s,"size":%s,"mtime":%s,"auto":"%s","nochange":%s}' "$1" "$f" \ "$([ -f "$f" ] && echo true || echo false)" "$(_zl_count "$f")" \ - "$([ -f "$f" ] && wc -c < "$f" | tr -d ' ' || echo 0)" "$([ -f "$f" ] && date -r "$f" +%s 2>/dev/null || echo 0)" + "$([ -f "$f" ] && wc -c < "$f" | tr -d ' ' || echo 0)" "$([ -f "$f" ] && date -r "$f" +%s 2>/dev/null || echo 0)" \ + "$([ "$1" = exclude ] && _zx_auto_get)" "$(_nochange_in "$f" && echo true || echo false)" +} + +ZX_AUTO_TAG="# zm-excl-auto" + +_zx_auto_get() { + local v + v="$(grep -F "$ZX_AUTO_TAG=" "$CRON_FILE" 2>/dev/null | head -n1 | sed 's/.*=//')" + case "$v" in h2|h4|h6|h12) echo "$v" ;; *) echo off ;; esac +} + +zapret_excl_auto_set() { + local v="$1" spec="" + case "$v" in + off) ;; + h2|h4|h6|h12) spec="17 */${v#h} * * *" ;; + *) echo '{"error":"неизвестный интервал"}'; return 1 ;; + esac + [ -z "$spec" ] || [ -x /etc/init.d/zapret ] || { echo '{"error":"Zapret не установлен"}'; return 1; } + mkdir -p "$(dirname "$CRON_FILE")" + touch "$CRON_FILE" + sed -i "\\|$ZX_AUTO_TAG=|d" "$CRON_FILE" + [ -n "$spec" ] && echo "$spec /opt/zapret-manager-luci/backend.sh zapret_excl_tick >/dev/null 2>&1 $ZX_AUTO_TAG=$v" >> "$CRON_FILE" + /etc/init.d/cron enable >/dev/null 2>&1 + /etc/init.d/cron restart >/dev/null 2>&1 + printf '{"ok":true,"info":%s}\n' "$(_zl_info exclude)" +} + +zapret_excl_tick() { + local f tmp + [ -x /etc/init.d/zapret ] || return 0 + f="$(_zl_path exclude)"; tmp="$f.zmauto" + _nochange_in "$f" && return 0 + _zm_busy_job >/dev/null 2>&1 && return 0 + mkdir -p "$(dirname "$f")" + rm -f "$tmp" + wget -q --timeout=20 -U "Mozilla/5.0" -O "$tmp" "$EXCLUDE_URL" 2>/dev/null + if [ ! -s "$tmp" ] || grep -qi ' "$tmp" && [ -s "$tmp" ] && cat "$tmp" > "$CONF" + rm -f "$tmp" + } + else + awk '/^[ \t]*option NFQWS_OPT \047$/ { b = 1; print; next } b && /^[ \t]*\047$/ { b = 0 } b { l = tolower($0); if (l ~ /^[ \t]*#[ \t]*nochange[ \t]*$/) next } { print }' "$CONF" > "$tmp" && [ -s "$tmp" ] && cat "$tmp" > "$CONF" + rm -f "$tmp" + _nochange_strategy && { echo '{"error":"пометка #nochange стоит внутри другой строки — уберите её вручную в редакторе стратегии","nochange":true}'; return 1; } + fi + printf '{"ok":true,"nochange":%s}\n' "$(_nochange_strategy && echo true || echo false)" ;; + exclude|user|google) + f="$(_zl_path "$t")"; tmp="$f.zmnc" + mkdir -p "$(dirname "$f")" + if [ "$st" = on ]; then + _nochange_in "$f" || { { echo "#nochange"; [ -f "$f" ] && cat "$f"; :; } > "$tmp" && mv -f "$tmp" "$f"; chmod 644 "$f"; } + elif [ -f "$f" ]; then + _nochange_strip < "$f" > "$tmp" && mv -f "$tmp" "$f"; chmod 644 "$f" + _nochange_in "$f" && { printf '{"error":"пометка #nochange стоит внутри другой строки — уберите её вручную в редакторе списка","info":%s}\n' "$(_zl_info "$t")"; return 1; } + fi + printf '{"ok":true,"info":%s}\n' "$(_zl_info "$t")" ;; + *) echo '{"error":"неизвестный список"}'; return 1 ;; + esac +} + zapret_list_restore() { + case "$1" in + auto=*) zapret_excl_auto_set "${1#auto=}"; return ;; + nochange=*) zapret_nochange_set "${1#nochange=}"; return ;; + esac [ "$1" = exclude ] || { echo '{"error":"восстановить можно только список исключений"}'; return 1; } [ -x /etc/init.d/zapret ] || { echo '{"error":"Zapret не установлен"}'; return 1; } local f tmp; f="$(_zl_path exclude)"; tmp="$f.zmdl" - _nochange_in "$f" && { echo '{"error":"список помечен #nochange — панель его не меняет. Уберите строку #nochange в редакторе списка, чтобы восстановить исходный"}'; return 1; } + _nochange_in "$f" && { echo '{"error":"включено «Не изменять список» (#nochange) — панель его не меняет. Выключите этот переключатель, чтобы восстановить исходный"}'; return 1; } mkdir -p "$(dirname "$f")" rm -f "$tmp" wget -q --timeout=20 -U "Mozilla/5.0" -O "$tmp" "$EXCLUDE_URL" 2>/dev/null @@ -2764,11 +2885,115 @@ tg_status() { local mt_l go_l rs_l mt_l="$(_tg_latest mt)"; mt_l="$(_ver_norm "${mt_l%% *}")"; go_l="$(_ver_norm "$(_tg_latest go)")"; rs_l="$(_ver_norm "$(_tg_latest rs)")" - printf '{"mtproto":"%s","mtproto_running":%s,"mtproto_version":"%s","mtproto_latest":"%s","mtproto_newer":%s,"socks5":"%s","socks5_running":%s,"socks5_version":"%s","socks5_latest":"%s","socks5_newer":%s,"rust":"%s","rust_running":%s,"rust_version":"%s","rust_latest":"%s","rust_newer":%s,"lan_ip":"%s","secret_mtproto":"%s","secret_rust":"%s"}\n' \ + printf '{"mtproto":"%s","mtproto_running":%s,"mtproto_version":"%s","mtproto_latest":"%s","mtproto_newer":%s,"socks5":"%s","socks5_running":%s,"socks5_version":"%s","socks5_latest":"%s","socks5_newer":%s,"rust":"%s","rust_running":%s,"rust_version":"%s","rust_latest":"%s","rust_newer":%s,"lan_ip":"%s","secret_mtproto":"%s","secret_rust":"%s","auto":%s}\n' \ "$mt" "$mt_running" "$(esc "$mt_ver")" "$(esc "$mt_l")" "$(_tg_newer "$mt_ver" "$mt_l" && echo true || echo false)" \ "$go" "$go_running" "$(esc "$go_ver")" "$(esc "$go_l")" "$(_tg_newer "$go_ver" "$go_l" && echo true || echo false)" \ "$rs" "$rs_running" "$(esc "$rs_ver")" "$(esc "$rs_l")" "$(_tg_newer "$rs_ver" "$rs_l" && echo true || echo false)" \ - "$(esc "$lan_ip")" "$(esc "$secret_mt")" "$(esc "$secret_rs")" + "$(esc "$lan_ip")" "$(esc "$secret_mt")" "$(esc "$secret_rs")" "$(_tg_auto_json)" +} + +TG_AUTO_TAG="# zm-tg-auto" +TG_WATCH_FLAG="$ZM_STATE_DIR/tg.watch" + +_tg_auto_init() { + case "$1" in + mtproto) echo /etc/init.d/tg-ws-proxy ;; + socks5) echo "$TG_INIT_GO" ;; + rust) echo "$TG_INIT_RS" ;; + tgws) echo /etc/init.d/tgws ;; + *) return 1 ;; + esac +} + +_tg_auto_alive() { + case "$1" in + mtproto) pidof tg-ws-proxy >/dev/null 2>&1 ;; + socks5) pidof tg-ws-proxy-go >/dev/null 2>&1 ;; + rust) pidof tg-ws-proxy-rs >/dev/null 2>&1 ;; + tgws) [ -n "$(tgws status 2>/dev/null)" ] ;; + esac +} + +_tg_auto_busy() { + case "$1" in + tgws) _job_running tgws_install || _job_running tgws_remove || _job_running tgws_restart || _job_running tgws_reconfigure ;; + *) _job_running "tg_install_$1" || _job_running "tg_remove_$1" ;; + esac +} + +_tg_auto_get() { + local v + v="$(grep -F "$TG_AUTO_TAG-$1=" "$CRON_FILE" 2>/dev/null | head -n1 | sed 's/.*=//')" + case "$v" in m5|m10|m15|m30|h1|h2|h3|h6|h12|d[0-9]|d1[0-9]|d2[0-3]) echo "$v" ;; *) echo off ;; esac +} + +_tg_auto_json() { + printf '{"mtproto":"%s","socks5":"%s","rust":"%s","tgws":"%s","watch":%s}' \ + "$(_tg_auto_get mtproto)" "$(_tg_auto_get socks5)" "$(_tg_auto_get rust)" "$(_tg_auto_get tgws)" \ + "$([ -f "$TG_WATCH_FLAG" ] && echo true || echo false)" +} + +_tg_auto_drop() { + grep -qF "$TG_AUTO_TAG-$1=" "$CRON_FILE" 2>/dev/null || return 0 + sed -i "\\|$TG_AUTO_TAG-$1=|d" "$CRON_FILE" + /etc/init.d/cron restart >/dev/null 2>&1 +} + +tg_auto_set() { + local id="$1" v="$2" spec init off + init="$(_tg_auto_init "$id")" || { echo '{"error":"неизвестный прокси"}'; return 1; } + off=$(( $(printf '%s' "$id" | wc -c) % 5 )) + case "$v" in + off) spec="" ;; + h1) spec="$((7 + off)) * * * *" ;; + h2|h3|h6|h12) spec="$((7 + off)) */${v#h} * * *" ;; + *) echo '{"error":"неизвестный интервал"}'; return 1 ;; + esac + [ -z "$spec" ] || [ -f "$init" ] || { echo '{"error":"этот прокси не установлен"}'; return 1; } + mkdir -p "$(dirname "$CRON_FILE")" + touch "$CRON_FILE" + sed -i "\\|$TG_AUTO_TAG-$id=|d" "$CRON_FILE" + [ -n "$spec" ] && echo "$spec /opt/zapret-manager-luci/backend.sh tg_auto_tick $id >/dev/null 2>&1 $TG_AUTO_TAG-$id=$v" >> "$CRON_FILE" + /etc/init.d/cron enable >/dev/null 2>&1 + /etc/init.d/cron restart >/dev/null 2>&1 + printf '{"ok":true,"auto":%s}\n' "$(_tg_auto_json)" +} + +tg_watch_set() { + mkdir -p "$ZM_STATE_DIR" + case "$1" in + on) : > "$TG_WATCH_FLAG" ;; + off) rm -f "$TG_WATCH_FLAG" ;; + *) echo '{"error":"неизвестное значение"}'; return 1 ;; + esac + printf '{"ok":true,"auto":%s}\n' "$(_tg_auto_json)" +} + +tg_auto_tick() { + local init + init="$(_tg_auto_init "$1")" || return 0 + [ -x "$init" ] || return 0 + "$init" enabled >/dev/null 2>&1 || return 0 + _tg_auto_busy "$1" && return 0 + "$init" restart >/dev/null 2>&1 + return 0 +} + +_tg_watch() { + local id init + [ -f "$TG_WATCH_FLAG" ] || return 0 + for id in mtproto socks5 rust tgws; do + init="$(_tg_auto_init "$id")" + [ -x "$init" ] || continue + "$init" enabled >/dev/null 2>&1 || continue + _tg_auto_busy "$id" && continue + _tg_auto_alive "$id" && continue + sleep 3 + _tg_auto_alive "$id" && continue + logger -t zapret-manager "TG-прокси ($id) не работает — запускаем заново" + "$init" restart >/dev/null 2>&1 + done + return 0 } do_tg_install_mtproto() { @@ -2930,6 +3155,11 @@ do_tg_remove_rust() { tg_action() { local variant="$1" action="$2" + case "$variant:$action" in + watch:on|watch:off) tg_watch_set "$action"; return ;; + *:auto=*) tg_auto_set "$variant" "${action#auto=}"; return ;; + mtproto:remove|socks5:remove|rust:remove) _tg_auto_drop "$variant" ;; + esac case "$variant:$action" in mtproto:install|mtproto:update) job_start tg_install_mtproto do_tg_install_mtproto ;; mtproto:reinstall) job_start tg_install_mtproto _zm_reinstall do_tg_remove_mtproto do_tg_install_mtproto ;; @@ -3099,7 +3329,7 @@ tgws_action() { case "$action" in install|update) job_start tgws_install do_tgws_install ;; reinstall) job_start tgws_install _zm_reinstall do_tgws_remove do_tgws_install ;; - remove) job_start tgws_remove do_tgws_remove ;; + remove) _tg_auto_drop tgws; job_start tgws_remove do_tgws_remove ;; restart) job_start tgws_restart do_tgws_restart ;; reconfigure) job_start tgws_reconfigure do_tgws_reconfigure ;; *) echo '{"error":"неизвестное действие"}' ;; @@ -9215,20 +9445,94 @@ do_steer_sub_links() { } do_steer_sub_update() { + local was now rm -f "$ST_STOP_FLAG" _st_phase sub [ -s "$ST_SUB_URL" ] || { echo "ОШИБКА: это не подписка, а свои ссылки — обновлять нечего"; return 1; } _st_need_ext || return 1 _rb_say "Обновляем подписку" - _st_sub_fetch "$(head -n1 "$ST_SUB_URL")" || return 1 - if _st_use_vpn; then _rb_say "Перезапускаем VPN-туннель с обновлённым списком серверов"; _st_sub_apply; else _rb_say "Готово"; fi + was="$(md5sum < "$ST_SUB" 2>/dev/null)" + if ! _st_sub_fetch "$(head -n1 "$ST_SUB_URL")"; then + [ "$1" = auto ] && echo " · прежний список серверов остался в работе" + return 1 + fi + now="$(md5sum < "$ST_SUB" 2>/dev/null)" + _st_use_vpn || { _rb_say "Готово"; return 0; } + if [ "$1" = auto ] && [ "$was" = "$now" ] && [ ! -f "$ST_OFF" ] && /etc/init.d/steer running >/dev/null 2>&1 && _st_vpn_probe; then + _rb_say "Список серверов не изменился, VPN работает — туннель не трогаем" + return 0 + fi + _rb_say "Перезапускаем VPN-туннель с обновлённым списком серверов" + _st_sub_apply || { [ "$1" = auto ] && [ ! -f "$ST_OFF" ] && _st_vpn_heal; return 1; } + [ "$1" = auto ] && [ ! -f "$ST_OFF" ] && [ "$(_st_sel)" ] && ! _st_vpn_probe && _st_vpn_heal + return 0 +} + +ST_SUB_FELL="$ST_DIR/sub.node.fell" +ST_VPN_WATCH="$JOBS_DIR/steer.vpnwatch" + +_st_vpn_heal() { + local pinned + _rb_say "VPN не отвечает — перезапускаем Steer" + logger -t zapret-manager "Steer: VPN не отвечает — перезапускаем службу" + _zm_run 120 /etc/init.d/steer restart >/dev/null 2>&1 + sleep 3 + if _st_vpn_check >/dev/null 2>&1; then + _st_vpn_probe >/dev/null 2>&1 + echo "[ OK ] VPN снова работает" + return 0 + fi + pinned="$(cat "$ST_SUB_NODE" 2>/dev/null)" + if [ -n "$pinned" ]; then + _rb_warn "Сервер «$pinned» не отвечает — включаем автовыбор рабочего сервера" + logger -t zapret-manager "Steer: сервер «$pinned» не отвечает — включён автовыбор" + mkdir -p "$ST_DIR" + printf '%s\n' "$pinned" > "$ST_SUB_FELL" + rm -f "$ST_SUB_NODE" + _st_apply >/dev/null 2>&1 || _zm_run 120 /etc/init.d/steer restart >/dev/null 2>&1 + sleep 3 + if _st_vpn_check >/dev/null 2>&1; then + _st_vpn_probe >/dev/null 2>&1 + echo "[ OK ] VPN работает через другой сервер" + return 0 + fi + fi + echo "[FAIL] VPN не поднялся — ни один сервер подписки не отвечает" + return 1 +} + +do_steer_vpn_heal() { + rm -f "$ST_STOP_FLAG" + _st_phase check + _st_vpn_heal +} + +_st_vpn_watch() { + local f="$ST_VPN_WATCH" fails=0 heals=0 last=0 now wait + _st_installed && _st_use_vpn && [ ! -f "$ST_OFF" ] && [ -s "$ST_SUB" ] && [ -s "$ST_STEER_SPEC" ] && _st_owns "steer-spec" || { rm -f "$f"; return 0; } + /etc/init.d/steer enabled >/dev/null 2>&1 || { rm -f "$f"; return 0; } + [ "$(_st_sel)" ] || { rm -f "$f"; return 0; } + [ -z "$(_st_blocker)" ] || return 0 + _zm_busy_job >/dev/null 2>&1 && return 0 + if _st_vpn_probe; then rm -f "$f"; return 0; fi + _zm_inet_ok || return 0 + { read -r fails heals last < "$f"; } 2>/dev/null + fails="${fails:-0}"; heals="${heals:-0}"; last="${last:-0}" + fails=$((fails + 1)) + now="$(date +%s)" + if [ "$fails" -lt 2 ]; then echo "$fails $heals $last" > "$f"; return 0; fi + case "$heals" in 0) wait=0 ;; 1) wait=600 ;; 2) wait=1800 ;; *) wait=10800 ;; esac + [ $((now - last)) -lt "$wait" ] && { echo "$fails $heals $last" > "$f"; return 0; } + echo "$fails $((heals + 1)) $now" > "$f" + job_start steer do_steer_vpn_heal >/dev/null 2>&1 + return 0 } do_steer_sub_remove() { local was=0 _st_phase sub _st_use_vpn && was=1 - rm -f "$ST_SUB" "$ST_SUB_INFO" "$ST_SUB_URL" "$ST_SUB_TITLE" "$ST_SUB_NODE" + rm -f "$ST_SUB" "$ST_SUB_INFO" "$ST_SUB_URL" "$ST_SUB_TITLE" "$ST_SUB_NODE" "$ST_SUB_FELL" echo warp > "$ST_EXIT" _st_vpn_zone off _st_sub_auto_set off >/dev/null @@ -9345,6 +9649,7 @@ steer_sub_action() { fi mkdir -p "$ST_DIR" if [ -n "$mode" ]; then printf '%s\n' "$mode" > "$ST_SUB_NODE"; else rm -f "$ST_SUB_NODE"; fi + rm -f "$ST_SUB_FELL" "$ST_VPN_WATCH" if _st_use_vpn && _st_installed && [ ! -f "$ST_OFF" ] && [ -z "$(_st_blocker)" ] && [ -n "$(_st_sel)" ]; then job_start steer do_steer_apply else @@ -9427,9 +9732,9 @@ steer_sub_status() { vpn="$(steer status --spec "$ST_STEER_SPEC" 2>/dev/null | jsonfilter -e "@.outputs.$ST_VPN_OUT" 2>/dev/null | tr '\n' ' ')" case "$vpn" in '{'*) ;; *) vpn=null ;; esac fi - printf '{"ext":%s,"has":%s,"kind":"%s","url":"%s","title":"%s","exit":"%s","auto":"%s","node":"%s","quota":{"up":"%s","down":"%s","total":"%s","expire":"%s"},"updated":%s,"list":%s,"vpn":%s,"hidden":[%s],"exclude":"%s","hide_names":[%s]}\n' \ + printf '{"ext":%s,"has":%s,"kind":"%s","url":"%s","title":"%s","exit":"%s","auto":"%s","node":"%s","quota":{"up":"%s","down":"%s","total":"%s","expire":"%s"},"updated":%s,"list":%s,"vpn":%s,"hidden":[%s],"exclude":"%s","hide_names":[%s],"fell":"%s"}\n' \ "$ext" "$has" "$kind" "$(esc "$url")" "$(esc "$title")" "$vexit" "$(_st_sub_auto_get)" "$(esc "$node")" \ - "$(esc "$up")" "$(esc "$down")" "$(esc "$total")" "$(esc "$expire")" "${mt:-0}" "$list" "$vpn" "$hidden" "$(esc "$(cat "$ST_SUB_EXCL" 2>/dev/null)")" "$(_zm_json_lines "$ST_SUB_HIDE")" + "$(esc "$up")" "$(esc "$down")" "$(esc "$total")" "$(esc "$expire")" "${mt:-0}" "$list" "$vpn" "$hidden" "$(esc "$(cat "$ST_SUB_EXCL" 2>/dev/null)")" "$(_zm_json_lines "$ST_SUB_HIDE")" "$(esc "$(head -n1 "$ST_SUB_FELL" 2>/dev/null)")" } steer_sub_probe() { @@ -9492,7 +9797,7 @@ steer_action() { sub_refresh) [ -s "$ST_SUB_URL" ] || return 0 _st_running && return 0 - job_start steer do_steer_sub_update >/dev/null + job_start steer do_steer_sub_update auto >/dev/null ;; sub_probe) steer_sub_probe "$mode" ;; sub_links_get) @@ -11127,15 +11432,14 @@ do_fk_install() { return 1 fi if [ "$fresh" = 1 ] && [ "$restored" = 0 ] && uci -q get forkop.settings >/dev/null; then - uci -q set forkop.settings.dns_type='dot' + uci -q set forkop.settings.dns_type='udp' uci -q delete forkop.settings.dns_server uci -q add_list forkop.settings.dns_server='9.9.9.9' - uci -q add_list forkop.settings.dns_server='common.dot.dns.yandex.net' uci -q delete forkop.settings.bootstrap_dns_server uci -q add_list forkop.settings.bootstrap_dns_server='9.9.9.9' uci -q add_list forkop.settings.bootstrap_dns_server='77.88.8.8' uci -q commit forkop - _fk_say "DNS по умолчанию: Quad9 9.9.9.9 (DNS over TLS), при сбое — Яндекс. Сменить можно в «Настройках»" + _fk_say "DNS по умолчанию: Quad9 9.9.9.9 (обычный UDP). Сменить можно в «Настройках»" fi rm -rf /usr/lib/forkop.zm-old echo "$tag" > "$FK_MARK" @@ -11387,12 +11691,8 @@ do_fk_subs() { do_fk_singbox() { local want="$1" free tfree have need tneed act out="$JOBS_DIR/forkop-sb.json" old oldvar msg i=0 _fk_installed || { echo "ОШИБКА: Forkozz не установлен"; return 1; } - case "$want" in tiny|stable|extended|compact) ;; *) echo "ОШИБКА: неизвестный вариант sing-box"; return 1 ;; esac - if [ "$want" = compact ] && ! _fk_sb_compact_ok; then - echo "ОШИБКА: эта версия Forkozz не умеет ставить компактный sing-box — обновите Forkozz" - return 1 - fi - if [ "$want" != extended ] && [ "$want" != compact ] && grep -Eqi "[?&]type=xhttp" /etc/config/forkop 2>/dev/null; then + case "$want" in tiny|stable|extended) ;; *) echo "ОШИБКА: неизвестный вариант sing-box"; return 1 ;; esac + if [ "$want" != extended ] && grep -Eqi "[?&]type=xhttp" /etc/config/forkop 2>/dev/null; then echo "ОШИБКА: среди серверов есть XHTTP — они работают только на расширенном sing-box. Сначала уберите их в «Подключении»" return 1 fi @@ -11404,12 +11704,10 @@ do_fk_singbox() { tfree="$(df -k /tmp 2>/dev/null | awk 'NR==2 {print $4}')" case "$want" in extended) need=40960; tneed=30720 ;; - compact) need=20480; tneed=40960 ;; *) need=16384; tneed=12288 ;; esac if [ -n "$free" ] && [ $((free + ${have:-0})) -lt "$need" ]; then echo "ОШИБКА: мало места на флеше для этого sing-box: свободно $(((free + ${have:-0}) / 1024)) МБ, нужно около $((need / 1024)) МБ" - [ "$want" = extended ] && _fk_sb_compact_ok && echo "!! Попробуйте компактный вариант — ему нужно в два раза меньше места" return 1 fi if [ -n "$tfree" ] && [ "$tfree" -lt "$tneed" ]; then @@ -11418,14 +11716,13 @@ do_fk_singbox() { fi echo " ✓ Флеш: свободно $(((free + ${have:-0}) / 1024)) МБ, /tmp: $((tfree / 1024)) МБ" case "$want" in - extended|compact) + extended) _fk_patch_sbx /usr/lib/forkop/components/action.uc || { echo "ОШИБКА: эта версия Forkozz не умеет ставить расширенный sing-box — обновите Forkozz"; return 1; } _zm_net_prepare api.github.com github.com objects.githubusercontent.com || return 1 ;; *) _zm_net_prepare downloads.openwrt.org || return 1 ;; esac case "$want" in extended) act=install_extended; _fk_say "Скачиваем расширенный sing-box с GitHub (github.com/$FK_SBX_REPO) — около 40 МБ" ;; - compact) act=install_extended_compressed; _fk_say "Скачиваем компактный расширенный sing-box с GitHub (github.com/$FK_SBX_REPO)" ;; *) act="install_$want"; _fk_say "Ставим $(_fk_sb_name "$want") sing-box из репозитория OpenWrt" ;; esac [ -n "$old" ] && echo " · сейчас стоит $old ($(_fk_sb_name "$oldvar")) — сохраняем его копию, чтобы вернуть, если новый не заработает" @@ -11536,6 +11833,7 @@ forkop_config_set() { printf '{"error":"%s"}\n' "$(esc "Forkozz не принял настройки: ${err:-неизвестная ошибка}")" return 1 } + rm -f "$ZM_STATE_DIR/fk.fell" "$JOBS_DIR/fk.heal" echo "$res" } @@ -11911,6 +12209,8 @@ case "$cmd" in nfqws_opt_set) nfqws_opt_set "$1" ;; tg_status) tg_status ;; tg_action) tg_action "$1" "$2" ;; + tg_auto_tick) tg_auto_tick "$1" ;; + zapret_excl_tick) zapret_excl_tick ;; tg_restart_all) tg_restart_all ;; tgws_status) tgws_status ;; tgws_action) tgws_action "$1" ;; @@ -12222,6 +12522,7 @@ function cat_meta() { try { let j = json(s(getenv("ZM_FK_CAT"))); return type(j) == "object" ? j : null; } catch (e) { return null; } } +const FELL_FILE = getenv("ZM_FK_FELL") || "/opt/zapret-manager-luci/state/fk.fell"; const DNS_FO_STATE = getenv("ZM_FK_DNSFO") || "/var/run/forkop/dns-failover.json"; function dns_info(st) { @@ -12633,7 +12934,42 @@ function cmd_servers(sec) { } for (let t in keys(nodes)) if (!nodes[t].members && excl_hit(nodes[t].name, xv)) { nodes[t].hidden = true; if (index(g.all, t) >= 0) hidden++; } - out({ group: group_tag(sec), now: s(g.now), list: g.all, nodes, sub: sub_info(sec), hidden, exclude: s(xm.zm_exclude), hide_names: xv.h }); + let fell = ""; + try { fell = trim(s(fs.readfile(FELL_FILE))); } catch (e) { fell = ""; } + out({ group: group_tag(sec), now: s(g.now), list: g.all, nodes, sub: sub_info(sec), hidden, exclude: s(xm.zm_exclude), hide_names: xv.h, fell }); +} + +function proxy_alive(tag) { + let l = jcmd(BIN + " clash_api get_proxy_latency " + q(tag) + " 5000"); + return type(l) == "object" && int(l.delay || 0) > 0; +} + +function cmd_heal(sec, act) { + let j = jcmd(BIN + " clash_api get_proxies"); + let px = j && type(j.proxies) == "object" ? j.proxies : null; + let g = px ? px[group_tag(sec)] : null; + if (!g || type(g.all) != "array" || s(g.now) == "") { out({ ok: false }); return; } + let now = s(g.now); + if (px[now] && lc(s(px[now].type)) == "urltest") { out({ ok: true, manual: false, dead: false }); return; } + if (proxy_alive(now) || proxy_alive(now)) { out({ ok: true, manual: true, dead: false }); return; } + let meta = jcmd(BIN + " get_outbound_metadata " + q(sec)) || {}; + let names = type(meta.names) == "object" ? meta.names : {}; + let nm = (t) => s(names[t] || t); + if (act != "switch") { out({ ok: true, manual: true, dead: true, name: nm(now) }); return; } + let xv = excl_vars(cursor().get_all(CFG, sec) || {}); + let pickt = null; + for (let t in g.all) if (px[t] && lc(s(px[t].type)) == "urltest") { pickt = t; break; } + if (!pickt) { + for (let t in g.all) { + if (t == now || excl_hit(nm(t), xv)) continue; + if (proxy_alive(t)) { pickt = t; break; } + } + } + if (!pickt) { out({ ok: true, manual: true, dead: true, changed: false, name: nm(now) }); return; } + let r = jcmd(BIN + " clash_api set_group_proxy " + q(group_tag(sec)) + " " + q(pickt)); + if (type(r) == "object" && (r.error || r.success === false)) { out({ ok: false, manual: true, dead: true, changed: false, name: nm(now) }); return; } + try { fs.writefile(FELL_FILE, nm(now) + "\n"); } catch (e) {} + out({ ok: true, manual: true, dead: true, changed: true, name: nm(now), to: px[pickt] && lc(s(px[pickt].type)) == "urltest" ? "" : nm(pickt) }); } function cmd_fixsel(sec) { @@ -12673,6 +13009,7 @@ function cmd_select(sec, tag) { } 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.error || j.message || "сервер не переключился")); + try { fs.unlink(FELL_FILE); } catch (e) {} out({ ok: true }); } @@ -12752,6 +13089,7 @@ else if (mode == "servers") cmd_servers(sec); else if (mode == "latency") cmd_latency(sec); else if (mode == "select") cmd_select(sec, ARGV[2]); else if (mode == "fixsel") cmd_fixsel(sec); +else if (mode == "heal") cmd_heal(sec, ARGV[2] || ""); else if (mode == "hide") cmd_hide(sec); else if (mode == "diag") cmd_diag(sec); else fail("неизвестная команда"); @@ -16427,6 +16765,8 @@ return view.extend({ } var probeAfterLoad = false; + var sortPing = false; + try { sortPing = localStorage.getItem('zm.steer.sort') === 'ping'; } catch (e) {} function loadSub() { if (data.blocker) return; subLoading = true; @@ -16575,6 +16915,9 @@ return view.extend({ E('div', { 'class': 'zm-sub-facts' }, facts) ])); + if (subData.fell && !subData.node) subCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show', 'role': 'note' }, [ + E('span', {}, [ 'Сервер «', String(subData.fell), '» перестал отвечать — Steer сам переключился на автовыбор рабочего сервера. Чтобы вернуть его, нажмите на него в списке.' ]) + ])); var vpn = (data.exit || subData.exit) === 'vpn'; if (vpn) { var v = subData.vpn || {}, pr = v.probe || {}, st; @@ -16596,6 +16939,13 @@ return view.extend({ subAct('sub_remove', '', 'Удаляем подписку'); } }, 'Удалить')); if (!hideMode) subCard.appendChild(E('div', { 'class': 'zm-actions' }, acts)); + if (!hideMode && !(linksOpen && subData.kind === 'links') && nodes.length > 1) subCard.appendChild(zm.swRow(sortPing, 'Сортировать по пингу', 'Серверы идут от быстрых к медленным, неотвечающие — в конце.', function() { + sortPing = !sortPing; + try { localStorage.setItem('zm.steer.sort', sortPing ? 'ping' : 'list'); } catch (e) {} + var tested = nodes.some(function(n) { return lat[n.index] && !lat[n.index].busy; }); + renderSub(); + if (sortPing && !tested && !probing && !busy) probeAll(); + })); if (linksOpen && subData.kind === 'links' && !hideMode) { subCard.appendChild(E('div', { 'class': 'zm-hide-box', 'style': 'border-style:solid; border-color:rgba(127,127,127,.3); background:transparent' }, [ E('div', { 'style': 'font-weight:600' }, 'Свои серверы VLESS'), @@ -16611,7 +16961,16 @@ return view.extend({ name: 'Авто', foot: 'первый рабочий узел', active: !subData.node, click: function() { if (subData.node && !busy) subAct('sub_node', '', 'Выбираем узел автоматически'); } })); - nodes.forEach(function(n, ni) { + function pingRank(n) { + var l = lat[n.index]; + if (!l || l.busy) return 1e8; + if (!l.ok) return 1e9; + return l.ms > 0 ? l.ms : 1e7; + } + var shown = nodes.map(function(n, i) { return { n: n, i: i }; }); + if (sortPing && !probing) shown.sort(function(a, b) { return pingRank(a.n) - pingRank(b.n) || a.i - b.i; }); + shown.forEach(function(sn) { + var n = sn.n, ni = sn.i; var l = lat[n.index], txt = '', cls = 'zm-lat-none', dead = false; if (l && l.busy) txt = '…'; else if (l && l.ok) { txt = (l.ms > 0 ? l.ms : '?') + ' мс'; cls = latClass(l.ms); } @@ -17983,6 +18342,7 @@ return view.extend({ zm.toast('Сохраняем стратегию и перезапускаем Zapret', 'warning'); return zm.nfqwsOptSet(v).then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return false; } + nfqNo = /#\s*nochange/i.test(v); renderNfqNo(); zm.toast('Сохранено, Zapret перезапущен', 'info'); refreshAll(); return true; @@ -17990,10 +18350,30 @@ return view.extend({ } }); + var nfqNo = false, nfqNoBusy = false, nfqNoBox = E('div', { 'class': 'zm-list-nc' }); + function renderNfqNo() { + nfqNoBox.innerHTML = ''; + nfqNoBox.appendChild(zm.swRow(nfqNo, 'Не изменять стратегию', 'Панель не будет менять стратегию: выбор стратегий, YouTube, Discord, игры, тест и переустановка её не тронут. В блок NFQWS_OPT добавляется строка #nochange.', function() { + if (nfqNoBusy) return; + if (nfqwsOpen && nfqwsBar.dirty()) { zm.toast('Сначала сохраните или отмените изменения в стратегии', 'warning'); return; } + nfqNoBusy = true; renderNfqNo(); + var was = nfqNo; + zm.zapretListRestore('nochange=strategy:' + (was ? 'off' : 'on')).then(function(r) { + nfqNoBusy = false; + if (r && typeof r.nochange === 'boolean') nfqNo = r.nochange; + if (!r || r.error) { zm.toast((r && r.error) || 'Роутер не ответил', 'error'); renderNfqNo(); return; } + zm.toast(was ? 'Панель снова может менять стратегию' : 'Стратегия защищена: панель её не изменит', 'info'); + renderNfqNo(); + if (nfqwsOpen) refreshNfqwsOpt(); + }).catch(function() { nfqNoBusy = false; zm.toast('Роутер не ответил', 'error'); renderNfqNo(); }); + }, nfqNoBusy)); + } function refreshNfqwsOpt() { - zm.nfqwsOptGet().then(function(res) { - if (res.error) { zm.toast(res.error, 'error'); return; } - nfqwsBar.reset(res.content || ''); + return zm.nfqwsOptGet().then(function(res) { + if (res.error) { if (nfqwsOpen) zm.toast(res.error, 'error'); return; } + nfqNo = /#\s*nochange/i.test(res.content || ''); + renderNfqNo(); + if (nfqwsOpen) nfqwsBar.reset(res.content || ''); }); } @@ -18012,9 +18392,10 @@ return view.extend({ } }, 'Открыть редактор стратегии') ])); + nfqwsCard.appendChild(nfqNoBox); return; } - nfqwsCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Блок NFQWS_OPT из /etc/config/zapret. «Сохранить и применить» перезапустит Zapret. Добавьте отдельной строкой #nochange — и панель перестанет менять эту стратегию (смена стратегий, YouTube, Discord, игры, тест, переустановка).')); + nfqwsCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Блок NFQWS_OPT из /etc/config/zapret. «Сохранить и применить» перезапустит Zapret. Чтобы панель не меняла эту стратегию (смена стратегий, YouTube, Discord, игры, тест, переустановка), включите «Не изменять стратегию».')); nfqwsEl.style.display = ''; nfqwsCard.appendChild(nfqwsEl); nfqwsCard.appendChild(E('div', { 'class': 'zm-actions' }, [ @@ -18033,9 +18414,13 @@ return view.extend({ } }, 'Свернуть') ])); + nfqwsCard.appendChild(nfqNoBox); nfqwsCard.appendChild(nfqwsBar); } + renderNfqNo(); renderNfqwsCard(); + if (zapretInstalled) refreshNfqwsOpt().catch(function() {}); + refreshers.nfqNo = function() { if (!nfqwsOpen || !nfqwsBar.dirty()) refreshNfqwsOpt().catch(function() {}); }; panels.strategy.appendChild(nfqwsCard); })(); @@ -18685,6 +19070,8 @@ return view.extend({ var n = st.loaded ? countOf(ta.value) : (st.info ? st.info.count : 0); countEl.textContent = st.info && !st.info.exists && !dirty() ? 'нет файла' : countText(n); countEl.className = 'zm-badge zm-list-count ' + (n ? 'zm-ok' : 'zm-off'); + if (typeof renderAutoBox === 'function') renderAutoBox(); + if (typeof renderNc === 'function' && ncBox) renderNc(); dirtyEl.style.display = dirty() ? '' : 'none'; btnSave.disabled = !st.loaded; lbar.set(dirty() || (busy && st.open && lbar.isDirty()), busy && lbar.isDirty()); @@ -18750,6 +19137,51 @@ return view.extend({ body.appendChild(E('p', { 'class': 'zm-hint zm-list-hint' }, L.hint)); body.appendChild(ta); body.appendChild(E('div', { 'class': 'zm-actions zm-list-actions' }, [ btnReload, btnRestore ])); + var ncBox = E('div', { 'class': 'zm-list-nc' }), ncBusy = false; + function ncOn() { return st.loaded ? /#\s*nochange/i.test(st.saved) : !!(st.info && st.info.nochange); } + function renderNc() { + var on = ncOn(); + ncBox.innerHTML = ''; + ncBox.appendChild(zm.swRow(on, 'Не изменять список', 'Панель не будет трогать этот список при установке, обновлении, смене стратегии' + (L.restore ? ', тесте и автообновлении' : ' и тесте') + '. В файл добавляется строка #nochange.', function() { + if (ncBusy || busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + if (dirty()) { zm.toast('Сначала сохраните или отмените изменения в списке', 'warning'); return; } + ncBusy = true; renderNc(); + zm.zapretListRestore('nochange=' + L.id + ':' + (on ? 'off' : 'on')).then(function(r) { + ncBusy = false; + if (r && r.info) st.info = r.info; + if (!r || r.error) { zm.toast((r && r.error) || 'Роутер не ответил', 'error'); renderNc(); return; } + zm.toast(on ? L.title + ': панель снова может его обновлять' : L.title + ': панель больше не будет его менять', 'info'); + return load(true); + }).catch(function() { ncBusy = false; zm.toast('Роутер не ответил', 'error'); renderNc(); }); + }, ncBusy)); + } + body.appendChild(ncBox); + var autoBox = L.restore ? E('div', { 'class': 'zm-list-auto' }) : null, autoBusy = false; + function renderAutoBox() { + if (!autoBox) return; + var cur = (st.info && st.info.auto) || 'off', locked = ncOn(); + autoBox.innerHTML = ''; + autoBox.appendChild(E('div', { 'class': 'zm-row zm-list-auto-row' }, [ + E('span', { 'class': 'zm-label' }, 'Обновлять список'), + E('div', { 'class': 'zm-seg' + (autoBusy ? ' zm-seg-busy' : '') }, [ [ 'off', 'Выкл' ], [ 'h2', '2 ч' ], [ 'h4', '4 ч' ], [ 'h6', '6 ч' ], [ 'h12', '12 ч' ] ].map(function(o) { + return E('div', { 'class': 'zm-seg-item' + (o[0] === cur ? ' zm-active' : ''), 'click': function() { + if (o[0] === cur || autoBusy) return; + autoBusy = true; renderAutoBox(); + zm.zapretListRestore('auto=' + o[0]).then(function(r) { + autoBusy = false; + if (!r || r.error) { zm.toast((r && r.error) || 'Роутер не ответил', 'error'); renderAutoBox(); return; } + st.info = r.info || st.info; + zm.toast(o[0] === 'off' ? 'Автообновление списка исключений выключено' : 'Список исключений будет обновляться каждые ' + o[1], 'info'); + renderAutoBox(); + }).catch(function() { autoBusy = false; zm.toast('Роутер не ответил', 'error'); renderAutoBox(); }); + } }, o[1]); + })) + ])); + autoBox.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin:4px 0 0' }, locked && cur !== 'off' + ? 'Включено «Не изменять список» — автообновление пропускается.' + : 'Панель сама скачивает свежий список из репозитория Zapret Manager и перезапускает Zapret, если он изменился. Свои правки при этом заменяются; чтобы список не трогали, включите «Не изменять список».')); + } + if (autoBox) { body.appendChild(autoBox); renderAutoBox(); } body.appendChild(lbar); var card = E('div', { 'class': 'zm-card zm-list-card' }, [ head, body ]); panels.lists.appendChild(card); @@ -19607,6 +20039,13 @@ html.zm-theme-dark .zm-stopbar { background: #2a1416; color: #fecaca; border-col .zm-links-ed .zm-links-tools { margin: 8px 0 0; } .zm-links-add-label { font-size: 13px; font-weight: 600; margin: 4px 0 6px; } @media (max-width: 600px) { .zm-link-row { flex-wrap: wrap; } .zm-link-main { flex: 1 1 calc(100% - 40px); } .zm-link-btns { margin-left: 32px; } .zm-link-edit input { flex-basis: calc(100% - 40px) !important; } } +.zm-seg-busy { opacity: .6; pointer-events: none; } +.zm-list-auto { margin: 12px 0 4px; } +.zm-list-nc { margin: 12px 0 0; } +.zm-list-auto-row { margin: 0; } +@media (max-width: 600px) { .zm-list-auto-row { flex-wrap: wrap; } .zm-list-auto-row .zm-label { flex: 0 0 100%; } .zm-list-auto-row .zm-seg { flex-wrap: nowrap; width: 100%; } .zm-list-auto-row .zm-seg-item { flex: 1 1 0; padding: 6px 2px; text-align: center; } } +.zm-tg-auto-row .zm-label { flex: 0 0 130px; } +@media (max-width: 600px) { .zm-tg-auto-row { flex-wrap: wrap; } .zm-tg-auto-row .zm-label { flex: 0 0 100%; } .zm-tg-auto-row .zm-seg { flex-wrap: nowrap; width: 100%; } .zm-tg-auto-row .zm-seg-item { flex: 1 1 0; padding: 6px 2px; text-align: center; } } .zm-route { margin: 4px 0 2px; } .zm-route-form { display: flex; gap: 8px; align-items: stretch; } .zm-route-form .zm-route-input { flex: 1; min-width: 0; width: auto !important; } @@ -20028,6 +20467,10 @@ var VARIANTS = [ { id: 'rust', title: 'Rust (MTProto)', port: 2443, kind: 'proto' } ]; +var AUTO_OPTS = [ + [ 'off', 'Выкл' ], [ 'h1', '1 ч' ], [ 'h2', '2 ч' ], [ 'h3', '3 ч' ], [ 'h6', '6 ч' ], [ 'h12', '12 ч' ] +]; + function copyToClipboard(text) { if (window.isSecureContext && navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(function() { @@ -20193,7 +20636,7 @@ return view.extend({ zm.pollJob(job, logEl, function(ok) { busyMap[variantId] = false; zm.toast(ok ? 'Готово' : 'Не получилось — причина в журнале ниже', ok ? 'info' : 'error'); - zm.tgStatus().then(function(d) { renderCards(d); renderLinks(d); }); + zm.tgStatus().then(function(d) { data = d; renderCards(d); renderLinks(d); renderAuto(); }); }); } else { busyMap[variantId] = false; @@ -20282,7 +20725,7 @@ return view.extend({ zm.pollJob(job, logEl, function(ok) { tgwsBusy = false; zm.toast(ok ? 'Готово' : 'Не получилось — причина в журнале ниже', ok ? 'info' : 'error'); - zm.tgwsStatus().then(function(d) { renderTgws(d); }); + zm.tgwsStatus().then(function(d) { tgwsData = d; renderTgws(d); return zm.tgStatus(); }).then(function(d) { if (d && !d.error) data = d; renderAuto(); }); }); } else { tgwsBusy = false; @@ -20293,6 +20736,54 @@ return view.extend({ renderTgws(tgwsData); cards.appendChild(tgwsCard); + var autoCard = E('div', { 'class': 'zm-card' }), autoBusy = false; + + function autoSet(id, value, okText) { + if (autoBusy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); renderAuto(); return; } + autoBusy = true; + renderAuto(); + zm.tgAction(id, value).then(function(res) { + autoBusy = false; + if (!res || res.error) { zm.toast((res && res.error) || 'Роутер не ответил', 'error'); renderAuto(); return; } + if (res.auto) data.auto = res.auto; + zm.toast(okText, 'info'); + renderAuto(); + }).catch(function() { autoBusy = false; zm.toast('Роутер не ответил', 'error'); renderAuto(); }); + } + + function renderAuto() { + var a = data.auto || {}, rows = []; + var list = VARIANTS.map(function(v) { return { id: v.id, title: v.title, on: statusOf(data, v.id).installed }; }); + list.push({ id: 'tgws', title: 'sTGWS', on: tgwsData.installed === 'installed' }); + autoCard.innerHTML = ''; + autoCard.appendChild(E('h3', {}, 'Автоперезапуск')); + list.filter(function(x) { return x.on; }).forEach(function(x) { + var cur = a[x.id] || 'off', opts = AUTO_OPTS.slice(); + if (!opts.some(function(o) { return o[0] === cur; })) opts.push([ cur, /^d\d+$/.test(cur) ? 'в ' + ('0' + cur.slice(1)).slice(-2) + ':00' : /^m\d+$/.test(cur) ? cur.slice(1) + ' мин' : /^h\d+$/.test(cur) ? cur.slice(1) + ' ч' : cur ]); + rows.push(E('div', { 'class': 'zm-row zm-tg-auto-row' }, [ + E('span', { 'class': 'zm-label' }, x.title), + E('div', { 'class': 'zm-seg' + (autoBusy ? ' zm-seg-busy' : '') }, opts.map(function(o) { + return E('div', { 'class': 'zm-seg-item' + (o[0] === cur ? ' zm-active' : ''), 'click': function() { + if (o[0] === cur || autoBusy) return; + autoSet(x.id, 'auto=' + o[0], o[0] === 'off' ? x.title + ': автоперезапуск выключен' : x.title + ': перезапуск каждые ' + o[1]); + } }, o[1]); + })) + ])); + }); + if (!rows.length) { + autoCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Установите хотя бы один прокси — здесь можно будет настроить его перезапуск по расписанию.')); + return; + } + autoCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Для каждого прокси можно задать свой интервал. Перезапуск занимает пару секунд — Telegram переподключится сам.')); + rows.forEach(function(r) { autoCard.appendChild(r); }); + autoCard.appendChild(zm.swRow(!!a.watch, 'Поднимать упавший прокси', 'Раз в 2 минуты панель проверяет установленные прокси и запускает заново тот, чей процесс перестал работать.', function() { + autoSet('watch', a.watch ? 'off' : 'on', a.watch ? 'Проверка прокси выключена' : 'Упавший прокси будет запущен заново в течение 2 минут'); + }, autoBusy)); + } + + renderAuto(); + wrap.appendChild(autoCard); + var restartBusy = false; wrap.appendChild(E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Общие действия'), @@ -20422,7 +20913,7 @@ var DNS_CAT = { ] }; -var DNS_DEF = { doh: [ 'https://dns.quad9.net/dns-query', 'https://common.dot.dns.yandex.net/dns-query' ], dot: [ 'dns.quad9.net', 'common.dot.dns.yandex.net' ], udp: [ '9.9.9.9', '77.88.8.8' ] }; +var DNS_DEF = { doh: [ 'https://dns.quad9.net/dns-query', 'https://common.dot.dns.yandex.net/dns-query' ], dot: [ 'dns.quad9.net', 'common.dot.dns.yandex.net' ], udp: [ '9.9.9.9' ] }; var DNS_MAX = 8; var DNS_TYPES = { udp: 'UDP', dot: 'DoT', doh: 'DoH' }; @@ -20468,7 +20959,6 @@ var SB_VARS = [ { id: 'stable', label: 'Обычный', hint: 'Все протоколы официального sing-box. Нужно больше места.', warn: '' }, { id: 'extended', label: 'Расширенный', hint: 'Сборка sing-box-extended с GitHub: XHTTP, mKCP, VLESS encryption, Amnezia и другое. Нужно ~40 МБ.', warn: 'Скачивается с GitHub (shtorm-7/sing-box-extended), нужно около 40 МБ свободной памяти.\n' } ]; -SB_VARS.push({ id: 'compact', label: 'Компактный', hint: 'Тот же расширенный sing-box с GitHub, но в сжатой сборке: места на флеше нужно вдвое меньше (~20 МБ), зато запускается чуть дольше и берёт больше оперативной памяти.', warn: 'Скачивается с GitHub (shtorm-7/sing-box-extended), нужно около 20 МБ на флеше и 40 МБ в /tmp.\n' }); var SB_NAMES = { tiny: 'облегчённый', stable: 'обычный', extended: 'расширенный', compact: 'расширенный компактный' }; var SUB_IV = [ { id: '1h', label: '1 ч' }, { id: '6h', label: '6 ч' }, { id: '12h', label: '12 ч' }, { id: '1d', label: '24 ч' } ]; var LIST_IV = [ { id: '6h', label: '6 ч' }, { id: '12h', label: '12 ч' }, { id: '1d', label: '24 ч' }, { id: '3d', label: '3 дня' } ]; @@ -20482,14 +20972,14 @@ var WARN = { var ACT_TEXT = { install: 'Устанавливаем Forkozz', update: 'Обновляем Forkozz', remove: 'Удаляем Forkozz', stop: 'Выключаем Forkozz', - start: 'Включаем Forkozz', restart: 'Перезапускаем Forkozz', apply: 'Применяем настройки', lists: 'Обновляем списки', - subs: 'Обновляем подписку', singbox: 'Меняем sing-box', singbox_remove: 'Удаляем sing-box' + start: 'Включаем Forkozz', restart: 'Перезапускаем Forkozz', apply: 'Применяем настройки', lists: 'Обновляем списки сервисов', + subs: 'Обновляем серверы подписки', singbox: 'Меняем sing-box', singbox_remove: 'Удаляем sing-box' }; var DONE_TEXT = { install: 'Forkozz установлен', update: 'Forkozz обновлён', remove: 'Forkozz удалён', stop: 'Forkozz выключен', - start: 'Forkozz включён', restart: 'Forkozz перезапущен', apply: 'Настройки применены', lists: 'Списки обновлены', - subs: 'Подписка обновлена', singbox: 'sing-box заменён', singbox_remove: 'sing-box удалён' + start: 'Forkozz включён', restart: 'Forkozz перезапущен', apply: 'Настройки применены', lists: 'Списки сервисов обновлены', + subs: 'Серверы подписки обновлены', singbox: 'sing-box заменён', singbox_remove: 'sing-box удалён' }; function badge(cls, text) { @@ -20691,6 +21181,8 @@ return view.extend({ } var pingAfter = false; + var sortPing = false; + try { sortPing = localStorage.getItem('zm.fk.sort') === 'ping'; } catch (e) {} function connSig(d) { return [ d.mode, textLines(d.links).join('\n'), String(d.sub || '').trim(), d.iface || '' ].join('\u0001'); } function finish(ok) { busy = false; @@ -21027,7 +21519,7 @@ return view.extend({ else if (draft.mode === 'sub') { connCard.appendChild(taSub); connCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Ссылка на подписку от VPN-сервиса, обычно https://…/sub/…. Выбор сервера — в карточке «Серверы» ниже.')); - connCard.appendChild(row('Обновлять', seg(SUB_IV, draft.sub_interval, function(v) { set('sub_interval', v); }))); + connCard.appendChild(row('Обновлять серверы', seg(SUB_IV, draft.sub_interval, function(v) { set('sub_interval', v); }))); } else { var tl = (cfg && cfg.tunnels) || []; @@ -21108,6 +21600,9 @@ return view.extend({ if (sub.used != null) srvCard.appendChild(row('Трафик', E('span', {}, fmtBytes(sub.used) + (sub.total ? ' из ' + fmtBytes(sub.total) : sub.unlimited ? ' · без лимита' : '')))); if (sub.expire) srvCard.appendChild(row('Действует до', E('span', {}, fmtDate(sub.expire)))); } + if (servers.fell && (isAuto || nodeName(cur) !== servers.fell)) srvCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show', 'role': 'note' }, [ + E('span', {}, [ 'Сервер «', String(servers.fell), '» перестал отвечать — Forkozz сам переключился на ' + (isAuto ? 'автовыбор. Чтобы вернуть его, переключите режим на «Вручную» и выберите его в списке.' : 'другой рабочий сервер. Чтобы вернуть его, нажмите на него в списке.') ]) + ])); if (auto) srvCard.appendChild(row('Режим', seg([ { id: 'auto', label: 'Авто' }, { id: 'manual', label: 'Вручную' } ], isAuto ? 'auto' : 'manual', function(m) { if (m === 'auto') pickServer(autoTag, 'автовыбор'); else if (curTag) pickServer(curTag, nodeName(cur)); @@ -21125,14 +21620,25 @@ return view.extend({ return { name: nm, foot: String(n.type || '').toLowerCase(), hidden: byName, locked: !!n.hidden && !byName }; }), { markers: servers.exclude || '', hideNames: servers.hide_names || [] }); if (hideMode) { srvCard.appendChild(hidePick); return; } + if (sortPing) { + var rank = function(t) { var d = ns[t] ? ns[t].delay : -1; return d > 0 ? d : d === 0 ? 1e9 : 1e8; }; + tags = tags.map(function(t, i) { return { t: t, i: i }; }).sort(function(a, b) { return rank(a.t) - rank(b.t) || a.i - b.i; }).map(function(x) { return x.t; }); + } srvCard.appendChild(E('div', { 'class': 'zm-nodes', 'style': 'margin-top:12px' }, tags.map(function(t) { var n = ns[t] || { name: t, delay: -1 }, on = t === curTag; return node(nodeName(n), String(n.type || '').toLowerCase(), on, function() { if (!on) pickServer(t, nodeName(n)); }, E('span', { 'class': 'zm-lat ' + (n.delay === 0 ? 'zm-lat-bad' : latClass(n.delay)) }, latText(n.delay)), n.delay === 0 ? 'zm-node-dead' : ''); }))); var acts = [ E('button', { 'class': 'cbi-button', 'disabled': latBusy ? '' : null, 'click': testLatency }, latBusy ? 'Проверяем…' : 'Проверить задержку') ]; - if (cfg.mode === 'sub') acts.push(E('button', { 'class': 'cbi-button', 'click': function() { act('subs'); } }, 'Обновить подписку')); + if (cfg.mode === 'sub') acts.push(E('button', { 'class': 'cbi-button', 'click': function() { act('subs'); } }, 'Обновить серверы подписки')); srvCard.appendChild(E('div', { 'class': 'zm-actions' }, acts)); + if (tags.length > 1) srvCard.appendChild(zm.swRow(sortPing, 'Сортировать по пингу', 'Серверы идут от быстрых к медленным, неотвечающие — в конце.', function() { + sortPing = !sortPing; + try { localStorage.setItem('zm.fk.sort', sortPing ? 'ping' : 'list'); } catch (e) {} + var tested = tags.some(function(t) { return ns[t] && ns[t].delay >= 0; }); + renderServers(); + if (sortPing && !tested && !latBusy) testLatency(); + })); srvCard.appendChild(E('p', { 'class': 'zm-hint' }, isAuto ? 'Авто: Forkozz сам выбирает лучший из этих серверов. Нажмите на сервер, чтобы закрепить его.' : 'Нажмите на сервер, чтобы переключиться. Выбор держится до перезагрузки роутера.')); srvCard.appendChild(hidePick); } @@ -21440,11 +21946,11 @@ 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.lists_via, 'Скачивать списки через VPN', 'Списки сервисов будут качаться через ваше подключение (сервер, подписку или туннель). Помогает, если GitHub у провайдера не открывается.', function() { set('lists_via', !draft.lists_via); })); + miscCard.appendChild(E('h4', { 'style': 'margin:18px 0 4px' }, 'Списки сервисов')); + miscCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:0' }, 'Домены и адреса выбранных сервисов (YouTube, Discord и другие) — по ним Forkozz понимает, что вести через VPN.')); + miscCard.appendChild(sw(draft.lists_via, 'Скачивать списки сервисов через VPN', 'Списки будут качаться через ваше подключение (сервер, подписку или туннель). Помогает, если GitHub у провайдера не открывается.', function() { set('lists_via', !draft.lists_via); })); miscCard.appendChild(row('Обновлять списки', seg(LIST_IV, draft.list_interval, function(v) { set('list_interval', v); }))); - var m = [ E('button', { 'class': 'cbi-button', 'disabled': !configured() || busy ? '' : null, 'click': function() { act('lists'); } }, 'Обновить списки сейчас') ]; - if (configured() && cfg.mode === 'sub') m.push(E('button', { 'class': 'cbi-button', 'disabled': busy ? '' : null, 'click': function() { act('subs'); } }, 'Обновить подписку сейчас')); - miscCard.appendChild(E('div', { 'class': 'zm-actions' }, m)); + miscCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'disabled': !configured() || busy ? '' : null, 'click': function() { act('lists'); } }, 'Обновить списки сервисов сейчас') ])); var extra = (cfg && cfg.extra) || []; if (extra.length) miscCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Есть ещё ' + nn(extra.length, 'секция', 'секции', 'секций') + ', созданных раньше (' + extra.map(function(x) { return x.label; }).join(', ') + '). Они работают как прежде.')); @@ -21452,7 +21958,7 @@ return view.extend({ sbCard.appendChild(E('h3', {}, 'sing-box')); var sv = st.singbox_pkg; sbCard.appendChild(row('Версия', E('span', {}, st.singbox ? st.singbox + ' · ' + (SB_NAMES[sv] || 'установлен вручную') : 'не установлен'))); - var vars = SB_VARS.filter(function(x) { return x.id !== 'compact' || st.sb_compact || sv === 'compact'; }); + var vars = SB_VARS; sbCard.appendChild(row('Вариант', seg(vars, SB_NAMES[sv] ? sv : '', function(v) { var it = SB_VARS.filter(function(x) { return x.id === v; })[0]; if (!confirm('Заменить sing-box на ' + it.label.toLowerCase() + '?\n\n' + it.warn + 'Forkozz перезапустится. Если новый sing-box не заработает, вернётся прежний.')) return;