diff --git a/CHANGELOG.md b/CHANGELOG.md index 28f1abd..4c2c1e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,34 @@ +## 3.93 — 20.10.2026 + +### 🎯 Автоподбор стратегии +- ➕ Режим «Только YouTube»: подбирается стратегия Yv, основная остаётся как есть +- ➕ Переключатель «Подбирать и стратегию для YouTube»: после основной стратегии подбирается Yv на адресах YouTube +- ⚡ Новая стратегия YouTube ставится, только если открывает больше адресов, чем текущая +- ⚡ Если YouTube выключен в стратегии или включено «Не изменять стратегию», подбор для YouTube пропускается +- ➕ Результат подбора для YouTube показывается отдельной строкой в «Последний запуск» +- ⚡ Режим автоподбора можно выбрать, даже когда расписание выключено + +### 📱 Удалённый доступ +- ⚡ Раздел Tailscale переименован в «Удалённый доступ» +- ➕ Кнопка «У меня всё работает»: одобрение роутера можно отметить вручную, если сервер не сообщает о нём +- ➕ Строка «Одобрение — отмечено вручную» с кнопкой «Снять отметку»; при смене сервера отметка сбрасывается сама +- 🐞 Доступ к домашней сети распознаётся, даже если сервер одобрил более широкую подсеть +- ⚡ Для Android — ссылка на последнюю версию Tailscale с GitHub и TailSocks, ссылка на Google Play убрана + +### 🌐 Steer +- 🐞 Проверка маршрута домена работает и при правилах для отдельных устройств + +### 🎨 Web UI +- ➕ Кнопка «Готово» в настройке меню, кнопки разложены сеткой +- ⚡ «Скрыть неустановленные» скрывает и Hosts с блокировкой рекламы, если они не включены +- ⚡ Баннер обновления аккуратно переносится на телефоне + +### 🧩 Прочее +- ⚡ Карточки системной информации раскладываются по колонкам ровнее, без пустот +- ⚡ Подписи и значения в системной информации не наезжают друг на друга +- ⚡ В «Доступности сайтов» сначала показываются недоступные +- ➕ В «Спасибо» добавлены openwrt-tailscale, luci-app-tailscale-community, NaïveProxy и tg-ws-proxy, у routerich — сервер удалённого доступа + ## 3.90 — 20.10.2026 ### 🛡 Zapret diff --git a/ZapretManager_LuCI.sh b/ZapretManager_LuCI.sh index 15e816a..7bfa024 100644 --- a/ZapretManager_LuCI.sh +++ b/ZapretManager_LuCI.sh @@ -1,6 +1,6 @@ #!/bin/sh read -r _ _ ZM_NEW_VER <<'ZM_VERSION_EOF' -# Version: 3.90 +# Version: 3.93 ZM_VERSION_EOF set -e @@ -3642,7 +3642,7 @@ _pg_st_dir() { } _pg_trace() { case "$1" in - zapret) echo "pkg:luci-i18n-zapret-ru pkg:luci-app-zapret pkg:zapret /opt/zapret /etc/config/zapret /etc/config/zapret-opkg /etc/init.d/zapret /etc/rc.d/*[0-9]zapret /etc/firewall.zapret /etc/hotplug.d/*/*zapret /etc/config/zapret.apk-new /tmp/zapret $(_zm_luci_files zapret) nft:zapret cron:zm-autobest $ZO_RKN_FLAG $ZO_USER_FLAG $ZA_MODE_FILE $ZA_LAST $ZA_TS $ZA_TIME_FILE $TEST_BACKUP $ZC_FILE $ZC_BAK $ZC_AFTER $YV_OFF_FLAG $DV_OFF_FLAG $TEST_DIR $TEST_CUSTOM_FILE" ;; + zapret) echo "pkg:luci-i18n-zapret-ru pkg:luci-app-zapret pkg:zapret /opt/zapret /etc/config/zapret /etc/config/zapret-opkg /etc/init.d/zapret /etc/rc.d/*[0-9]zapret /etc/firewall.zapret /etc/hotplug.d/*/*zapret /etc/config/zapret.apk-new /tmp/zapret $(_zm_luci_files zapret) nft:zapret cron:zm-autobest $ZO_RKN_FLAG $ZO_USER_FLAG $ZA_MODE_FILE $ZA_LAST $ZA_YT_FILE $ZA_LAST_YT $ZA_TS $ZA_TIME_FILE $TEST_BACKUP $ZC_FILE $ZC_BAK $ZC_AFTER $YV_OFF_FLAG $DV_OFF_FLAG $TEST_DIR $TEST_CUSTOM_FILE" ;; zapret2) echo "pkg:luci-i18n-zapret2-ru pkg:luci-app-zapret2 pkg:zapret2 /opt/zapret2 /etc/config/zapret2 /etc/config/zapret2-opkg /etc/init.d/zapret2 /etc/rc.d/*zapret2 /etc/hotplug.d/*/*zapret2 /etc/config/zapret2.apk-new /tmp/zapret2* $(_zm_luci_files zapret2) nft:zapret2 cronrx:zapret2" ;; bytetube) echo "pkg:byedpi /usr/bin/ciadpi /etc/init.d/byedpi /etc/rc.d/*byedpi /etc/config/byedpi /etc/config/byedpi-opkg /usr/bin/bytetube /etc/config/bytetube /etc/init.d/bytetube /etc/rc.d/*bytetube /usr/libexec/bytetube /usr/share/bytetube /etc/bytetube /etc/hotplug.d/firewall/90-bytetube /usr/share/nftables.d/chain-pre/forward/50-bytetube.nft /var/etc/bytetube /var/run/bytetube.started /tmp/bytetube-test /lib/upgrade/keep.d/bytetube /tmp/dhcp.ytb.bak dnsconf:bytetube.conf cronrx:bytetube" @@ -5342,7 +5342,7 @@ test_action() { job_start strategy_test do_autobest ;; auto_tick) test_auto_tick ;; - auto_clear) rm -f "$ZA_LAST"; test_status ;; + auto_clear) rm -f "$ZA_LAST" "$ZA_LAST_YT"; test_status ;; stop) _test_running || { echo '{"error":"тест не запущен"}'; return 1; } mkdir -p "$TEST_DIR" @@ -5582,7 +5582,10 @@ ZA_LAST="$ZM_STATE_DIR/autobest.last" ZA_RUN="$TEST_DIR/autobest" ZA_TS="$ZM_STATE_DIR/autobest.ts" -_za_mode() { case "$(cat "$ZA_MODE_FILE" 2>/dev/null)" in v) echo v ;; flowseal) echo flowseal ;; *) echo v_flowseal ;; esac; } +ZA_YT_FILE="$ZM_STATE_DIR/autobest.yt" +ZA_LAST_YT="$ZM_STATE_DIR/autobest.last_yt" +_za_mode() { case "$(cat "$ZA_MODE_FILE" 2>/dev/null)" in v) echo v ;; flowseal) echo flowseal ;; youtube) echo youtube ;; *) echo v_flowseal ;; esac; } +_za_yt() { [ -f "$ZA_YT_FILE" ]; } ZA_TIME_FILE="$ZM_STATE_DIR/autobest.time" _za_time() { grep -F "$ZA_TAG" "$CRON_FILE" 2>/dev/null | head -n 1 | awk '{ printf "%02d:%02d\n", $2, $1 }'; } _za_running() { [ -f "$ZA_RUN" ] && _test_running; } @@ -5634,28 +5637,93 @@ _za_extras_restore() { _za_last_write() { mkdir -p "$ZM_STATE_DIR" - printf '%s|%s|%s|%s|%s|%s|%s|%s\n' "$(date +%s)" "$1" "$2" "$3" "$4" "$5" "$6" "$7" > "$ZA_LAST" + printf '%s|%s|%s|%s|%s|%s|%s|%s\n' "$(date +%s)" "$1" "$2" "$3" "$4" "$5" "$6" "$7" > "${ZA_LAST_TO:-$ZA_LAST}" } do_autobest() { - local mode res best bname bok btot cok ctot cname urls applied=0 r + local mode rc=0 mode="$(_za_mode)" mkdir -p "$TEST_DIR"; : > "$ZA_RUN" - echo "==> Автоподбор стратегии: $(case "$mode" in v) echo v1–v10 ;; flowseal) echo Flowseal ;; *) echo 'v + Flowseal' ;; esac)" + echo "==> Автоподбор стратегии: $(case "$mode" in v) echo v1–v10 ;; flowseal) echo Flowseal ;; youtube) echo 'только YouTube (Yv)' ;; *) echo 'v + Flowseal' ;; esac)$([ "$mode" != youtube ] && _za_yt && echo ', затем YouTube (Yv)')" [ -f "$CONF" ] || { echo "ОШИБКА: Zapret не установлен"; rm -f "$ZA_RUN"; return 1; } _test_unstale + rm -f "$ZA_LAST_YT" if _nochange_strategy; then echo "==> Включено «Не изменять стратегию» (#nochange) — автоподбор ничего не меняет" _za_last_write "$mode" "" 0 0 0 "" nochange; rm -f "$ZA_RUN"; return 0 fi + ZT_STOPPED="" + if [ "$mode" = youtube ]; then + _za_youtube; rc=$? + else + _za_main "$mode"; rc=$? + if [ "$rc" = 0 ] && [ -z "$ZT_STOPPED" ] && _za_yt; then + ZA_LAST_TO="$ZA_LAST_YT"; _za_youtube; ZA_LAST_TO="" + fi + fi + rm -f "$ZA_RUN" + return "$rc" +} + +_za_youtube() { + local res best yname yok ytot cur cok ctot r applied=0 + echo "==> YouTube: подбираем стратегию Yv" + if [ -f "$YV_OFF_FLAG" ]; then + echo " YouTube выключен в стратегии — его настройку не трогаем" + _za_last_write youtube "" 0 0 0 "" yoff; return 0 + fi + if _nochange_strategy; then + echo "==> Включено «Не изменять стратегию» (#nochange) — YouTube не трогаем" + _za_last_write youtube "" 0 0 0 "" nochange; return 0 + fi + cur="$(grep -oE '^#[[:space:]]*Yv[0-9]+$' "$CONF" | head -n1 | sed 's/^#[[:space:]]*//')" + do_test_run youtube || { _za_last_write youtube "" 0 0 0 "$cur" error; return 1; } + res="$(_test_results_file youtube)" + best="$(grep -v '^Контрольный тест' "$res" 2>/dev/null | head -n 1)" + if [ -z "$best" ] || [ -n "$ZT_STOPPED" ]; then + echo "==> YouTube: лучшую стратегию определить не удалось — оставляем текущую" + _za_last_write youtube "" 0 0 0 "$cur" none; return 0 + fi + yname="$(printf '%s' "$best" | sed 's/[[:space:]]*→.*$//')" + yok="$(printf '%s' "$best" | sed -n 's/.*→[[:space:]]*\([0-9]*\)\/\([0-9]*\).*/\1/p')" + ytot="$(printf '%s' "$best" | sed -n 's/.*→[[:space:]]*\([0-9]*\)\/\([0-9]*\).*/\2/p')" + echo "==> Проверяем текущую настройку YouTube (${cur:-из основной стратегии}) на тех же адресах" + r="$(_test_check_all_urls "$(_test_yt_urls)" "$TEST_DIR/log_cur_yt.txt")" + cok="${r% *}"; ctot="${r#* }" + echo " текущая: ${cok}/${ctot}, лучшая из теста: $yname — ${yok}/${ytot}" + if [ "$yname" != "$cur" ] && [ "${yok:-0}" -gt "${cok:-0}" ] 2>/dev/null; then + if _nochange_strategy; then + echo "==> Пока шёл тест, включили «Не изменять стратегию» — ничего не меняем" + _za_last_write youtube "$yname" "$yok" "$ytot" "$cok" "$cur" nochange; return 0 + fi + echo "==> Применяем $yname для YouTube" + _zc_lift + r="$(strategy_set_youtube "$yname" 2>/dev/null)" + _zc_drop + case "$r" in *'"ok":true'*) applied=1; echo "==> Готово: стратегия YouTube $yname применена" ;; *) echo "!! Не удалось применить $yname — YouTube оставлен как был" ;; esac + if [ "$applied" = 1 ] && _nfq_opt_body | _ts_has; then + date +%s > "$ZA_TS" + echo " ! в стратегии есть метка ts — на компьютерах с Windows нужно один раз выполнить:" + echo " netsh int tcp set global timestamps=enabled (в терминале от имени администратора)" + fi + [ "$applied" = 1 ] && logger -t zapret-manager "Автоподбор: для YouTube применена $yname ($yok/$ytot, было $cok/$ctot)" + else + echo "==> Текущая настройка YouTube не хуже — оставляем её" + fi + _za_last_write youtube "$yname" "$yok" "$ytot" "$cok" "$cur" "$([ "$applied" = 1 ] && echo applied || echo kept)" + return 0 +} + +_za_main() { + local mode="$1" res best bname bok btot cok ctot cname urls applied=0 r cname="$(_nfq_names | sed 's/^#[[:space:]]*//' | grep -vE '^(udp443|Yv[0-9]+|Dv[0-9]+|Gv[0-9]+.*)$' | head -n 1)" [ -n "$cname" ] || cname="$(grep -m1 '^# ZMFS:' "$CONF" | sed 's/^# ZMFS://')" - do_test_run "$mode" || { _za_last_write "$mode" "" 0 0 0 "" error; rm -f "$ZA_RUN"; return 1; } + do_test_run "$mode" || { _za_last_write "$mode" "" 0 0 0 "" error; return 1; } res="$(_test_results_file "$mode")" best="$(grep -v '^Контрольный тест' "$res" 2>/dev/null | head -n 1)" if [ -z "$best" ] || [ -n "$ZT_STOPPED" ]; then echo "==> Лучшую стратегию определить не удалось — оставляем текущую" - _za_last_write "$mode" "" 0 0 0 "" none; rm -f "$ZA_RUN"; return 0 + _za_last_write "$mode" "" 0 0 0 "" none; return 0 fi bname="$(printf '%s' "$best" | sed 's/[[:space:]]*→.*$//')" bok="$(printf '%s' "$best" | sed -n 's/.*→[[:space:]]*\([0-9]*\)\/\([0-9]*\).*/\1/p')" @@ -5668,7 +5736,7 @@ do_autobest() { if [ "${bok:-0}" -gt "${cok:-0}" ] 2>/dev/null; then if _nochange_strategy; then echo "==> Пока шёл тест, включили «Не изменять стратегию» — ничего не меняем" - _za_last_write "$mode" "$bname" "$bok" "$btot" "$cok" "$cname" nochange; rm -f "$ZA_RUN"; return 0 + _za_last_write "$mode" "$bname" "$bok" "$btot" "$cok" "$cname" nochange; return 0 fi echo "==> Применяем $bname" _zc_has && echo " · свой блок #CustomStart … #CustomEnd сохраняем — он останется в самом верху стратегии" @@ -5692,13 +5760,13 @@ do_autobest() { echo "==> Текущая стратегия не хуже — оставляем её" fi _za_last_write "$mode" "$bname" "$bok" "$btot" "$cok" "$cname" "$([ "$applied" = 1 ] && echo applied || echo kept)" - rm -f "$ZA_RUN" return 0 } test_auto_set() { - local when="${1%%|*}" mode="${1#*|}" h m - case "$mode" in v|flowseal|v_flowseal) ;; *) mode="$(_za_mode)" ;; esac + local when="${1%%|*}" mode="${1#*|}" yt="" h m + case "$mode" in *'|'*) yt="${mode#*|}"; mode="${mode%%|*}" ;; esac + case "$mode" in v|flowseal|v_flowseal|youtube) ;; *) mode="$(_za_mode)" ;; esac case "$when" in off) ;; [0-9]:[0-5][0-9]|[01][0-9]:[0-5][0-9]|2[0-3]:[0-5][0-9]) @@ -5707,6 +5775,7 @@ test_auto_set() { *) echo '{"error":"неверное время"}'; return 1 ;; esac mkdir -p "$ZM_STATE_DIR"; echo "$mode" > "$ZA_MODE_FILE" + case "$yt" in on) : > "$ZA_YT_FILE" ;; off) rm -f "$ZA_YT_FILE" ;; esac mkdir -p "$(dirname "$CRON_FILE")"; touch "$CRON_FILE" sed -i "\\|$ZA_TAG|d" "$CRON_FILE" if [ "$when" != off ]; then @@ -5727,23 +5796,30 @@ test_auto_tick() { job_start strategy_test do_autobest >/dev/null } +_za_last_json() { + local last at m b bo bt co cn res + last="$(cat "$1" 2>/dev/null)" + [ -n "$last" ] || { echo null; return 0; } + IFS='|' read -r at m b bo bt co cn res <<-ZA_EOF + $last + ZA_EOF + printf '{"at":%s,"mode":"%s","best":"%s","best_ok":%s,"total":%s,"cur_ok":%s,"cur":"%s","result":"%s"}' \ + "$(printf '%s' "$at" | tr -cd 0-9 | grep . || echo 0)" "$(esc "$m")" "$(esc "$b")" "$(printf '%s' "$bo" | tr -cd 0-9 | grep . || echo 0)" \ + "$(printf '%s' "$bt" | tr -cd 0-9 | grep . || echo 0)" "$(printf '%s' "$co" | tr -cd 0-9 | grep . || echo 0)" "$(esc "$cn")" "$(esc "$res")" +} + _za_json() { - local h last at m b bo bt co cn res + local h last lasty h="$(_za_time)" - last="$(cat "$ZA_LAST" 2>/dev/null)" - if [ -n "$last" ]; then - IFS='|' read -r at m b bo bt co cn res <<-ZA_EOF - $last - ZA_EOF - last="{\"at\":${at:-0},\"mode\":\"$(esc "$m")\",\"best\":\"$(esc "$b")\",\"best_ok\":${bo:-0},\"total\":${bt:-0},\"cur_ok\":${co:-0},\"cur\":\"$(esc "$cn")\",\"result\":\"$(esc "$res")\"}" - else last=null; fi + last="$(_za_last_json "$ZA_LAST")" + lasty="$(_za_last_json "$ZA_LAST_YT")" local tsat="" tsp="" l tsat="$(cat "$ZA_TS" 2>/dev/null | tr -cd 0-9)" if [ -n "$tsat" ] && [ -f "$CONF" ]; then for l in $(_nfq_opt_body | _ts_parts | tr ' ' '_'); do tsp="$tsp${tsp:+,}\"$(esc "$(printf '%s' "$l" | tr '_' ' ')")\""; done [ -n "$tsp" ] || tsat="" fi - printf '{"time":"%s","pref":"%s","mode":"%s","running":%s,"last":%s,"ts_at":%s,"ts_parts":[%s]}' "$h" "$(cat "$ZA_TIME_FILE" 2>/dev/null || echo 04:00)" "$(_za_mode)" "$(_za_running && echo true || echo false)" "$last" "${tsat:-0}" "$tsp" + printf '{"time":"%s","pref":"%s","mode":"%s","yt":%s,"running":%s,"last":%s,"last_yt":%s,"ts_at":%s,"ts_parts":[%s]}' "$h" "$(cat "$ZA_TIME_FILE" 2>/dev/null || echo 04:00)" "$(_za_mode)" "$(_za_yt && echo true || echo false)" "$(_za_running && echo true || echo false)" "$last" "$lasty" "${tsat:-0}" "$tsp" } _zt_tz() { @@ -10332,10 +10408,20 @@ stl_diag_lines() { rm -f "$f" } +_stl_explain_run() { + local t="" x="$STL_ETC/.zm-explain.$$.json" + if [ -s "$STL_SPEC" ] && grep -q '"scope":"device"' "$STL_SPEC" 2>/dev/null; then + sed 's/"scope":"device"/"scope":"device","enabled":false/g' "$STL_SPEC" > "$x" 2>/dev/null && t="$(_stl_t 20 steer explain --spec "$x" "$1" 2>&1)" + rm -f "$x" + case "$t" in *' -> '*) printf '%s\n' "$t"; return 0 ;; esac + fi + _stl_t 20 steer explain "$1" 2>&1 +} + stl_explain() { local t line STL_X_VERDICT=unknown; STL_X_OUT=""; STL_X_DEV=""; STL_X_SET=""; STL_X_ADDR=""; STL_X_FAKE=false - t="$(_stl_t 20 steer explain "$1" 2>&1)" + t="$(_stl_explain_run "$1")" STL_X_TEXT="$t" line="$(printf '%s\n' "$t" | grep -m1 -- '-> output "')" STL_X_ADDR="$(printf '%s\n' "$t" | head -n1 | sed -n 's/^[^ ]* -> \([0-9a-fA-F.:]*\) (.*/\1/p')" @@ -17008,8 +17094,20 @@ _tsc_login() { return 1 } +_tsc_route_ok() { + { printf '%s' "$1" | jsonfilter -e '@.Self.PrimaryRoutes[*]' 2>/dev/null; printf '%s' "$1" | jsonfilter -e '@.Self.AllowedIPs[*]' 2>/dev/null; } | + awk -v want="$2" ' + function ipn(a, o) { if (split(a, o, ".") != 4) return -1; return ((o[1] * 256 + o[2]) * 256 + o[3]) * 256 + o[4] } + BEGIN { split(want, w, "/"); wa = ipn(w[1]); wm = (w[2] == "" ? 32 : w[2] + 0) } + $0 !~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\/[0-9]+$/ { next } + { split($0, r, "/"); m = r[2] + 0; if (m < 1 || m > wm) next; z = 2 ^ (32 - m); if (int(ipn(r[1]) / z) == int(wa / z)) { ok = 1; exit } } + END { exit !ok }' +} + +TSC_MANUAL="$TSC_DIR/approved.manual" + tsc_status() { - local inst=false run=false en=false on=false j="" st="" url="" ip="" host="" tn="" exo=false lano=false + local inst=false run=false en=false on=false j="" st="" url="" ip="" host="" tn="" exo=false lano=false man=false local pok=true cidr="" ver="" kexp="" lip lprev="" net=false zone=false nft=false tg=false tgr=0 zr=false st_on=false z lz free _tsc_installed && { inst=true; ver="$(_tsc_ver)"; } [ -f "$TSC_ON" ] && on=true @@ -17029,7 +17127,11 @@ tsc_status() { [ "$(printf '%s' "$j" | jsonfilter -e '@.Self.ExitNodeOption' 2>/dev/null)" = true ] && exo=true printf '%s' "$j" | jsonfilter -e '@.Self.AllowedIPs[*]' 2>/dev/null | grep -qx '0.0.0.0/0' && exo=true cidr="$(_tsc_lan_cidr)" - [ -n "$cidr" ] && printf '%s' "$j" | jsonfilter -e '@.Self.PrimaryRoutes[*]' 2>/dev/null | grep -qxF "$cidr" && lano=true + [ -n "$cidr" ] && _tsc_route_ok "$j" "$cidr" && lano=true + [ "$st" = Running ] && [ -s "$TSC_MANUAL" ] && { + ! $exo && grep -qx 'exit' "$TSC_MANUAL" && exo=true && man=true + ! $lano && [ -n "$cidr" ] && grep -qxF "lan $cidr" "$TSC_MANUAL" && lano=true && man=true + } if [ "$st" = Running ] && [ -f "$TSC_ON" ] && ! _job_alive tsc; then local dp dp="$(_tsc_dbg)" @@ -17068,6 +17170,7 @@ tsc_status() { local lat="" vcur $inst && lat="$(_zm_cached tsc _tsc_gh_latest)" vcur="$(_tsc_vnum "$ver")" + printf '"manual_ok":%s,' "$man" printf '"prefs_ok":%s,"luci_path":"%s","key_expiry":"%s","lan_prev":"%s","term_mode":"%s",' "$pok" "$(esc "$(_tsc_luci_path)")" "$(esc "$kexp")" "$(esc "$lprev")" "$(_term_installed && _term_mode)" printf '"fk":%s,"fk_ok":%s,"mx":%s,"adblock":%s,' "$(_fk_installed && echo true || echo false)" "$(_tsc_fk_has && echo true || echo false)" \ "$([ -x /etc/init.d/magitrickle ] && echo true || echo false)" "$(_ab_on && echo true || echo false)" @@ -17206,20 +17309,26 @@ tsc_action() { repair) job_start tsc do_tsc_repair ;; update) job_start tsc do_tsc_update ;; esac ;; - login|logout|restart|exit|lan|server|apply|kick|lanack) + login|logout|restart|exit|lan|server|apply|kick|lanack|approved) _tsc_installed || { echo '{"error":"Tailscale не установлен"}'; return 1; } _job_alive tsc && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } case "$a" in login) _tsc_login ;; - server) _tsc_server_set "$m" ;; + server) rm -f "$TSC_MANUAL"; _tsc_server_set "$m" ;; kick) _tsc_kick_bg; printf '{"ok":true}\n' ;; lanack) mkdir -p "$TSC_DIR"; _zm_lan_ip > "$TSC_DIR/lan.ip"; printf '{"ok":true}\n' ;; + approved) + mkdir -p "$TSC_DIR" + if [ "$m" = off ]; then rm -f "$TSC_MANUAL"; printf '{"ok":true}\n'; return 0; fi + { [ -f "$TSC_NOEXIT" ] || echo exit; [ -f "$TSC_LAN" ] && _tsc_lan_cidr | sed 's/^/lan /'; } > "$TSC_MANUAL" + printf '{"ok":true}\n' ;; apply) _tsc_prefs [ -n "$TSC_KICK" ] && _tsc_kick_bg if _tsc_prefs_ok; then printf '{"ok":true}\n' else echo '{"error":"Tailscale не принял настройки — нажмите «Перезапустить» и попробуйте ещё раз"}'; return 1; fi ;; logout) + rm -f "$TSC_MANUAL" pkill -f 'tailscale (up|login)' >/dev/null 2>&1 _zm_tmo 15 tailscale logout >/dev/null 2>&1 || { echo '{"error":"не получилось выйти из аккаунта — смотрите журнал роутера"}'; return 1; } printf '{"ok":true}\n' ;; @@ -17238,6 +17347,7 @@ tsc_action() { : > "$TSC_LAN" else rm -f "$TSC_LAN"; fi fi + [ "$m" = off ] && rm -f "$TSC_MANUAL" _tsc_prefs || { echo '{"error":"Tailscale не принял настройку — смотрите журнал роутера"}'; return 1; } printf '{"ok":true}\n' ;; esac ;; @@ -24648,7 +24758,7 @@ cat > '/usr/share/luci/menu.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF "action": { "type": "view", "path": "zapret-manager/awg" } }, "admin/services/zapret-manager/tailscale": { - "title": "Tailscale", + "title": "Удалённый доступ", "order": 54, "action": { "type": "view", "path": "zapret-manager/tailscale" } }, @@ -28775,8 +28885,7 @@ var E = (function(raw) { return function() { var a = Array.prototype.slice.call( var ROUTERICH = 'https://rc.routerich.ru'; var ROUTERICH_NEW = 'https://remote.routerich.ru/create-tailnet'; -var APK_URL = 'https://github.com/tailscale/tailscale-android/releases'; -var PLAY_URL = 'https://play.google.com/store/apps/details?id=com.tailscale.ipn'; +var APK_URL = 'https://github.com/tailscale/tailscale-android/releases/latest'; var IOS_URL = 'https://apps.apple.com/app/tailscale/id1470499037'; var TESTFLIGHT_URL = 'https://testflight.apple.com/join/tLcYLZJV'; var TAILSOCKS_URL = 'https://github.com/bropines/tailsocks/releases/latest'; @@ -29026,8 +29135,8 @@ return view.extend({ function renderIntro() { var free = +data.free_kb || 0, need = +data.need_kb || 20480, low = free > 0 && free < need; - mainCard.appendChild(E('h3', {}, 'Tailscale')); - mainCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Телефон в любой сети — мобильный интернет, кафе, поездка — выходит в интернет через домашний роутер. С ним работают Zapret, Steer и sTGWS, как дома. Белый IP не нужен: телефон и роутер находят друг друга сами.')); + mainCard.appendChild(E('h3', {}, 'Удалённый доступ')); + mainCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Работает на Tailscale. Телефон в любой сети — мобильный интернет, кафе, поездка — выходит в интернет через домашний роутер. С ним работают Zapret, Steer и sTGWS, как дома. Белый IP не нужен: телефон и роутер находят друг друга сами.')); mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Всё ставится с GitHub: уменьшенная сборка Tailscale для OpenWrt и страница для LuCI. Сайт Tailscale из России не открывается, поэтому подключиться можно через Routerich — по ключу, без аккаунта.')); mainCard.appendChild(E('div', { 'class': 'zm-kv' }, [ row('Нужно', E('span', {}, 'около ' + Math.round(need / 1024) + ' МБ флеш-памяти')), @@ -29041,7 +29150,7 @@ return view.extend({ function renderMain() { mainCard.innerHTML = ''; if (!data.installed) { renderIntro(); return; } - mainCard.appendChild(E('h3', {}, 'Tailscale')); + mainCard.appendChild(E('h3', {}, 'Удалённый доступ')); var st = data.state || '', kv = E('div', { 'class': 'zm-kv' }); kv.appendChild(row('Служба', data.running ? badge('zm-ok', 'работает') : badge(data.enabled ? 'zm-bad' : 'zm-off', 'остановлена'))); kv.appendChild(row('Сервер', E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap' }, [ @@ -29061,6 +29170,10 @@ return view.extend({ kv.appendChild(row('Выход в интернет', !data.exit_want ? badge('zm-off', 'выключен') : data.exit_ok ? badge('zm-ok', 'готов — выберите роутер на телефоне') : badge('zm-warn', 'ждёт одобрения на сервере'))); if (data.lan_want) kv.appendChild(row('Домашняя сеть', data.lan_ok ? badge('zm-ok', 'доступна · ' + data.lan_cidr) : badge('zm-warn', 'ждёт одобрения · ' + data.lan_cidr))); + if (data.manual_ok) kv.appendChild(row('Одобрение', E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap' }, [ + E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'отмечено вручную'), + E('button', { 'type': 'button', 'class': 'cbi-button', 'style': 'padding:2px 10px', 'disabled': busy ? '' : null, 'click': function() { quick('approved', 'off', 'Отметка снята — панель снова проверяет сервер'); } }, 'Снять отметку') + ]))); kv.appendChild(row('Вход роутера', keyExp() ? badge(keyDays() < 30 ? 'zm-bad' : 'zm-warn', 'истекает ' + keyExp()) : badge('zm-ok', 'бессрочный'))); } var srcName = data.source === 'github' ? ' · сборка с GitHub' : data.source === 'openwrt' ? ' · из репозитория OpenWrt' : ''; @@ -29186,7 +29299,16 @@ return view.extend({ ? (data.lan_want && !data.lan_ok ? [ 'Нажмите ', E('b', {}, 'Маршруты'), ', отметьте подсеть ', E('b', {}, data.lan_cidr), ' и сохраните' ] : null) : [ whatNode ] ].filter(Boolean)), - E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Статус здесь обновится в течение минуты.') + E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Статус здесь обновится в течение минуты. Уже одобрили, телефон видит домашнюю сеть и выходит в интернет через роутер, а надпись не уходит? Часть серверов не сообщает роутеру об одобрении — отметьте вручную.'), + E('div', {}, [ E('button', { 'type': 'button', 'class': 'cbi-button', 'disabled': busy ? '' : null, 'click': function() { + zm.dialog({ title: 'Всё уже работает?', okText: 'Да, всё работает', + blocks: [ { type: 'list', title: 'Отметьте, только если с телефона через Tailscale', items: [ + data.lan_want && !data.lan_ok ? 'открываются устройства домашней сети (например, LuCI по адресу ' + (data.lan_ip || 'роутера') + ')' : null, + data.exit_want && !data.exit_ok ? 'работает выход в интернет через роутер' : null, + 'Панель перестанет показывать «ждёт одобрения»; сменится домашняя подсеть — отметка сбросится сама' + ].filter(Boolean) } ] + }).then(function(v) { if (v) quick('approved', 'on', 'Отмечено: роутер одобрен на сервере'); }); + } }, 'У меня всё работает') ]) ])); } @@ -29269,7 +29391,7 @@ return view.extend({ if (useLan && !data.lan_ok) phoneCard.appendChild(E('p', { 'class': 'zm-hint zm-ab-warn' }, 'Домашняя сеть ' + (data.lan_cidr || '') + ' ещё не одобрена на сервере — без этого телефон не достучится до ' + dnsIp + '. Одобрите её (см. выше).')); phoneCard.appendChild(E('h4', { 'style': 'margin:14px 0 6px' }, '2. На телефоне')); - var android = [ E('b', {}, 'Android'), ': ', ext(PLAY_URL, 'Google Play'), ' или ', ext(APK_URL, 'APK с GitHub'), ' (файл tailscale-android-universal). Или ', E('b', {}, 'TailSocks'), ' — неофициальный клиент Tailscale с открытым кодом (', ext(TAILSOCKS_URL, 'APK на GitHub'), ')' ]; + var android = [ E('b', {}, 'Android'), ': ', ext(APK_URL, 'Tailscale'), ' (файл tailscale-android-universal) или ', ext(TAILSOCKS_URL, 'TailSocks'), ' — неофициальный клиент Tailscale с открытым кодом' ]; var iphone = [ E('b', {}, 'iPhone'), ': ', ext(IOS_URL, 'App Store'), ', а если его там нет — ', ext(TESTFLIGHT_URL, 'TestFlight') ]; phoneCard.appendChild(steps([ [ 'Установите приложение ', E('b', {}, 'Tailscale'), '. ' ].concat(android, [ '. ' ], iphone), @@ -29320,7 +29442,7 @@ return view.extend({ function renderLoading() { mainCard.innerHTML = ''; - mainCard.appendChild(E('h3', {}, 'Tailscale')); + mainCard.appendChild(E('h3', {}, 'Удалённый доступ')); if (loadErr) { mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Роутер не ответил: ' + loadErr)); mainCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-action', 'click': firstLoad }, 'Повторить') ])); @@ -33355,7 +33477,7 @@ var TEST_MODE_LABELS = { var TEST_RESULT_MODES = ['current', 'domain', 'v', 'flowseal', 'v_flowseal', 'youtube', 'custom', 'custom_yt']; var ZONE_RU = { 'Europe/Kaliningrad': 'Калининград', 'Europe/Moscow': 'Москва', 'Europe/Samara': 'Самара', 'Asia/Yekaterinburg': 'Екатеринбург', 'Asia/Omsk': 'Омск', 'Asia/Krasnoyarsk': 'Красноярск', 'Asia/Irkutsk': 'Иркутск', 'Asia/Yakutsk': 'Якутск', 'Asia/Vladivostok': 'Владивосток', 'Asia/Magadan': 'Магадан', 'Asia/Kamchatka': 'Камчатка', 'UTC': 'UTC' }; -var AUTO_MODES = [ { id: 'v', label: 'v1–v10' }, { id: 'flowseal', label: 'Flowseal' }, { id: 'v_flowseal', label: 'v + Flowseal' } ]; +var AUTO_MODES = [ { id: 'v', label: 'v1–v10' }, { id: 'flowseal', label: 'Flowseal' }, { id: 'v_flowseal', label: 'v + Flowseal' }, { id: 'youtube', label: 'Только YouTube' } ]; var CUSTOM_SCRIPTS = [ { id: 'off', label: 'Нет' }, { id: 'stun4all', label: '50-stun4all', hint: 'обход для STUN на всех портах — голос и видео в Discord, Telegram, WhatsApp и других' }, @@ -33930,10 +34052,10 @@ return view.extend({ var mins = (+t.slice(0, 2)) * 60 + (+t.slice(3, 5)), cur = now.getUTCHours() * 60 + now.getUTCMinutes(); return (mins > cur ? 'сегодня в ' : 'завтра в ') + t; } - function autoSet(when, mode, okText) { + function autoSet(when, mode, okText, yt) { if (autoBusy) return; autoBusy = true; renderAuto(); - zm.testAction('auto_set', when + '|' + mode).then(function(r) { + zm.testAction('auto_set', when + '|' + mode + (yt ? '|' + yt : '')).then(function(r) { autoBusy = false; if (!r || r.error) { zm.toast((r && r.error) || 'Роутер не ответил', 'error'); renderAuto(); return; } autoInfo = r.auto || {}; timeInfo = r; @@ -33942,11 +34064,11 @@ return view.extend({ }).catch(function() { autoBusy = false; zm.toast('Роутер не ответил', 'error'); renderAuto(); }); } function renderAuto() { - var on = !!autoInfo.time, mode = autoInfo.mode || 'v_flowseal', t = autoInfo.time || autoInfo.pref || '04:00'; + var on = !!autoInfo.time, mode = autoInfo.mode || 'v_flowseal', t = autoInfo.time || autoInfo.pref || '04:00', yt = !!autoInfo.yt; var preset = AUTO_TIMES.indexOf(t) >= 0 && !autoOwn; autoCard.innerHTML = ''; autoCard.appendChild(E('h3', {}, [ 'Автоподбор стратегии ', on ? zm.badge(true, 'ежедневно в ' + autoInfo.time, '') : E([]) ])); - autoCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Роутер проверяет стратегии на списке заблокированных сайтов и сравнивает лучшую с текущей. Новая стратегия применяется, только если открывает больше сайтов. Настройки YouTube, Discord, игр, QUIC и дополнения к стратегии сохраняются.')); + autoCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Роутер проверяет стратегии на списке заблокированных сайтов и сравнивает лучшую с текущей. Новая стратегия применяется, только если открывает больше сайтов. Настройки Discord, игр, QUIC и дополнения к стратегии сохраняются, YouTube — тоже, если не включён подбор для него.')); autoCard.appendChild(zm.swRow(on, 'Запускать ежедневно', on ? 'Следующий запуск: ' + nextText(autoInfo.time) + '. Проверка длится 5–10 минут, Zapret в это время перезапускается.' : 'Выключено — стратегия меняется только вручную.', function() { if (autoBusy) return; autoSet(on ? 'off' : t, mode); @@ -33964,19 +34086,29 @@ return view.extend({ } }, 'Сохранить')); } autoCard.appendChild(E('div', { 'class': 'zm-row zm-ab-row' }, [ E('span', { 'class': 'zm-label' }, 'Время запуска'), E('div', { 'class': 'zm-ab-ctl' }, ctl) ])); - autoCard.appendChild(E('div', { 'class': 'zm-row zm-ab-row' }, [ E('span', { 'class': 'zm-label' }, 'Стратегии'), - E('div', { 'class': 'zm-seg' + (autoBusy ? ' zm-seg-busy' : '') }, AUTO_MODES.map(function(m) { - return E('div', { 'class': 'zm-seg-item' + (m.id === mode ? ' zm-active' : ''), 'click': function() { if (!autoBusy && m.id !== mode) autoSet(autoInfo.time, m.id, 'Автоподбор будет проверять: ' + m.label); } }, m.label); - })) - ])); + } + autoCard.appendChild(E('div', { 'class': 'zm-row zm-ab-row' }, [ E('span', { 'class': 'zm-label' }, 'Стратегии'), + E('div', { 'class': 'zm-seg' + (autoBusy ? ' zm-seg-busy' : '') }, AUTO_MODES.map(function(m) { + return E('div', { 'class': 'zm-seg-item' + (m.id === mode ? ' zm-active' : ''), 'click': function() { if (!autoBusy && m.id !== mode) autoSet(autoInfo.time || 'off', m.id, 'Автоподбор будет проверять: ' + (m.id === 'youtube' ? 'только стратегии YouTube (Yv)' : m.label + (yt ? ' и YouTube' : ''))); } }, m.label); + })) + ])); + if (mode !== 'youtube') autoCard.appendChild(zm.swRow(yt, 'Подбирать и стратегию для YouTube', + yt ? 'Сначала подбирается основная стратегия, затем для YouTube (Yv) — на адресах YouTube. Новая ставится, только если открывает больше.' : 'Выключено — настройка YouTube остаётся как есть.', + function() { if (!autoBusy) autoSet(autoInfo.time || 'off', mode, yt ? 'Подбор для YouTube выключен' : 'Включено: после основной стратегии — подбор для YouTube', yt ? 'off' : 'on'); }, autoBusy)); + else autoCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Меняется только стратегия YouTube (Yv), основная стратегия остаётся как есть.')); + function lastRes(l) { + var yv = l.mode === 'youtube', unit = yv ? ' адресов YouTube' : ' сайтов', cur = l.cur || (yv ? 'из основной стратегии' : 'текущая'); + return l.result === 'applied' ? [ E('b', { 'class': 'zm-ab-ok' }, 'применена ' + l.best), ' — ' + l.best_ok + ' из ' + l.total + unit + ' (было ' + cur + ' — ' + l.cur_ok + ' из ' + l.total + ')' ] + : l.result === 'kept' ? [ E('b', {}, 'оставлена ' + cur), ' — ' + l.cur_ok + ' из ' + l.total + unit + '; лучшая из проверенных ' + l.best + ' — ' + l.best_ok + ' из ' + l.total ] + : l.result === 'nochange' ? [ 'пропущен — включено «Не изменять стратегию»' ] + : l.result === 'yoff' ? [ 'пропущен — YouTube выключен в стратегии' ] + : l.result === 'none' ? [ 'лучшую стратегию определить не удалось, оставлена текущая' ] : [ E('b', { 'class': 'zm-ab-bad' }, 'ошибка'), ' — подробности в журнале теста' ]; } var last = autoInfo.last; if (last && last.at) { - var res = last.result === 'applied' ? [ E('b', { 'class': 'zm-ab-ok' }, 'применена ' + last.best), ' — ' + last.best_ok + ' из ' + last.total + ' сайтов (было ' + (last.cur || 'текущая') + ' — ' + last.cur_ok + ' из ' + last.total + ')' ] - : last.result === 'kept' ? [ E('b', {}, 'оставлена ' + (last.cur || 'текущая')), ' — ' + last.cur_ok + ' из ' + last.total + ' сайтов; лучшая из проверенных ' + last.best + ' — ' + last.best_ok + ' из ' + last.total ] - : last.result === 'nochange' ? [ 'пропущен — включено «Не изменять стратегию»' ] - : last.result === 'none' ? [ 'лучшую стратегию определить не удалось, оставлена текущая' ] : [ E('b', { 'class': 'zm-ab-bad' }, 'ошибка'), ' — подробности в журнале теста' ]; - autoCard.appendChild(E('div', { 'class': 'zm-ab-last' }, [ E('span', { 'class': 'zm-label' }, 'Последний запуск ' + whenText(last.at) + ': ') ].concat(res))); + var ly = autoInfo.last_yt, lastEl = E('div', { 'class': 'zm-ab-last' }, [ E('span', { 'class': 'zm-label' }, 'Последний запуск ' + whenText(last.at) + (last.mode === 'youtube' ? ', YouTube: ' : ': ')) ].concat(lastRes(last))); + if (ly && ly.at && last.mode !== 'youtube') lastEl.appendChild(E('div', { 'style': 'margin-top:6px' }, [ E('span', { 'class': 'zm-label' }, 'YouTube: ') ].concat(lastRes(ly)))); + autoCard.appendChild(lastEl); } var yr = timeInfo.time ? new Date(timeInfo.time * 1000).getFullYear() : 0; if (on && yr && yr < 2024) autoCard.appendChild(E('p', { 'class': 'zm-hint' }, '⚠ Время на роутере неверное — расписание не сработает. Настройте его на странице «Система».')); @@ -35430,6 +35562,14 @@ html.zm-theme-dark .zm-list-dirty { color: #d29922; } margin-top: 12px; } .zm-refresh-banner button { flex-shrink: 0; } +.zm-refresh-banner:not(.zm-reboot-banner) { flex-wrap: wrap; } +.zm-refresh-banner:not(.zm-reboot-banner) > span:first-child { flex: 1 1 260px; min-width: 0; line-height: 1.45; } +@media (max-width: 640px) { + .zm-refresh-banner:not(.zm-reboot-banner) { padding: 12px 14px; font-size: 14px; gap: 10px 12px; } + .zm-refresh-banner:not(.zm-reboot-banner) > span:first-child { flex-basis: 100%; } + .zm-refresh-banner:not(.zm-reboot-banner) > .cbi-button { flex: 1 1 auto; margin: 0; min-width: 0; white-space: normal; } + .zm-refresh-banner:not(.zm-reboot-banner) > .cbi-button.zm-cl-link { flex: 0 0 auto; } +} .zm-ab-auto { margin-top: 16px; border: 1px solid rgba(127,127,127,.22); border-radius: 12px; padding: 12px 14px; background: rgba(127,127,127,.05); } .zm-ab-auto-title { font-weight: 600; font-size: 13.5px; } .zm-ab-auto-sub { font-size: 12.5px; opacity: .65; margin-top: 2px; line-height: 1.45; } @@ -35763,18 +35903,22 @@ html.zm-theme-dark .zm-fk-chip.zm-fk-chip-x { border-color: rgba(255,255,255,.25 .zm-log-wait { color: #e3c04a; font-weight: 600; white-space: pre-wrap; } .zm-si-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 14px; margin-top: 14px; } .zm-si-group { margin: 22px 0 10px; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; opacity: .6; } -.zm-si-cols { column-width: 340px; column-gap: 14px; } -.zm-si-cols > .zm-si-box { break-inside: avoid; -webkit-column-break-inside: avoid; display: inline-block; width: 100%; margin: 0 0 14px; vertical-align: top; } +.zm-si-mas { display: flex; align-items: flex-start; gap: 14px; } +.zm-si-mas.zm-si-even { align-items: stretch; } +.zm-si-col { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 14px; } +.zm-si-col > .zm-si-box { margin: 0 !important; } +.zm-si-even .zm-si-col > .zm-si-box { flex: 1 0 auto; } .zm-si-box { border: 1px solid rgba(0,0,0,.1); border-radius: 12px; padding: 12px 14px; background: rgba(0,0,0,.02); min-width: 0; box-sizing: border-box; } div > .zm-si-box { margin-top: 14px; } .zm-si-grid > .zm-si-box { margin-top: 0; } html.zm-theme-dark .zm-si-box { border-color: rgba(255,255,255,.12); background: rgba(255,255,255,.03); } .zm-si-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px; } .zm-si-head h4 { margin: 0; font-size: 14px; } -.zm-si-box .zm-row { padding: 5px 0; gap: 12px; } -.zm-si-box .zm-row > .zm-label { min-width: 150px; } +.zm-si-box .zm-row { padding: 5px 0; gap: 12px; display: grid; grid-template-columns: minmax(96px, 42%) minmax(0, 1fr); align-items: center; } +.zm-si-box .zm-row > .zm-label { min-width: 0 !important; width: auto !important; overflow-wrap: anywhere; } +.zm-si-box .zm-row > .zm-badge { justify-self: start; max-width: 100%; white-space: normal; border-radius: 10px; } .zm-si-val { overflow-wrap: anywhere; } -@media (max-width: 600px) { .zm-si-box .zm-row > .zm-label { min-width: 110px; } } + .zm-si-pre { margin: 0; white-space: pre-wrap; word-break: break-all; font-size: 12px; max-height: 260px; overflow: auto; } .zm-chips { display: flex; flex-wrap: wrap; gap: 6px; } .zm-chip { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; background: rgba(110,118,129,.14); } @@ -36526,6 +36670,66 @@ return view.extend({ return E('div', { 'class': 'zm-si-box' }, [ E('div', { 'class': 'zm-si-head' }, [ E('h4', {}, [ title ]), badge || E([]) ]) ].concat(body)); } + function siSitesSorted() { + return (si.sites || []).map(function(x, i) { return { x: x, i: i }; }).sort(function(a, b) { + return (a.x.ok ? 1 : 0) - (b.x.ok ? 1 : 0) || a.i - b.i; + }).map(function(o) { return o.x; }); + } + + var SI_COL_MIN = 330, SI_GAP = 14; + function siMasonry(boxes) { + var host = E('div', { 'class': 'zm-si-mas' }), col = E('div', { 'class': 'zm-si-col' }); + boxes.forEach(function(b) { col.appendChild(b); }); + host.appendChild(col); + host._boxes = boxes; host._w = 0; + var relayout = function() { siMasLayout(host); }; + if (window.ResizeObserver) new ResizeObserver(relayout).observe(host); + else { window.addEventListener('resize', relayout); setTimeout(relayout, 0); setTimeout(relayout, 400); } + return host; + } + function siSpread(cols) { + var hi = 0, lo = Infinity; + cols.forEach(function(c) { hi = Math.max(hi, c.h); lo = Math.min(lo, c.h); }); + return hi - lo; + } + function siAssign(hs, n, order) { + var cols = []; + for (var i = 0; i < n; i++) cols.push({ h: 0, items: [] }); + order.forEach(function(k) { + var best = cols[0]; + cols.forEach(function(c) { if (c.h < best.h - 0.5) best = c; }); + best.items.push(k); best.h += hs[k] + SI_GAP; + }); + cols.forEach(function(c) { c.items.sort(function(a, b) { return a - b; }); }); + return cols; + } + function siMasLayout(host) { + var boxes = host._boxes, w = host.clientWidth; + if (!w || !boxes || !host.isConnected) return; + var n = Math.max(1, Math.min(boxes.length, Math.floor((w + SI_GAP) / (SI_COL_MIN + SI_GAP)))); + if (w === host._w && n === host._n) return; + host._w = w; host._n = n; + host.classList.remove('zm-si-even'); + host.innerHTML = ''; + var colW = (w - SI_GAP * (n - 1)) / n; + var meter = E('div', { 'class': 'zm-si-col', 'style': 'flex:0 0 ' + colW + 'px; width:' + colW + 'px' }); + boxes.forEach(function(b) { meter.appendChild(b); }); + host.appendChild(meter); + var hs = boxes.map(function(b) { return b.offsetHeight; }); + host.innerHTML = ''; + var idx = boxes.map(function(b, i) { return i; }); + var plans = [ siAssign(hs, n, idx), siAssign(hs, n, idx.slice().sort(function(a, b) { return hs[b] - hs[a] || a - b; })) ]; + var plan = siSpread(plans[1]) + 1 < siSpread(plans[0]) ? plans[1] : plans[0]; + plan.sort(function(a, b) { return (a.items.length ? a.items[0] : 1e9) - (b.items.length ? b.items[0] : 1e9); }); + plan.forEach(function(c) { + if (!c.items.length) return; + var col = E('div', { 'class': 'zm-si-col' }); + c.items.forEach(function(k) { col.appendChild(boxes[k]); }); + host.appendChild(col); + }); + if (n > 1) host.classList.add('zm-si-even'); + } + function siSections() { var l = (si.sections || []).slice(), i = -1; l.forEach(function(s, k) { if (s.title === 'Компоненты ZM') i = k; }); @@ -36547,7 +36751,7 @@ return view.extend({ if (si.packages && si.packages.length) out.push('', '== Пакеты ==', si.packages.join(', ')); if (si.sites && si.sites.length) { out.push('', '== Доступность сайтов: ' + si.sites_ok + '/' + si.sites_total + ' =='); - si.sites.forEach(function(x) { out.push((x.ok ? '[ OK ] ' : '[FAIL] ') + x.name); }); + siSitesSorted().forEach(function(x) { out.push((x.ok ? '[ OK ] ' : '[FAIL] ') + x.name); }); } out.push('', '(адреса, ключи, пароли и ссылки подписок в отчёт не включены)'); return out.join('\n'); @@ -36576,20 +36780,20 @@ return view.extend({ siBody.innerHTML = ''; if (!si || siBusy) return; var SYS = { 'Роутер': 1, 'Память': 1, 'Система': 1, 'Сеть': 1 }; - var gSys = E('div', { 'class': 'zm-si-cols' }), gZm = E('div', { 'class': 'zm-si-cols' }); + var bSys = [], bZm = []; siSections().forEach(function(s) { if (!s.rows || !s.rows.length) return; - (SYS[s.title] ? gSys : gZm).appendChild(siBox(s.title, s.rows.map(function(r) { + (SYS[s.title] ? bSys : bZm).push(siBox(s.title, s.rows.map(function(r) { return E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, [ r[0] ]), siState(r[1], r[2]) ]); }))); }); - if (si.hosts && si.hosts.length) gZm.appendChild(siBox('Домены в hosts', [ E('div', { 'class': 'zm-chips' }, si.hosts.map(function(h) { return E('span', { 'class': 'zm-chip' }, [ h ]); })) ])); - if (gSys.childNodes.length) { siBody.appendChild(E('div', { 'class': 'zm-si-group' }, 'Роутер и сеть')); siBody.appendChild(gSys); } - if (gZm.childNodes.length) { siBody.appendChild(E('div', { 'class': 'zm-si-group' }, 'Zapret Manager и компоненты')); siBody.appendChild(gZm); } + if (si.hosts && si.hosts.length) bZm.push(siBox('Домены в hosts', [ E('div', { 'class': 'zm-chips' }, si.hosts.map(function(h) { return E('span', { 'class': 'zm-chip' }, [ h ]); })) ])); + if (bSys.length) { siBody.appendChild(E('div', { 'class': 'zm-si-group' }, 'Роутер и сеть')); siBody.appendChild(siMasonry(bSys)); } + if (bZm.length) { siBody.appendChild(E('div', { 'class': 'zm-si-group' }, 'Zapret Manager и компоненты')); siBody.appendChild(siMasonry(bZm)); } if (si.strategy) siBody.appendChild(siBox('Стратегия Zapret', [ E('pre', { 'class': 'zm-si-pre' }, [ si.strategy ]) ])); if (si.sites && si.sites.length) { var ok = si.sites_ok, all = si.sites_total, cls = ok === all ? 'zm-ok' : ok * 2 >= all ? 'zm-warn' : 'zm-bad'; - siBody.appendChild(siBox('Доступность сайтов', [ E('div', { 'class': 'zm-chips' }, si.sites.map(function(x) { + siBody.appendChild(siBox('Доступность сайтов', [ E('div', { 'class': 'zm-chips' }, siSitesSorted().map(function(x) { return E('span', { 'class': 'zm-chip ' + (x.ok ? 'zm-chip-ok' : 'zm-chip-bad') }, [ E('span', { 'class': 'zm-dot' }), x.name ]); })) ], E('span', { 'class': 'zm-badge ' + cls }, [ E('span', { 'class': 'zm-dot' }), [ ok + ' из ' + all ] ]))); } @@ -37148,7 +37352,7 @@ return view.extend({ { g: 'zm', product: 'Zapret Manager и ByeTube', author: 'StressOzz', url: 'https://github.com/StressOzz' }, { g: 'block', product: 'zapret, zapret2', author: 'bol-van', url: 'https://github.com/bol-van' }, { g: 'block', product: 'zapret-openwrt', author: 'remittor', url: 'https://github.com/remittor/zapret-openwrt' }, - { g: 'block', product: 'Zapret2', author: 'routerich', url: 'https://github.com/routerich' }, + { g: 'block', product: 'Zapret2, сервер удалённого доступа', author: 'routerich', url: 'https://github.com/routerich' }, { g: 'block', product: 'стратегии Flowseal', author: 'Flowseal', url: 'https://github.com/Flowseal/zapret-discord-youtube' }, { g: 'block', product: 'ByeDPI', author: 'hufrea', url: 'https://github.com/hufrea/byedpi' }, { g: 'block', product: 'ByeDPI-OpenWrt', author: 'DPITrickster', url: 'https://github.com/DPITrickster/ByeDPI-OpenWrt' }, @@ -37165,12 +37369,16 @@ return view.extend({ { g: 'vpn', product: 'MagiTrickle', author: 'MagiTrickle', url: 'https://github.com/MagiTrickle/MagiTrickle' }, { g: 'vpn', product: 'AmneziaWG', author: 'amnezia-vpn', url: 'https://github.com/amnezia-vpn' }, { g: 'vpn', product: 'Tailscale', author: 'Tailscale', url: 'https://github.com/tailscale/tailscale' }, + { g: 'vpn', product: 'openwrt-tailscale (сборка для OpenWrt)', author: 'GuNanOvO', url: 'https://github.com/GuNanOvO/openwrt-tailscale' }, + { g: 'vpn', product: 'luci-app-tailscale-community', author: 'Tokisaki-Galaxy', url: 'https://github.com/Tokisaki-Galaxy/luci-app-tailscale-community' }, + { g: 'vpn', product: 'NaïveProxy (для Forkozz)', author: 'klzgrad', url: 'https://github.com/klzgrad/naiveproxy' }, { g: 'vpn', product: 'TailSocks (клиент для Android)', author: 'bropines', url: 'https://github.com/bropines/tailsocks' }, { g: 'vpn', product: 'awg-openwrt (пакеты AmneziaWG)', author: 'Slava-Shchipunov', url: 'https://github.com/Slava-Shchipunov/awg-openwrt' }, { g: 'vpn', product: 'awg-openwrt (сборки)', author: '2Grey', url: 'https://github.com/2Grey/awg-openwrt' }, { g: 'vpn', product: 'warpscout (разведка WARP)', author: 'vernette', url: 'https://github.com/vernette/warpscout' }, { g: 'vpn', product: 'base-relay (реле регистрации WARP)', author: 'nellimonix', url: 'https://github.com/nellimonix/base-relay' }, { g: 'vpn', product: 'генератор WARP для Mixomo', author: 'santa-atmo.ru', url: 'https://santa-atmo.ru' }, + { g: 'tg', product: 'tg-ws-proxy (список доменов Cloudflare)', author: 'Flowseal', url: 'https://github.com/Flowseal/tg-ws-proxy' }, { g: 'tg', product: 'tg-ws-proxy-go (MTProto)', author: 'spatiumstas', url: 'https://github.com/spatiumstas/tg-ws-proxy-go' }, { g: 'tg', product: 'tg-ws-proxy-go (SOCKS5)', author: 'd0mhate', url: 'https://github.com/d0mhate/-tg-ws-proxy-Manager-go' }, { g: 'tg', product: 'tg-ws-proxy-rs (Rust)', author: 'valnesfjord', url: 'https://github.com/valnesfjord/tg-ws-proxy-rs' }, @@ -43506,7 +43714,7 @@ var ROUTES = [ { id: 'adblock', title: 'Блокировка рекламы', sub: 'Реклама, трекеры и фишинг — для всех устройств в сети', icon: 'adblock', group: 'Сеть', dot: 'adblock' }, { id: 'doh', title: 'DNS over HTTPS', sub: 'Шифрованный DNS для всей сети', icon: 'globe', group: 'Сеть', dot: 'doh' }, { id: 'awg', title: 'AmneziaWG', sub: 'Туннели AmneziaWG и WARP: установка, ключи, интерфейсы', icon: 'anarchy', group: 'Сеть', dot: 'awg' }, - { id: 'tailscale', title: 'Tailscale', sub: 'Телефон в любой сети — через домашний роутер', icon: 'phone', group: 'Сеть', dot: 'ts' }, + { id: 'tailscale', title: 'Удалённый доступ', sub: 'Tailscale: телефон в любой сети — через домашний роутер', icon: 'phone', group: 'Сеть', dot: 'ts' }, { id: 'terminal', title: 'Терминал', sub: 'Командная строка роутера в браузере (ttyd)', icon: 'terminal', group: 'Сеть', dot: 'term' }, { id: 'system', title: 'Система', sub: 'Параметры роутера, зеркала и обслуживание', icon: 'cpu', group: 'Сервис' }, { id: 'monitor', title: 'Монитор ресурсов', sub: 'Процессор, память, температура и процессы', icon: 'activity', group: 'Сервис' } @@ -43521,10 +43729,7 @@ function currentRoute() { var root, shell = null, viewEl, titleEl, subEl, navLinks = {}, navDots = {}, deviceEl, verEl, updateEl, memEl; -/* Скрытые пункты меню: список на роутере (health.nav_hidden), копия в браузере — чтобы меню - не мигало при загрузке. Дашборд скрыть нельзя. Hosts и блокировка рекламы — встроенные - функции, «неустановленными» не считаются. */ -var NAV_LOCK = { dashboard: 1 }, NAV_BUILTIN = { dashboard: 1, hosts: 1, adblock: 1, system: 1, monitor: 1 }; +var NAV_LOCK = { dashboard: 1 }, NAV_BUILTIN = { dashboard: 1, system: 1, monitor: 1 }; var navHidden = navParse(sget(K_NAV)), navEdit = null, navSaving = false, navGroups = [], navGear = null, navNav = null, navHealth = null; function navParse(v) { var o = {}; String(v || '').split(/[\s,]+/).forEach(function (k) { if (/^[a-z0-9_]{1,32}$/.test(k) && !NAV_LOCK[k]) o[k] = 1; }); return o; } function navList(o) { return ROUTES.map(function (r) { return r.id; }).filter(function (k) { return o[k]; }); } @@ -43569,7 +43774,7 @@ function navUninstalled() { if (st === 0 && !navEdit[r.id]) { navEdit[r.id] = 1; n++; } }); navApply(); - toast(n ? 'Отмечено неустановленных: ' + n + ' — нажмите «Готово»' : (navHealth ? 'Неустановленных разделов нет' : 'Состояние компонентов ещё загружается — попробуйте через пару секунд'), 'info'); + toast(n ? 'Отмечено неустановленных и невключённых: ' + n + ' — нажмите «Готово»' : (navHealth ? 'Неустановленных разделов нет' : 'Состояние компонентов ещё загружается — попробуйте через пару секунд'), 'info'); } function navAll() { if (navEdit) { navEdit = {}; navApply(); } } function navDone() { @@ -43696,8 +43901,9 @@ function buildShell() { nav.appendChild(a); }); nav.appendChild(E('div', { 'class': 'zmw-nav-editfoot' }, [ - E('button', { 'class': 'zmw-nav-btn', 'type': 'button', 'click': function () { navUninstalled(); } }, [ 'Скрыть неустановленные' ]), - E('button', { 'class': 'zmw-nav-btn', 'type': 'button', 'click': function () { navAll(); } }, [ 'Показать все' ]) + E('button', { 'class': 'zmw-nav-btn zmw-nav-btn-wide', 'type': 'button', 'click': function () { navUninstalled(); } }, [ 'Скрыть неустановленные' ]), + E('button', { 'class': 'zmw-nav-btn', 'type': 'button', 'click': function () { navAll(); } }, [ 'Показать все' ]), + E('button', { 'class': 'zmw-nav-btn zmw-nav-btn-done', 'type': 'button', 'click': function () { navDone(); } }, [ 'Готово' ]) ])); navApply(); @@ -44516,7 +44722,11 @@ html[data-theme="dark"] .zmw-brand-name b, html[data-theme="depth"] .zmw-brand-n .zmw-nav-gear.zmw-has > i { display: block; } .zmw-nav-gear.zmw-on > i { display: none; } .zmw-nav-editfoot, .zmw-nav-eye, .zmw-nav-lock { display: none; } -.zmw-nav-editing .zmw-nav-editfoot { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 4px 4px; } +.zmw-nav-editing .zmw-nav-editfoot { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; padding: 10px 4px 4px; } +.zmw-nav-editfoot .zmw-nav-btn { min-width: 0; padding: 8px 10px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.zmw-nav-editfoot .zmw-nav-btn-wide { grid-column: 1 / -1; } +.zmw-nav-editfoot .zmw-nav-btn-done { color: #fff; background: var(--grad); border-color: transparent; } +.zmw-nav-editfoot .zmw-nav-btn-done:hover { color: #fff; border-color: transparent; filter: brightness(1.08); } .zmw-nav-btn { border: 1px solid var(--border); background: var(--surface-2); color: var(--text-2); border-radius: 9px; padding: 6px 10px; font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; } .zmw-nav-btn:hover { color: var(--text); border-color: var(--a1); } .zmw-nav-editing .zmw-nav-eye { @@ -45984,7 +46194,7 @@ html[data-theme="oled"] .zmw-nav-item { border-radius: 999px; } html[data-theme="oled"] .zmw-nav-item.zmw-active { background: #ffffff; color: #000000; } html[data-theme="oled"] .zmw-nav-item.zmw-active::before { display: none; } html[data-theme="oled"] .zmw-nav-item.zmw-active .zmw-nav-ico { background: #000000; color: #ffffff; border-color: #000000; } -html[data-theme="oled"] .zmw-nav-gear.zmw-on { background: #ffffff; color: #000000; border-color: #ffffff; } +html[data-theme="oled"] .zmw-nav-gear.zmw-on, html[data-theme="oled"] .zmw-nav-editfoot .zmw-nav-btn-done { background: #ffffff; color: #000000; border-color: #ffffff; } html[data-theme="oled"] .zmw-nav-editing .zmw-nav-eye { color: #ffffff; background: rgba(255,255,255,.12); } html[data-theme="oled"] .zmw-nav-gear > i { background: #ffffff; } html[data-theme="oled"] #zmw-view .zm-card { border-radius: 20px; }