From ecc867ee69ab06e47d44abb8e00bf8ff6236edf0 Mon Sep 17 00:00:00 2001 From: StressOzz <131419324+StressOzz@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:28:51 +0500 Subject: [PATCH] v2.91 https://github.com/StressOzz/Zapret-Manager/issues/1062#issuecomment-6108533482 --- ZapretManager_LuCI.sh | 2075 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 2039 insertions(+), 36 deletions(-) diff --git a/ZapretManager_LuCI.sh b/ZapretManager_LuCI.sh index 3ccabf7..98f6934 100644 --- a/ZapretManager_LuCI.sh +++ b/ZapretManager_LuCI.sh @@ -1,6 +1,6 @@ #!/bin/sh read -r _ _ ZM_NEW_VER <<'ZM_VERSION_EOF' -# Version: 2.75 +# Version: 2.91 ZM_VERSION_EOF set -e @@ -122,6 +122,12 @@ if [ "$1" = zm_watch ]; then printf '%s\n' "$v" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' && [ "${v%%.*}" -ge 2 ] 2>/dev/null || return 1 fi fi + # Блокировка рекламы: список включён, а у dnsmasq его нет (после перезагрузки папки dnsmasq пустые) + if grep -qx 'on=1' "$s/adblock/settings" 2>/dev/null; then + for t in $(sed -n 's/^conf-dir=\([^,]*\).*/\1/p' /var/etc/dnsmasq.conf.* 2>/dev/null | sort -u); do + [ -s "$t/zm-adblock.conf" ] || return 1 + done + fi # делать нечего — убираем то же, что убрали бы сами проверки на этом пути rm -f /tmp/zm-rpcd-slow /tmp/zm-rpcd-plugin.stamp "$s/fk.watch" "$d/steer.vpnwatch" return 0 @@ -906,6 +912,7 @@ zm_watch() { _st_legacy_check _st_vpn_watch _tg_watch + _ab_watch return 0 } @@ -1061,6 +1068,23 @@ ui_theme_set() { printf '{"ok":true}\n' } +# Отметки интерфейса («больше не показывать»): по одной в строке +ui_flags_get() { + local f="$ZM_STATE_DIR/ui.flags" out="" sep="" k + if [ -s "$f" ]; then + for k in $(grep -E '^[a-z0-9_]{1,48}$' "$f" | sort -u); do out="$out$sep\"$k\":1"; sep=","; done + fi + printf '{"flags":{%s}}\n' "$out" +} + +ui_flag_set() { + local f="$ZM_STATE_DIR/ui.flags" + printf '%s' "$1" | grep -qE '^[a-z0-9_]{1,48}$' || { echo '{"error":"неверное имя"}'; return 1; } + mkdir -p "$ZM_STATE_DIR" + grep -qx "$1" "$f" 2>/dev/null || echo "$1" >> "$f" + printf '{"ok":true}\n' +} + job_status() { local name="$1" local pid="$JOBS_DIR/$name.pid" @@ -3039,6 +3063,10 @@ if [ -s /tmp/zapret-manager-luci/resolv.conf.zm-bak ]; then if [ -e /tmp/resolv.conf ]; then cat /tmp/zapret-manager-luci/resolv.conf.zm-bak > /tmp/resolv.conf 2>/dev/null || true else cat /tmp/zapret-manager-luci/resolv.conf.zm-bak > /etc/resolv.conf 2>/dev/null || true; fi fi +for zm_d in $(sed -n 's/^conf-dir=\([^,]*\).*/\1/p' /var/etc/dnsmasq.conf.* 2>/dev/null | sort -u) /tmp/dnsmasq.d; do + if [ -f "$zm_d/zm-adblock.conf" ]; then rm -f "$zm_d/zm-adblock.conf"; zm_dns=1; fi +done +[ -n "$zm_dns" ] && /etc/init.d/dnsmasq restart >/dev/null 2>&1 ZM_OPT="/opt/zapret-manager-luci" ZM_KEEP="$(cat /etc/steer/*.json /etc/steer/*.yaml /etc/steer/*.yml 2>/dev/null | sed 's|\\/|/|g' | tr '",[]{} \t\r' '\n\n\n\n\n\n\n\n\n' | sed "s/^'//; s/'\$//" | grep "^$ZM_OPT/lists/" | sort -u)" if [ -n "$ZM_KEEP" ]; then @@ -5146,7 +5174,7 @@ _zm_job_label() { mixomo*) echo "Mixomo" ;; bytetube*) echo "ByeTube" ;; install_zapret2|remove_zapret2) echo "Zapret2" ;; install_zapret|remove_zapret) echo "Zapret" ;; strategy_test) echo "тест стратегий" ;; tg*) echo "TG WS Proxy" ;; doh*) echo "DNS over HTTPS" ;; - mirror_set) echo "смена зеркала" ;; zm_update) echo "обновление панели" ;; *) echo "$1" ;; + adblock) echo "блокировка рекламы" ;; mirror_set) echo "смена зеркала" ;; zm_update) echo "обновление панели" ;; *) echo "$1" ;; esac } @@ -6316,8 +6344,10 @@ health() { if _term_installed; then if _term_running; then d_term=1; elif _term_enabled; then d_term=2; else d_term=4; fi fi - printf '{"dots":{"zapret":%s,"zapret2":%s,"bytetube":%s,"tg":%s,"mixomo":%s,"doh":%s,"hosts":%s,"steer":%s,"forkop":%s,"awg":%s,"term":%s},' \ - "$d_zr" "$d_zr2" "$d_bt" "$d_tg" "$d_mx" "$d_doh" "$hs" "$d_sr" "${d_fk:-0}" "${d_awg:-0}" "$d_term" + local d_ab=0 + if _ab_on; then if _ab_applied; then d_ab=1; else d_ab=2; fi; fi + printf '{"dots":{"zapret":%s,"zapret2":%s,"bytetube":%s,"tg":%s,"mixomo":%s,"doh":%s,"hosts":%s,"adblock":%s,"steer":%s,"forkop":%s,"awg":%s,"term":%s},' \ + "$d_zr" "$d_zr2" "$d_bt" "$d_tg" "$d_mx" "$d_doh" "$hs" "$d_ab" "$d_sr" "${d_fk:-0}" "${d_awg:-0}" "$d_term" printf '"zapret":%s,"zapret2":%s,"bytetube":%s,"tg":%s,"mixomo":%s,"doh":%s,"hosts":%s,"steer":%s,"steer_off":%s,"steer_exit":"%s","awg":%s,"forkop":%s,"flow_warn":%s,"ipv6_warn":%s,"reboot_hint":"%s"}\n' \ "$zr" "$zr2" "$bt" "$tg" "$mx" "$doh" "$hs" "$sr" \ "$([ -f /etc/zm-steer/stopped ] && echo true || echo false)" "$sx" "$awgj" "$fkh" "$fw" "$v6" "$(esc "$(_zm_reboot_hint_get)")" @@ -8627,7 +8657,18 @@ stl_engine_install() { _stl_say "Ставим ядро steer $ver: $(echo $pkgs | sed 's/ /, /g')" $UPDATE >&2 [ -s "$STL_SPEC" ] && cp -f "$STL_SPEC" "$keep" - if [ "$PKG" = apk ]; then + if [ -n "$STL_REINSTALL" ] && [ -z "$legacy" ]; then + # переустановка той же версии: пакеты уже скачаны, ставим их поверх стоящих + "$STL_INIT" stop >/dev/null 2>&1 + if [ "$PKG" = apk ]; then + n=""; for p in $pkgs; do _pkg_is_installed "$p" && n="$n $p"; done + [ -n "$n" ] && $DELETE $n >&2 + $INSTALL $files >&2; rc=$? + [ "$rc" = 0 ] || { $INSTALL $files >&2; rc=$?; } + else + $INSTALL --force-reinstall $files >&2; rc=$? + fi + elif [ "$PKG" = apk ]; then for n in $legacy; do if [ "$n" = steer ]; then apk add steer >/dev/null 2>&1; else old="$old !$n"; fi; done $INSTALL $files $old >&2; rc=$? [ "$rc" = 0 ] && for n in $legacy; do apk del "$n" >/dev/null 2>&1; done @@ -11093,8 +11134,53 @@ _st_engine_ready() { _st_failopen() { stl_state "$(_st_exit)" && [ "$STL_FAILED" = true ]; } +# Самопочинка по итогам самодиагностики ядра: сначала перезапуск (клиенты протоколов поднимаются не сразу — +# ждём до 30 секунд), не помогло — переустановка той же версии ядра с нужными модулями (правила остаются). +# Переустановка — не чаще раза в 30 минут, чтобы при поломке, которую так не исправить, не качать ядро по кругу. +ST_HEAL_STAMP="$ST_DIR/heal.reinstall" +_st_heal_wait() { + local f="$1" i=0 + while [ "$i" -lt 10 ]; do + sleep 3; i=$((i + 1)) + stl_running || continue + stl_diag_lines > "$f" + [ -s "$f" ] && ! grep -q '^fail' "$f" && return 0 + done + return 1 +} +_st_heal() { + local f="$1" ver + ST_HEAL_DID="перезапуск ядра не помог" + _rb_say "Чиним сами: перезапускаем ядро steer" + if stl_restart && _st_heal_wait "$f"; then + echo " ✓ После перезапуска ядра всё работает" + return 0 + fi + if [ -f "$ST_HEAL_STAMP" ] && [ -z "$(find "$ST_HEAL_STAMP" -mmin +30 2>/dev/null)" ]; then + echo " ! Перезапуск не помог, а ядро уже переустанавливали меньше получаса назад — второй раз не трогаем" + stl_diag_lines > "$f" + return 1 + fi + mkdir -p "$ST_DIR"; touch "$ST_HEAL_STAMP" + ST_HEAL_DID="не помогли ни перезапуск, ни переустановка ядра" + ver="$(stl_version)"; _stl_ver_ok "$ver" || ver="$(stl_latest)" + _rb_say "Перезапуск не помог — переустанавливаем ядро steer $ver" + if ! STL_REINSTALL=1 stl_engine_install "$ver" $(_st_use_vpn && _st_sub_feats); then + echo " ✗ Переустановить ядро не удалось — проверьте интернет и нажмите «Применить» ещё раз" + stl_running || stl_restart >/dev/null 2>&1 + stl_diag_lines > "$f" + return 1 + fi + stl_running || stl_restart >/dev/null 2>&1 + if _st_heal_wait "$f"; then + echo " ✓ После переустановки ядра всё работает" + return 0 + fi + return 1 +} + _st_selfcheck() { - local f="$ST_RUN/diag.json" n i v what why bad=0 trace + local f="$ST_RUN/diag.json" n i v what why bad=0 trace healed="" did="" _rb_say "Проверяем, что всё работает" if _st_work && _st_use_vpn; then _st_vpn_check || bad=1 @@ -11132,14 +11218,29 @@ _st_selfcheck() { _st_work && echo "[ OK ] Ядро steer ведёт выбранные сервисы в туннель" fi stl_diag_lines > "$f" + # поломку ядра чиним сами — сразу, без «перезапустите вручную»; один раз за проверку + if grep -q '^fail' "$f" && [ -z "$ST_HEALING" ] && _st_work && [ ! -f "$ST_OFF" ]; then + while IFS="$(printf '\t')" read -r v what why; do + [ "$v" = fail ] && echo "!! $what" + done < "$f" + ST_HEALING=1 + if _st_heal "$f"; then healed=1; fi + ST_HEALING="" + did="$ST_HEAL_DID" + _rb_say "Проверяем ещё раз" + fi while IFS="$(printf '\t')" read -r v what why; do case "$v" in ok) echo "[ OK ] $what" ;; warn) echo "!! $what${why:+ — $why}" ;; - fail) echo "[FAIL] $what${why:+ — $why}"; bad=1 ;; + fail) + # совет «перезапустите ядро» после нашей починки уже не к месту — пишем, что пробовали + case "$why" in *'init.d/steer'*|*ерезапустите*|*ереустановите*) [ -n "$did" ] && why="$did; пришлите журнал в чат поддержки" ;; esac + echo "[FAIL] $what${why:+ — $why}"; bad=1 ;; esac done < "$f" rm -f "$f" + [ -n "$healed" ] && [ "$bad" = 0 ] && echo " · Поломку ядра панель исправила сама" if [ "$bad" = 0 ]; then _rb_say "Проверка пройдена"; else _rb_warn "Проверка нашла поломку — подробности выше"; fi return $bad } @@ -11149,6 +11250,8 @@ do_steer_install() { local blk _st_phase install rm -f "$ST_STOP_FLAG" + # ручная установка — с чистого листа: автопочинке снова можно переустанавливать ядро + rm -f "$ST_HEAL_STAMP" blk="$(_st_blocker)" _fk_present && { echo "ОШИБКА: стоит Forkozz — он сам направляет сервисы в туннель. Удалите Forkozz, чтобы поставить Steer"; return 1; } case "$blk" in @@ -11705,6 +11808,7 @@ do_steer_warp_recreate() { do_steer_remove() { _st_phase remove + rm -f "$ST_HEAL_STAMP" _rb_say "Удаляем Steer и туннель WARP" local foreign="" p pkgs="" rc=0 keepf="$JOBS_DIR/steer.keep.$$" keptif="" kept=0 alien="" ownpk="" case "$(_st_blocker)" in @@ -11853,15 +11957,31 @@ steer_status() { _st_cat_bg [ -n "$(_st_wfix_min)" ] && _st_wfix_cron sel=" $(_st_sel | tr '\n' ' ') " - svc="$(_rb_svc_src | awk -F'|' -v sel="$sel" -v skipf="$ST_SKIP" ' + local cust=0 + [ -s "$ST_USER_DIR/custom.lst" ] && cust=1 + # dom — есть ли в списке пункта домены (значок «≡» на карточке): известное число из $ST_DOMCNT, иначе по + # источникам — у наборов каталога с одними подсетями («s» в индексе) доменов нет + svc="$(_rb_svc_src | awk -F'|' -v sel="$sel" -v skipf="$ST_SKIP" -v dcf="$ST_DOMCNT" -v idxf="$ST_CAT_IDX" -v cust="$cust" ' function j(v, o, i, c) { o = ""; for (i = 1; i <= length(v); i++) { c = substr(v, i, 1); if (c == "\\") o = o "\\\\"; else if (c == "\"") o = o "\\\""; else if (c == "\t" || c == "\r" || c == "\n") o = o " "; else o = o c } return o } - BEGIN { while ((getline l < skipf) > 0) sk[l] = 1 } + function hasdom( m, i, s) { + if ($1 == "custom") return cust + 0 + if ($1 in dc) return dc[$1] > 0 + if ($3 != "" || $5 != "" || $6 != "") return 1 + m = split($8, s, ",") + for (i = 1; i <= m; i++) if (s[i] != "" && (!(s[i] in ik) || ik[s[i]] != "s")) return 1 + return 0 + } + BEGIN { + while ((getline l < skipf) > 0) sk[l] = 1 + while ((getline l < dcf) > 0) { split(l, a, " "); if (a[1] != "") dc[a[1]] = a[2] + 0 } + while ((getline l < idxf) > 0) { split(l, a, "|"); if (a[1] != "") ik[a[1]] = a[4] } + } $1 == "" || ($1 in seen) { next } { seen[$1] = 1 } $1 != "custom" && $3 $4 $8 == "" { next } { - printf "%s{\"id\":\"%s\",\"name\":\"%s\",\"group\":\"%s\",\"on\":%s,\"skip\":%s}", (n++ ? "," : ""), $1, j($2), j($9), - (index(sel, " " $1 " ") ? "true" : "false"), (($1 in sk) ? "true" : "false") + printf "%s{\"id\":\"%s\",\"name\":\"%s\",\"group\":\"%s\",\"on\":%s,\"skip\":%s,\"dom\":%s}", (n++ ? "," : ""), $1, j($2), j($9), + (index(sel, " " $1 " ") ? "true" : "false"), (($1 in sk) ? "true" : "false"), (hasdom() ? "true" : "false") }')" local vexit vup=false vsub=false latest="" ext=false won=false failed=false active="" vexit="$(_st_exit)" @@ -11942,6 +12062,244 @@ _st_list_effective() { # Списки, которые правит человек: «Свой список» (custom — через Steer) и «Исключения» (exclude — всегда напрямую). _st_user_list() { [ "$1" = exclude ] || { [ "$1" = custom ] && _rb_routable "$1"; }; } +# Домены сервиса для просмотра (значок «≡» у карточки на вкладке «Сервисы»). Источники — те же, что берёт +# применение (_st_svc_lists): наборы каталога, а если их нет или один не скачался — списки пакета, а без них — +# основные домены сервиса; у «Своего списка» — ваши строки. Показываем только домены: подсети и адреса +# отбрасываются. Наборы .srs раскладывает в текст ядро (steer srs-read) — тот же кэш, что у «Куда пойдёт +# запрос». Пока идёт применение, ничего не качаем — берём то, что уже на роутере. Число доменов запоминаем +# в $ST_DOMCNT: карточка, у которой в списке одни подсети, значка не показывает. +ST_DOMCNT="$ST_DIR/domcount" +_st_dom_norm() { + awk '{ + l = tolower($0); sub(/[#;].*$/, "", l); gsub(/^[ \t\r]+|[ \t\r]+$/, "", l); sub(/[ \t].*$/, "", l) + sub(/^(domain|full|suffix|domain_suffix):/, "", l); sub(/^=/, "", l); sub(/^\*\./, "", l); sub(/^\./, "", l) + if (l ~ /^[a-z0-9]([a-z0-9_-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9_-]*[a-z0-9])?)+$/ && l !~ /^[0-9.]+$/) print l + }' +} +# Набор .srs ($1) в текст: домены в $2, подсети в $3. Раскладывает ядро steer, а без него (стоит только +# Forkozz) — sing-box: rule-set decompile отдаёт JSON, из него берём domain/domain_suffix и ip_cidr. +_st_srs_dump() { + local j="$2.json.$$" + if command -v steer >/dev/null 2>&1; then + steer srs-read "$1" --out "$2" --prefixes-out "$3" >/dev/null 2>&1 && return 0 + fi + command -v sing-box >/dev/null 2>&1 && command -v jsonfilter >/dev/null 2>&1 || return 1 + sing-box rule-set decompile -o "$j" "$1" >/dev/null 2>&1 && [ -s "$j" ] || { rm -f "$j"; return 1; } + jsonfilter -i "$j" -e '@.rules[*].domain[*]' -e '@.rules[*].domain_suffix[*]' -e '@.rules[*].rules[*].domain[*]' -e '@.rules[*].rules[*].domain_suffix[*]' 2>/dev/null > "$2" + jsonfilter -i "$j" -e '@.rules[*].ip_cidr[*]' -e '@.rules[*].rules[*].ip_cidr[*]' 2>/dev/null > "$3" + rm -f "$j" + return 0 +} + +# $1 — сервис, $2 — итоговый файл. В $ST_DOM_SRC — откуда взяты домены (для подписи). +_st_dom_build() { + local id="$1" out="$2" src="$2.src.$$" pfx="$2.pfx.$$" dl=1 sets set f fmt x ok=1 + ST_DOM_SRC=""; ST_DOM_MISS="" + { _st_running || [ -n "$ST_DOM_NODL" ]; } && dl=0 + mkdir -p "${out%/*}" "$ST_DIR/lists" "$ST_RUN/srsdump" + : > "$src"; : > "$pfx" + if [ "$id" = custom ]; then + [ -s "$ST_USER_DIR/custom.lst" ] && echo "$ST_USER_DIR/custom.lst" >> "$src" + [ -s "$ST_USER_DIR/custom.pfx" ] && echo "$ST_USER_DIR/custom.pfx" >> "$pfx" + ST_DOM_SRC="ваши строки" + else + sets="$(_rb_svc_field "$id" 8 | tr ',' ' ')" + for set in $sets; do + fmt="$(awk -F'|' -v k="$set" '$1 == k { print $2; exit }' "$ST_CAT_IDX" 2>/dev/null)" + if [ "$fmt" = lst ]; then + f="$ST_DIR/lists/$set.dom" + [ -s "$f" ] || [ -s "$ST_DIR/lists/$set.pfx" ] || { [ "$dl" = 1 ] && _st_srs_get "$set" >/dev/null 2>&1; } + [ -s "$ST_DIR/lists/$set.pfx" ] && echo "$ST_DIR/lists/$set.pfx" >> "$pfx" + if [ -s "$f" ]; then echo "$f" >> "$src" + elif [ ! -s "$ST_DIR/lists/$set.pfx" ]; then ST_DOM_MISS="$ST_DOM_MISS $set"; ok=0; break; fi + else + f="$ST_DIR/lists/$set.srs" + [ -s "$f" ] || { [ "$dl" = 1 ] && _st_srs_get "$set" >/dev/null 2>&1; } + [ -s "$f" ] || ST_DOM_MISS="$ST_DOM_MISS $set" + [ -s "$f" ] || { ok=0; break; } + x="$ST_RUN/srsdump/$(printf '%s' "$f" | tr -c 'A-Za-z0-9_.-' '_')" + if { [ ! -s "$x.dom" ] && [ ! -s "$x.pfx" ]; } || [ "$f" -nt "$x.dom" ]; then + rm -f "$x.dom" "$x.pfx" + _st_srs_dump "$f" "$x.dom" "$x.pfx" || rm -f "$x.dom" "$x.pfx" + fi + [ -f "$x.dom" ] || { ok=0; break; } + [ -s "$x.dom" ] && echo "$x.dom" >> "$src" + [ -s "$x.pfx" ] && echo "$x.pfx" >> "$pfx" + fi + done + if [ -n "$sets" ] && [ "$ok" = 1 ]; then ST_DOM_SRC="каталог списков" + else + : > "$src"; : > "$pfx" + for f in $(_rb_svc_field "$id" 4 | tr ',' ' '); do + if [ "$dl" = 1 ]; then _rb_list_get "$f" >/dev/null 2>&1; fi + [ -s "$ZM_LISTS_DIR/$f" ] && echo "$ZM_LISTS_DIR/$f" >> "$pfx" + done + for f in $(_rb_svc_field "$id" 3 | tr ',' ' '); do + if [ "$dl" = 1 ]; then _rb_list_get "$f" >/dev/null 2>&1; fi + [ -s "$ZM_LISTS_DIR/$f" ] && echo "$ZM_LISTS_DIR/$f" >> "$src" + done + if [ -s "$src" ]; then ST_DOM_SRC="списки сервиса" + else + printf '%s,%s\n' "$(_rb_svc_field "$id" 5)" "$(_rb_svc_field "$id" 6)" | tr ',' '\n' > "$out.base.$$" + [ -s "$out.base.$$" ] && echo "$out.base.$$" >> "$src" + ST_DOM_SRC="основные домены сервиса" + fi + fi + fi + while IFS= read -r f; do cat "$f"; echo; done < "$src" | _st_dom_norm | sort -u > "$out.$$" + mv -f "$out.$$" "$out" + mv -f "$pfx" "$out.pfxsrc" + rm -f "$src" "$out.base.$$" + printf '%s\n' "$ST_DOM_MISS" > "$out.miss" + printf '%s\n' "$ST_DOM_SRC" > "$out.how" + # подсети и адреса IP списка (IPv4 и IPv6, с маской или без, диапазоны): $out.ips — сами строки (для + # «Скачать .txt»), $out.ipn — их число (для подписи панели) + if [ -s "$out.pfxsrc" ]; then + awk '{ l = tolower($0); sub(/[#;].*$/, "", l); gsub(/[ \t\r]/, "", l) + if (l ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+((\/[0-9]+)|(-[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+))?$/ || (l ~ /^[0-9a-f:]+(\/[0-9]+)?$/ && index(l, ":"))) if (!s[l]++) print l }' \ + $(cat "$out.pfxsrc") 2>/dev/null > "$out.ips" + else + : > "$out.ips" + fi + wc -l < "$out.ips" | tr -d ' ' > "$out.ipn" +} + +# Есть ли в списке пункта домены — для значка «≡» на карточках Forkozz (у Steer это поле dom в его статусе): +# известное число из $ST_DOMCNT, иначе по источникам; у наборов каталога с одними подсетями доменов нет. +steer_svc_domflags() { + _rb_svc_src | awk -F'|' -v dcf="$ST_DOMCNT" -v idxf="$ST_CAT_IDX" ' + BEGIN { + while ((getline l < dcf) > 0) { split(l, a, " "); if (a[1] != "") dc[a[1]] = a[2] + 0 } + while ((getline l < idxf) > 0) { split(l, a, "|"); if (a[1] != "") ik[a[1]] = a[4] } + printf "{" + } + $1 == "" || $1 == "custom" || ($1 in seen) || $3 $4 $8 == "" || $1 !~ /^[A-Za-z0-9_.-]+$/ { next } + { + seen[$1] = 1; d = 0 + if ($1 in dc) d = dc[$1] > 0 + else if ($3 != "" || $5 != "" || $6 != "") d = 1 + else { m = split($8, s, ","); for (i = 1; i <= m; i++) if (s[i] != "" && (!(s[i] in ik) || ik[s[i]] != "s")) d = 1 } + printf "%s\"%s\":%s", (n++ ? "," : ""), $1, (d ? "true" : "false") + } + END { printf "}\n" }' +} + +steer_svc_dom() { + local id="${1%%:*}" r o l f n ipn k="" src + r="${1#*:}"; o="${r%%:*}"; l="${r#*:}" + # четвёртое поле «ip» — отдать не домены, а IP-подсети списка (для «Скачать .txt») + case "$l" in *:*) k="${l#*:}"; l="${l%%:*}" ;; esac + case "$id" in ''|*[!A-Za-z0-9_.-]*) echo '{"error":"нет такого сервиса"}'; return 1 ;; esac + case "$o" in ''|*[!0-9]*) o=0 ;; esac + case "$l" in ''|*[!0-9]*) l=500 ;; esac + [ "$l" -gt 5000 ] && l=5000 + [ "$l" -lt 1 ] && l=1 + _rb_routable "$id" || { echo '{"error":"у этого пункта нет списка"}'; return 1; } + f="$ST_RUN/dom/$id.txt" + # файл моложе минуты берём как есть: «Скачать .txt» читает его частями + if [ ! -f "$f" ] || [ ! -f "$f.how" ] || [ ! -f "$f.ipn" ] || [ ! -f "$f.ips" ] || [ -n "$(find "$f" -mmin +1 2>/dev/null)" ] || { [ "$o" = 0 ] && [ "$id" = custom ] && [ "$k" != ip ]; }; then + _st_dom_build "$id" "$f" + fi + n="$(wc -l < "$f" | tr -d ' ')" + if [ "$id" != custom ]; then + mkdir -p "$ST_DIR" + { grep -v "^$id " "$ST_DOMCNT" 2>/dev/null; echo "$id $n"; } > "$ST_DOMCNT.$$" && mv -f "$ST_DOMCNT.$$" "$ST_DOMCNT" + fi + ipn="$(cat "$f.ipn" 2>/dev/null)"; case "$ipn" in ''|*[!0-9]*) ipn=0 ;; esac + src="$f"; [ "$k" = ip ] && src="$f.ips" + printf '{"id":"%s","name":"%s","kind":"%s","count":%s,"ips":%s,"offset":%s,"src":"%s","domains":"%s"}\n' "$id" "$(esc "$(_rb_svc_field "$id" 2)")" "${k:-dom}" "${n:-0}" "$ipn" "$o" \ + "$(esc "$(cat "$f.how" 2>/dev/null)")" "$(sed -n "$((o + 1)),$((o + l))p" "$src" 2>/dev/null | tr '\n' ' ')" +} + +# «Найти сайт» на вкладке «Сервисы»: в каких пунктах есть домен (вместе с поддоменами) или его адрес IPv4 +# (подсети — так находятся и пункты b4geoip). Смотрим только то, что уже лежит на роутере, чтобы ответ был +# быстрым; наборы, которых ещё нет, докачиваются в фоне, а в ответе — сколько их ждём (missing), и панель +# повторяет поиск. Адрес берём у DNS роутера, а если там поддельный адрес Steer (198.18.0.0/15) — у внешнего. +_st_find_ip() { + local a s + for s in 127.0.0.1 77.88.8.8 1.1.1.1; do + a="$(_stl_t 4 nslookup "$1" "$s" 2>/dev/null | awk '/^Name:/ { f = 1; next } f && /^Address/ { a = $0; sub(/^Address[ 0-9]*:[ \t]*/, "", a); if (a ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) { print a; exit } }')" + case "$a" in ''|198.18.*|198.19.*|0.*|127.*) continue ;; esac + printf '%s' "$a" + return 0 + done + return 1 +} + +# адрес $1 в подсетях файлов $2…: печатает сработавшую строку (самую узкую) +_st_ip_in() { + local ip="$1"; shift + [ $# -gt 0 ] || return 0 + awk -v ip="$ip" ' + function ipn(a, p) { if (split(a, p, ".") != 4) return -1; return ((p[1] * 256 + p[2]) * 256 + p[3]) * 256 + p[4] } + BEGIN { iv = ipn(ip); bw = -1 } + { + l = $0; sub(/[#;].*$/, "", l); gsub(/[ \t\r]/, "", l) + if (l ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(\/[0-9]+)?$/) { + split(l, c, "/"); b = (c[2] == "") ? 32 : c[2] + 0; if (b < 0 || b > 32) next + z = 2 ^ (32 - b); a = ipn(c[1]) + if (a >= 0 && int(iv / z) == int(a / z) && b > bw) { best = l; bw = b } + } else if (l ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+-[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) { + split(l, c, "-"); if (iv >= ipn(c[1]) && iv <= ipn(c[2]) && bw < 0) { best = l; bw = 0 } + } + } + END { if (bw >= 0) print best }' "$@" 2>/dev/null +} + +steer_svc_find() { + local q host="" ip="" d="$ST_RUN/dom" suf="$ST_RUN/find.$$.s" res="$ST_RUN/find.$$.r" sel miss nmiss=0 sep="" + local id name f3 f4 f5 f6 f7 f8 rest f by line n grp on + q="$(_zm_route_target "$1")" || { echo '{"error":"введите сайт или IP, например fast.com или 1.2.3.4"}'; return 1; } + if printf '%s' "$q" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then + printf '%s' "$q" | awk -F. '$1 < 256 && $2 < 256 && $3 < 256 && $4 < 256 { ok = 1 } END { exit !ok }' || { echo '{"error":"такого IP-адреса не бывает — в каждой части число от 0 до 255"}'; return 1; } + ip="$q" + elif printf '%s' "$q" | grep -q ':'; then echo '{"error":"IPv6 пока не ищется — введите IPv4-адрес или сайт"}'; return 1 + else host="$q"; ip="$(_st_find_ip "$host")"; fi + mkdir -p "$d" + sel=" $(_st_sel | tr '\n' ' ') " + : > "$res"; : > "$suf" + [ -n "$host" ] && printf '%s\n' "$host" | awk '{ s = $0; while (1) { print s; i = index(s, "."); if (!i) break; s = substr(s, i + 1) } }' > "$suf" + _rb_svc_src | awk -F'|' '$1 != "" && !s[$1]++ && ($1 == "custom" || $3 $4 $8 != "")' > "$res.svc" + while IFS='|' read -r id name f3 f4 f5 f6 f7 f8 rest; do + case "$id" in *[!A-Za-z0-9_.-]*) continue ;; esac + f="$d/$id.txt" + if [ "$id" = custom ] || [ ! -f "$f" ] || [ ! -f "$f.pfxsrc" ] || [ -s "$f.miss" ] || [ -n "$(find "$f" -mmin +9 2>/dev/null)" ]; then + ST_DOM_NODL=1 + _st_dom_build "$id" "$f" /dev/null + ST_DOM_NODL="" + fi + by=""; line="" + if [ -s "$suf" ] && [ -s "$f" ]; then + line="$(grep -xF -f "$suf" "$f" 2>/dev/null | awk '{ print length($0) "\t" $0 }' | sort -rn | head -n1 | cut -f2)" + [ -n "$line" ] && by=dom + fi + if [ -z "$by" ] && [ -n "$ip" ] && [ -s "$f.pfxsrc" ]; then + line="$(_st_ip_in "$ip" $(cat "$f.pfxsrc"))" + [ -n "$line" ] && by=ip + fi + [ -n "$by" ] || continue + n="$(cat "$f" $(cat "$f.pfxsrc" 2>/dev/null) 2>/dev/null | wc -l | tr -d ' ')" + grp="${rest%%|*}" + on=false; case "$sel" in *" $id "*) on=true ;; esac + printf '%s|%s|%s|%s|%s|%s|%s\n' "$id" "$name" "$grp" "$on" "$by" "$line" "${n:-0}" >> "$res" + done < "$res.svc" + miss="$(cat "$d"/*.txt.miss 2>/dev/null | tr ' ' '\n' | grep . | sort -u)" + if [ -n "$miss" ]; then + nmiss="$(printf '%s\n' "$miss" | grep -c .)" + [ -d "$ST_RUN/find.lock" ] && [ -n "$(find "$ST_RUN/find.lock" -maxdepth 0 -mmin +10 2>/dev/null)" ] && rmdir "$ST_RUN/find.lock" 2>/dev/null + if ! _st_running && mkdir "$ST_RUN/find.lock" 2>/dev/null; then + ( for f in $miss; do _st_srs_get "$f"; done; rmdir "$ST_RUN/find.lock" ) >/dev/null 2>&1 $*"; } +_ab_get() { + local v + v="$(sed -n "s/^$1=//p" "$AB_SET" 2>/dev/null | tail -n1)" + echo "${v:-$2}" +} +_ab_put() { + local k v tmp="$AB_SET.zm-new" + mkdir -p "$AB_DIR" + [ -f "$AB_SET" ] || : > "$AB_SET" + cp -f "$AB_SET" "$tmp" + while [ $# -ge 2 ]; do + k="$1"; v="$2"; shift 2 + grep -v "^$k=" "$tmp" > "$tmp.2" 2>/dev/null; mv -f "$tmp.2" "$tmp" + echo "$k=$v" >> "$tmp" + done + mv -f "$tmp" "$AB_SET" +} +_ab_file() { + case "$1" in + light) echo light.txt ;; multi) echo multi.txt ;; pro) echo pro.txt ;; + proplus) echo pro.plus.txt ;; ultimate) echo ultimate.txt ;; + tif) echo tif.mini.txt ;; doh) echo doh.txt ;; *) return 1 ;; + esac +} +_ab_title() { + case "$1" in + light) echo Light ;; multi) echo Multi ;; pro) echo Pro ;; proplus) echo "Pro++" ;; + ultimate) echo Ultimate ;; tif) echo "TIF mini (фишинг)" ;; doh) echo "чужих DoH-серверов" ;; *) echo "$1" ;; + esac +} +# Меньше этого — значит, скачалась ошибка или обрывок, а не список +_ab_min() { + case "$1" in + light) echo 20000 ;; multi|pro|proplus|ultimate) echo 60000 ;; tif) echo 80000 ;; doh) echo 1000 ;; + esac +} +_ab_gz() { command -v gzip >/dev/null 2>&1; } +_ab_src() { if [ -s "$AB_DIR/src/$1.gz" ]; then echo "$AB_DIR/src/$1.gz"; elif [ -s "$AB_DIR/src/$1.txt" ]; then echo "$AB_DIR/src/$1.txt"; else return 1; fi; } +_ab_cat() { + local f + f="$(_ab_src "$1")" || return 1 + case "$f" in *.gz) gzip -dc "$f" ;; *) cat "$f" ;; esac +} +_ab_num() { echo "${1:-0}" | awk '{ s = $1; o = ""; while (length(s) > 3) { o = " " substr(s, length(s) - 2) o; s = substr(s, 1, length(s) - 3) } print s o }'; } +_ab_on() { [ "$(_ab_get on 0)" = 1 ]; } +_ab_ids() { + local ids + ids="$(_ab_get list pro)" + [ "$(_ab_get tif 0)" = 1 ] && ids="$ids tif" + [ "$(_ab_get doh 0)" = 1 ] && ids="$ids doh" + echo "$ids" +} +_ab_dirs() { + local d + d="$(sed -n 's/^conf-dir=\([^,]*\).*/\1/p' /var/etc/dnsmasq.conf.* 2>/dev/null | sort -u)" + echo "${d:-/tmp/dnsmasq.d}" +} +# Список подключён ко всем запущенным dnsmasq +_ab_applied() { + local d n=0 + for d in $(_ab_dirs); do [ -s "$d/$AB_NAME" ] || return 1; n=1; done + [ "$n" = 1 ] +} +_ab_dns_rss() { + local p t=0 v + for p in $(pidof dnsmasq 2>/dev/null); do + v="$(awk '/^VmRSS:/ { print $2 }' "/proc/$p/status" 2>/dev/null)" + t=$((t + ${v:-0})) + done + echo "$t" +} +_ab_dns_alive() { pidof dnsmasq >/dev/null 2>&1 && { nslookup localhost 127.0.0.1 >/dev/null 2>&1 || nslookup openwrt.org 127.0.0.1 >/dev/null 2>&1; }; } + +# Адреса DoH-серверов, которыми пользуется сам роутер (https-dns-proxy), никогда не блокируем +_ab_own_hosts() { + local i=0 u + while u="$(uci -q get "https-dns-proxy.@https-dns-proxy[$i].resolver_url")"; do + printf '%s\n' "$u" | sed -n 's#^[a-z]*://\([^/:]*\).*#\1#p' + i=$((i + 1)) + done +} + +# Белый список: по одному домену в строке, «#» — комментарий; лишнее (http://, путь, *.) отрезаем +_ab_allow_norm() { + tr 'A-Z' 'a-z' | sed 's/#.*//; s/^[[:space:]]*//; s/[[:space:]].*$//; s#^[a-z]*://##; s#[/:?].*$##; s/^\*\.//; s/^\.//; s/\.$//' | + grep -E '^[a-z0-9_]([a-z0-9_-]*[a-z0-9_])?(\.[a-z0-9_]([a-z0-9_-]*[a-z0-9_])?)+$' | awk '!s[$0]++' | head -n 500 +} +_ab_allow_text() { if [ -f "$AB_ALLOW" ]; then cat "$AB_ALLOW"; else printf '%s\n' "$AB_ALLOW_DEFAULT"; fi; } + +# Скачивает один список и сохраняет только домены. 0 — скачан, 1 — не вышло (старая копия не тронута) +_ab_dl() { + local id="$1" f tmp="$JOBS_DIR/adblock.$1.dl" dom="$JOBS_DIR/adblock.$1.dom" url host n min ver ok=0 + f="$(_ab_file "$id")" || return 1 + min="$(_ab_min "$id")" + mkdir -p "$AB_DIR/src" + for url in "$AB_URL_GH/$f" "$AB_URL_CDN/$f"; do + host="$(printf '%s' "$url" | sed 's#^https://\([^/]*\).*#\1#')" + echo " → Скачиваем $(_ab_title "$id") ($host)" + rm -f "$tmp" + if curl -fsSL --connect-timeout 10 --max-time 240 --retry 2 -o "$tmp" "$url" >/dev/null 2>&1 && [ -s "$tmp" ]; then + awk -F/ '/^(local|address|server)=\/[^\/]+\// { d = tolower($2); if (d ~ /^[a-z0-9_.-]+$/ && index(d, ".")) print d }' "$tmp" > "$dom" + n="$(wc -l < "$dom" | tr -d ' ')" + if [ "${n:-0}" -ge "$min" ]; then ok=1; break; fi + echo " ! Получен неполный файл ($(_ab_num "${n:-0}") доменов) — пробуем другой адрес" + else + echo " ! Не скачалось" + fi + done + if [ "$ok" != 1 ]; then rm -f "$tmp" "$dom"; return 1; fi + ver="$(sed -n 's/^# Version: *//p' "$tmp" | head -n1 | tr -cd '0-9.')" + rm -f "$tmp" + if _ab_gz; then + gzip -c "$dom" > "$AB_DIR/src/$id.gz.zm-new" && mv -f "$AB_DIR/src/$id.gz.zm-new" "$AB_DIR/src/$id.gz" && rm -f "$AB_DIR/src/$id.txt" + else + mv -f "$dom" "$AB_DIR/src/$id.txt" && rm -f "$AB_DIR/src/$id.gz" + fi + rm -f "$dom" "$AB_DIR/src/$id."*.zm-new + _ab_put "n_$id" "$n" "v_$id" "$ver" "t_$id" "$(date +%s)" + echo " ✓ $(_ab_title "$id"): $(_ab_num "$n") доменов${ver:+, версия $ver}" + return 0 +} + +# Собирает conf из скачанных списков и белого списка. Печатает число заблокированных доменов +_ab_build() { + local out="$1" ids id allow="$JOBS_DIR/adblock.allow" dedup=0 n + ids="$(_ab_ids)" + for id in $ids; do _ab_src "$id" >/dev/null || { echo "нет скачанного списка $(_ab_title "$id")" >&2; return 1; }; done + set -- $ids; [ $# -gt 1 ] && dedup=1 + { _ab_allow_text | _ab_allow_norm; _ab_own_hosts | _ab_allow_norm; } | awk '!s[$0]++' > "$allow" + { for id in $ids; do _ab_cat "$id"; done; } | awk -v af="$allow" -v dedup="$dedup" -v cf="$out.count" ' + BEGIN { while ((getline l < af) > 0) if (l != "") A[l] = 1; close(af) } + { + d = $0 + if (d == "") next + if (dedup) { if (d in S) next; S[d] = 1 } + x = d + while (1) { + if (x in A) next + i = index(x, "."); if (!i) break + x = substr(x, i + 1) + } + print "local=/" d "/"; n++ + } + END { for (a in A) print "server=/" a "/#"; print n + 0 > cf }' > "$out" || return 1 + rm -f "$allow" + n="$(cat "$out.count" 2>/dev/null)"; rm -f "$out.count" + [ "${n:-0}" -gt 0 ] || { echo "список получился пустым" >&2; return 1; } + echo "$n" +} + +# Подключает собранный conf ко всем dnsmasq. Если dnsmasq с ним не поднялся — возвращает как было +_ab_install() { + local new="$1" d dirs bak="$JOBS_DIR/adblock.old" t=0 had=0 + dirs="$(_ab_dirs)" + if ! dnsmasq --test --conf-file="$new" >/dev/null 2>&1; then + echo "ОШИБКА: dnsmasq не принял собранный список — ничего не меняем" + return 1 + fi + rm -f "$bak" + for d in $dirs; do + [ "$had" = 0 ] && [ -s "$d/$AB_NAME" ] && { cp -f "$d/$AB_NAME" "$bak" 2>/dev/null && had=1; } + mkdir -p "$d" && cp -f "$new" "$d/$AB_NAME.zm-tmp" && mv -f "$d/$AB_NAME.zm-tmp" "$d/$AB_NAME" || { + echo "ОШИБКА: не хватило места в памяти роутера для списка" + rm -f "$d/$AB_NAME.zm-tmp" + for d in $dirs; do rm -f "$d/$AB_NAME"; done + return 1 + } + done + /etc/init.d/dnsmasq restart >/dev/null 2>&1 + while [ "$t" -lt 30 ]; do + sleep 2; t=$((t + 2)) + _ab_dns_alive && { rm -f "$bak"; return 0; } + done + echo "!! dnsmasq не отвечает с новым списком — возвращаем прежнее состояние" + for d in $dirs; do + if [ "$had" = 1 ]; then cp -f "$bak" "$d/$AB_NAME"; else rm -f "$d/$AB_NAME"; fi + done + rm -f "$bak" + /etc/init.d/dnsmasq restart >/dev/null 2>&1 + return 1 +} + +_ab_unplug() { + local d + for d in $(_ab_dirs) /tmp/dnsmasq.d; do rm -f "$d/$AB_NAME" "$d/$AB_NAME.zm-tmp"; done +} + +_ab_cron_sync() { + local m line + if _ab_on && [ "$(_ab_get auto 1)" = 1 ]; then + m="$(_ab_get minute "")" + case "$m" in ''|*[!0-9]*) m="$(awk 'BEGIN { srand(); print int(rand() * 60) }')"; _ab_put minute "$m" ;; esac + line="$m 4 * * * /opt/zapret-manager-luci/backend.sh adblock_cron >/dev/null 2>&1 $AB_CRON_TAG" + grep -qxF "$line" "$CRON_FILE" 2>/dev/null && return 0 + mkdir -p "$(dirname "$CRON_FILE")"; touch "$CRON_FILE" + grep -vF "$AB_CRON_TAG" "$CRON_FILE" > "$CRON_FILE.zm" 2>/dev/null; cat "$CRON_FILE.zm" > "$CRON_FILE"; rm -f "$CRON_FILE.zm" + echo "$line" >> "$CRON_FILE" + /etc/init.d/cron restart >/dev/null 2>&1 + else + grep -qF "$AB_CRON_TAG" "$CRON_FILE" 2>/dev/null || return 0 + grep -vF "$AB_CRON_TAG" "$CRON_FILE" > "$CRON_FILE.zm" 2>/dev/null; cat "$CRON_FILE.zm" > "$CRON_FILE"; rm -f "$CRON_FILE.zm" + /etc/init.d/cron restart >/dev/null 2>&1 + fi + return 0 +} + +# Собрать и подключить из уже скачанного (белый список, после перезагрузки) +_ab_rebuild() { + local tmp="$JOBS_DIR/adblock.conf.new" n + mkdir -p "$JOBS_DIR" + n="$(_ab_build "$tmp" 2>&1)" || { rm -f "$tmp"; echo "${n:-не удалось собрать список}"; return 1; } + if ! _ab_install "$tmp" >/dev/null 2>&1; then rm -f "$tmp"; echo "dnsmasq не запустился с этим списком"; return 1; fi + rm -f "$tmp" + _ab_put count "$n" err "" + echo "$n" +} + +do_ab_apply() { + local mode="$1" ids id f tmp="$JOBS_DIR/adblock.conf.new" n avail age now fails=0 got=0 keep + ids="$(_ab_ids)" + _ab_say "Блокировка рекламы: $(_ab_title "$(_ab_get list pro)")$([ "$(_ab_get tif 0)" = 1 ] && echo ' + защита от фишинга')$([ "$(_ab_get doh 0)" = 1 ] && echo ' + запрет чужих DoH')" + avail="$(awk '/^MemAvailable:/ { print $2 }' /proc/meminfo 2>/dev/null)" + if [ -n "$avail" ] && [ "$avail" -lt 60000 ] 2>/dev/null; then + echo "ОШИБКА: свободной памяти роутера всего $((avail / 1024)) МБ — для списка нужно хотя бы 60 МБ. Выберите список поменьше или отключите защиту от фишинга" + _ab_put err "мало свободной памяти" errat "$(date +%s)" + return 1 + fi + _ab_say "Скачиваем списки" + now="$(date +%s)" + for id in $ids; do + age=$(( now - $(_ab_get "t_$id" 0) )) + if [ "$mode" != force ] && _ab_src "$id" >/dev/null && [ "$age" -ge 0 ] && [ "$age" -lt 43200 ]; then + echo " ✓ $(_ab_title "$id"): свежий, скачан $((age / 60)) мин назад" + continue + fi + if _ab_dl "$id"; then got=1 + elif _ab_src "$id" >/dev/null; then echo " ! $(_ab_title "$id"): обновить не удалось — оставляем прежнюю копию"; fails=1 + else + echo "ОШИБКА: $(_ab_title "$id") не скачался ни с GitHub, ни с jsDelivr — проверьте интернет на роутере" + _ab_put err "список не скачался" errat "$(date +%s)" + return 1 + fi + done + # Списки, которые больше не выбраны, — с флеша долой + for f in "$AB_DIR"/src/*; do + [ -f "$f" ] || continue + keep=0; id="$(basename "$f")"; id="${id%.*}" + for n in $ids; do [ "$n" = "$id" ] && keep=1; done + [ "$keep" = 1 ] || rm -f "$f" + done + _ab_say "Собираем список для dnsmasq" + n="$(_ab_build "$tmp")" || { rm -f "$tmp"; echo "ОШИБКА: не удалось собрать список"; _ab_put err "не удалось собрать список" errat "$(date +%s)"; return 1; } + echo " ✓ К блокировке: $(_ab_num "$n") доменов (белый список учтён)" + _ab_say "Подключаем к dnsmasq" + if ! _ab_install "$tmp"; then + rm -f "$tmp" + _ab_put err "dnsmasq не запустился со списком" errat "$(date +%s)" + return 1 + fi + rm -f "$tmp" + echo " ✓ dnsmasq перезапущен и отвечает (занимает $(( $(_ab_dns_rss) / 1024 )) МБ памяти)" + _ab_put count "$n" updated "$(date +%s)" err "" + _ab_cron_sync + _zm_inet_ok || echo "!! Сайты сейчас не открываются с роутера — проверьте интернет; список тут ни при чём, его можно выключить кнопкой «Выключить»" + [ "$fails" = 1 ] && echo "!! Часть списков осталась прежней версии — попробуйте «Обновить сейчас» позже" + _ab_say "Готово: реклама блокируется для всех устройств в сети" +} + +adblock_status() { + local on=false applied=false auto=false tif=false doh=false job=false ram avail have="" sep="" id ver="" vs="" err errat + _ab_on && on=true + _ab_applied && applied=true + [ "$(_ab_get auto 1)" = 1 ] && auto=true + [ "$(_ab_get tif 0)" = 1 ] && tif=true + [ "$(_ab_get doh 0)" = 1 ] && doh=true + _job_alive adblock && job=true + ram="$(awk '/^MemTotal:/ { print $2 }' /proc/meminfo 2>/dev/null)" + avail="$(awk '/^MemAvailable:/ { print $2 }' /proc/meminfo 2>/dev/null)" + for id in light multi pro proplus ultimate tif doh; do + _ab_src "$id" >/dev/null || continue + have="$have$sep\"$id\":{\"n\":$(_ab_get "n_$id" 0),\"v\":\"$(esc "$(_ab_get "v_$id" "")")\",\"t\":$(_ab_get "t_$id" 0)}"; sep="," + done + err="$(_ab_get err "")"; errat="$(_ab_get errat 0)" + printf '{"on":%s,"applied":%s,"list":"%s","tif":%s,"doh":%s,"auto":%s,"minute":"%s","count":%s,"updated":%s,"now":%s,"err":"%s","errat":%s,"job":%s,"dns_kb":%s,"ram_kb":%s,"avail_kb":%s,"doh_proxy":%s,"src":{%s},"allow":"%s","allow_default":"%s"}\n' \ + "$on" "$applied" "$(esc "$(_ab_get list pro)")" "$tif" "$doh" "$auto" "$(esc "$(_ab_get minute "")")" \ + "$(_ab_get count 0)" "$(_ab_get updated 0)" "$(date +%s)" "$(esc "$err")" "${errat:-0}" "$job" \ + "$(_ab_dns_rss)" "${ram:-0}" "${avail:-0}" "$(pidof https-dns-proxy >/dev/null 2>&1 && echo true || echo false)" \ + "$have" "$(esc_ml "$(_ab_allow_text)")" "$(esc_ml "$AB_ALLOW_DEFAULT")" +} + +# Есть ли домен (или домен уровнем выше) в скачанных списках и в белом списке +_ab_check() { + local q id hit allow + q="$(printf '%s\n' "$1" | _ab_allow_norm | head -n1)" + [ -n "$q" ] || { echo '{"error":"введите домен, например doubleclick.net"}'; return 1; } + if ! _ab_on; then printf '{"domain":"%s","state":"off"}\n' "$(esc "$q")"; return 0; fi + allow="$( { _ab_allow_text | _ab_allow_norm; _ab_own_hosts | _ab_allow_norm; } | awk -v q="$q" ' + { A[$0] = 1 } END { x = q; while (1) { if (x in A) { print x; exit } i = index(x, "."); if (!i) break; x = substr(x, i + 1) } }')" + for id in $(_ab_ids); do + hit="$(_ab_cat "$id" 2>/dev/null | awk -v q="$q" ' + BEGIN { x = q; while (1) { C[x] = 1; i = index(x, "."); if (!i) break; x = substr(x, i + 1) } } + ($0 in C) { print; exit }')" + [ -n "$hit" ] && break + done + if [ -n "$hit" ] && [ -n "$allow" ]; then + printf '{"domain":"%s","state":"allowed","source":"%s","by":"%s","allow":"%s"}\n' "$(esc "$q")" "$(esc "$(_ab_title "$id")")" "$(esc "$hit")" "$(esc "$allow")" + elif [ -n "$hit" ]; then + printf '{"domain":"%s","state":"blocked","source":"%s","by":"%s"}\n' "$(esc "$q")" "$(esc "$(_ab_title "$id")")" "$(esc "$hit")" + else + printf '{"domain":"%s","state":"clean"}\n' "$(esc "$q")" + fi +} + +adblock_action() { + local a="$1" m="$2" j k v kv list tif doh auto n + case "$a" in + apply|update) + _job_alive adblock && { echo '{"error":"список уже обновляется — дождитесь окончания"}'; return 1; } + if j="$(_zm_busy_job)"; then printf '{"error":"%s"}\n' "$(esc "Сейчас идёт операция: $j — дождитесь её окончания")"; return 1; fi + if [ "$a" = apply ]; then + list="$(_ab_get list pro)"; tif="$(_ab_get tif 0)"; doh="$(_ab_get doh 0)"; auto="$(_ab_get auto 1)" + for kv in $m; do + k="${kv%%=*}"; v="${kv#*=}" + case "$k" in + list) _ab_file "$v" >/dev/null && case "$v" in tif|doh) ;; *) list="$v" ;; esac ;; + tif) [ "$v" = 1 ] && tif=1 || tif=0 ;; + doh) [ "$v" = 1 ] && doh=1 || doh=0 ;; + auto) [ "$v" = 1 ] && auto=1 || auto=0 ;; + esac + done + _ab_put on 1 list "$list" tif "$tif" doh "$doh" auto "$auto" + job_start adblock do_ab_apply + else + _ab_on || { echo '{"error":"блокировка выключена"}'; return 1; } + job_start adblock do_ab_apply force + fi ;; + off) + _job_alive adblock && { echo '{"error":"дождитесь окончания операции"}'; return 1; } + _ab_put on 0 err "" + _ab_unplug + rm -rf "$AB_DIR/src" + _ab_cron_sync + /etc/init.d/dnsmasq restart >/dev/null 2>&1 + printf '{"ok":true}\n' ;; + auto) + [ "$m" = 1 ] && v=1 || v=0 + _ab_put auto "$v" + _ab_cron_sync + printf '{"ok":true,"auto":%s,"minute":"%s"}\n' "$([ "$v" = 1 ] && echo true || echo false)" "$(_ab_get minute "")" ;; + allow_set) + _job_alive adblock && { echo '{"error":"список сейчас обновляется — сохраните чуть позже"}'; return 1; } + mkdir -p "$AB_DIR" + if [ "$m" = default ]; then rm -f "$AB_ALLOW" + else printf '%s\n' "$m" | tr -d '\r' | head -c 65536 > "$AB_ALLOW.zm-new" && mv -f "$AB_ALLOW.zm-new" "$AB_ALLOW"; fi + n="" + if _ab_on && _ab_applied; then + n="$(_ab_rebuild)" || { printf '{"error":"%s"}\n' "$(esc "белый список сохранён, но применить не удалось: $n")"; return 1; } + fi + printf '{"ok":true,"count":%s,"entries":%s}\n' "${n:-0}" "$(_ab_allow_text | _ab_allow_norm | wc -l | tr -d ' ')" ;; + check) _ab_check "$m" ;; + *) echo '{"error":"неизвестное действие"}'; return 1 ;; + esac +} + +# Ежедневное обновление из cron +adblock_cron() { + _ab_on && [ "$(_ab_get auto 1)" = 1 ] || { _ab_cron_sync; return 0; } + _job_alive adblock && return 0 + _zm_busy_job >/dev/null && return 0 + job_start adblock do_ab_apply force >/dev/null +} + +# Сторож: после перезагрузки или чужого перезапуска dnsmasq файл со списком мог пропасть +_ab_watch() { + local stamp="$JOBS_DIR/adblock.watch" r + _ab_on || return 0 + _ab_applied && return 0 + _job_alive adblock && return 0 + [ -n "$(find "$stamp" -mmin -10 2>/dev/null)" ] && return 0 + touch "$stamp" + if r="$(_ab_rebuild)"; then + logger -t zapret-manager "Блокировка рекламы: список снова подключён к dnsmasq ($r доменов)" + return 0 + fi + _zm_busy_job >/dev/null && return 0 + logger -t zapret-manager "Блокировка рекламы: нет готового списка ($r) — скачиваем заново" + job_start adblock do_ab_apply >/dev/null +} + cmd="$1"; shift _zm_in() { ZM_IN="$(cat; echo .)" @@ -16660,6 +17431,9 @@ case "$cmd" in awg_action) awg_action "$1" "$2" ;; term_status) term_status ;; term_action) term_action "$1" "$2" ;; + adblock_status) adblock_status ;; + adblock_action) adblock_action "$1" "$2" ;; + adblock_cron) adblock_cron ;; steer_status) steer_status ;; steer_action) steer_action "$1" "$2" ;; bytetube_action) bytetube_action "$1" ;; @@ -16678,6 +17452,8 @@ case "$cmd" in zm_watch) zm_watch ;; ui_theme_get) ui_theme_get ;; ui_theme_set) ui_theme_set "$1" ;; + ui_flags_get) ui_flags_get ;; + ui_flag_set) ui_flag_set "$1" ;; *) echo '{"error":"неизвестная команда"}'; exit 1 ;; esac ZM_INSTALLER_EOF @@ -19537,6 +20313,8 @@ list_methods() { json_add_object "awg_action"; json_add_string "action" "string"; json_add_string "mode" "string"; json_close_object json_add_object "term_status"; json_close_object json_add_object "term_action"; json_add_string "action" "string"; json_add_string "mode" "string"; json_close_object + json_add_object "adblock_status"; json_close_object + json_add_object "adblock_action"; json_add_string "action" "string"; json_add_string "mode" "string"; json_close_object json_add_object "steer_status"; json_close_object json_add_object "steer_action"; json_add_string "action" "string"; json_add_string "mode" "string"; json_close_object json_add_object "bytetube_action"; json_add_string "action" "string"; json_close_object @@ -19547,6 +20325,8 @@ list_methods() { json_add_object "jobs_cancel"; json_add_string "job" "string"; json_close_object json_add_object "ui_theme_get"; json_close_object json_add_object "ui_theme_set"; json_add_string "theme" "string"; json_close_object + json_add_object "ui_flags_get"; json_close_object + json_add_object "ui_flag_set"; json_add_string "name" "string"; json_close_object json_dump } @@ -19649,6 +20429,8 @@ call_method() { awg_action) json_get_var action action; json_get_var mode mode; printf '%s' "$mode" | "$BACKEND" awg_action "$action" @stdin ;; term_status) "$BACKEND" term_status ;; term_action) json_get_var action action; json_get_var mode mode; printf '%s' "$mode" | "$BACKEND" term_action "$action" @stdin ;; + adblock_status) "$BACKEND" adblock_status ;; + adblock_action) json_get_var action action; json_get_var mode mode; printf '%s' "$mode" | "$BACKEND" adblock_action "$action" @stdin ;; steer_status) "$BACKEND" steer_status ;; steer_action) json_get_var action action; json_get_var mode mode; printf '%s' "$mode" | "$BACKEND" steer_action "$action" @stdin ;; bytetube_action) json_get_var action action; "$BACKEND" bytetube_action "$action" ;; @@ -19659,6 +20441,8 @@ call_method() { jobs_cancel) json_get_var job job; "$BACKEND" jobs_cancel "$job" ;; ui_theme_get) "$BACKEND" ui_theme_get ;; ui_theme_set) json_get_var theme theme; "$BACKEND" ui_theme_set "$theme" ;; + ui_flags_get) "$BACKEND" ui_flags_get ;; + ui_flag_set) json_get_var name name; "$BACKEND" ui_flag_set "$name" ;; *) echo '{"error":"unknown method"}'; return 1 ;; esac } @@ -19727,11 +20511,13 @@ cat > '/usr/share/rpcd/acl.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF' "versions", "awg_status", "term_status", + "adblock_status", "steer_status", "forkop_status", "forkop_config_get", "sysinfo_get", - "ui_theme_get" + "ui_theme_get", + "ui_flags_get" ], "system": [ "info" @@ -19805,12 +20591,14 @@ cat > '/usr/share/rpcd/acl.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF' "bytetube_action", "awg_action", "term_action", + "adblock_action", "steer_action", "forkop_config_set", "forkop_action", "sysinfo_run", "jobs_cancel", "ui_theme_set", + "ui_flag_set", "system_reboot", "reboot_hint_clear" ] @@ -19867,6 +20655,11 @@ cat > '/usr/share/luci/menu.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF "order": 40, "action": { "type": "view", "path": "zapret-manager/hosts" } }, + "admin/services/zapret-manager/adblock": { + "title": "Блокировка рекламы", + "order": 45, + "action": { "type": "view", "path": "zapret-manager/adblock" } + }, "admin/services/zapret-manager/doh": { "title": "DNS over HTTPS", "order": 50, @@ -19942,6 +20735,10 @@ var callAwgStatus = zmDeclare({ object: 'zapret-manager', method: 'awg_status', var callAwgAction = zmDeclare({ object: 'zapret-manager', method: 'awg_action', params: ['action', 'mode'], expect: {} }); var callTermStatus = zmDeclare({ object: 'zapret-manager', method: 'term_status', expect: {} }); var callTermAction = zmDeclare({ object: 'zapret-manager', method: 'term_action', params: ['action', 'mode'], expect: {} }); +var callUiFlagsGet = zmDeclare({ object: 'zapret-manager', method: 'ui_flags_get', expect: {} }); +var callUiFlagSet = zmDeclare({ object: 'zapret-manager', method: 'ui_flag_set', params: ['name'], expect: {} }); +var callAdblockStatus = zmDeclare({ object: 'zapret-manager', method: 'adblock_status', expect: {} }); +var callAdblockAction = zmDeclare({ object: 'zapret-manager', method: 'adblock_action', params: ['action', 'mode'], expect: {} }); var callSteerStatus = zmDeclare({ object: 'zapret-manager', method: 'steer_status', expect: {} }); var callSteerAction = zmDeclare({ object: 'zapret-manager', method: 'steer_action', params: ['action', 'mode'], expect: {} }); var callSystemInfo = zmDeclare({ object: 'zapret-manager', method: 'system_info', expect: {} }); @@ -20702,6 +21499,7 @@ function svcCard(o) { return E('div', { 'class': 'zm-svc' + (on ? ' zm-svc-on' : '') + (o.inc && !o.on ? ' zm-svc-inc' : '') + (o.cls ? ' ' + o.cls : ''), 'title': o.title || (o.inc && !o.on ? 'Входит в «Всё сразу» (Russia inside)' : (typeof o.name === 'string' ? o.name : '')), 'click': o.click }, [ E('div', { 'class': 'zm-svc-ico', 'style': 'background:' + (o.color || ic.color) }, o.ico || ic.ico), E('div', { 'class': 'zm-svc-text' }, [ E('div', { 'class': 'zm-svc-name' }, svcTx(o.name)), o.inc && !o.on ? E('div', { 'class': 'zm-svc-sub' }, 'входит в «Всё сразу»') : o.sub ? E('div', { 'class': 'zm-svc-sub' }, svcTx(o.sub)) : E([]) ]), + o.extra || E([]), o.right || E('div', { 'class': 'zm-switch' + (on ? ' zm-switch-on' : '') + (o.inc && !o.on ? ' zm-switch-inc' : '') }, [ E('span') ]) ]); } @@ -21394,12 +22192,40 @@ function routeWhere(w, o) { return out; } +/* Плашки «Понятно, больше не показывать»: отметка хранится на роутере (LuCI и Web UI — разные адреса, + браузер держит их хранилища раздельно), а в браузере — только как быстрый запас до ответа роутера */ +var _uiFlags = null; +function uiFlags() { + if (!_uiFlags) _uiFlags = callUiFlagsGet().then(function(r) { return (r && r.flags) || {}; }).catch(function() { return {}; }); + return _uiFlags; +} +function uiFlagLocal(key, set) { + try { + if (set) localStorage.setItem('zm.flag.' + key, '1'); + return localStorage.getItem('zm.flag.' + key) === '1'; + } catch (e) { return !!set; } +} +function uiFlagSet(key) { + uiFlagLocal(key, true); + uiFlags().then(function(f) { f[key] = 1; }); + return callUiFlagSet(key).catch(function() {}); +} + function hostsWarn(name) { var href = document.body.classList.contains('zmw-body') ? '#/hosts' : L.url('admin/services/zapret-manager/hosts'); + var key = 'hosts_warn_' + String(name).toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, ''); + var want = false, hidden = uiFlagLocal(key); var el = E('div', { 'class': 'zm-refresh-banner zm-show zm-hosts-warn', 'role': 'note', 'style': 'display:none' }, [ - E('span', { 'style': 'line-height:1.5' }, [ 'В ', E('a', { 'href': href }, 'hosts'), ' есть сторонние записи — они могут мешать работе ' + name + '.' ]) + E('span', { 'style': 'line-height:1.5' }, [ 'В ', E('a', { 'href': href }, 'hosts'), ' есть сторонние записи — они могут мешать работе ' + name + '.' ]), + E('button', { 'type': 'button', 'class': 'cbi-button zm-hosts-warn-ok', 'click': function() { + hidden = true; paint(); + uiFlagSet(key); + toast('Это предупреждение больше не появится', 'info'); + } }, 'Понятно, больше не показывать') ]); - el.set = function(on) { el.style.display = on ? '' : 'none'; return el; }; + function paint() { el.style.display = want && !hidden ? '' : 'none'; } + uiFlags().then(function(f) { if (f[key]) { hidden = true; paint(); } }); + el.set = function(on) { want = !!on; paint(); return el; }; return el; } @@ -21501,6 +22327,8 @@ return baseclass.extend({ awgAction: bigArg2(callAwgAction), termStatus: callTermStatus, termAction: bigArg2(callTermAction), + adblockStatus: callAdblockStatus, + adblockAction: bigArg2(callAdblockAction), steerStatus: callSteerStatus, catalogWatch: catalogWatch, versions: callVersions, @@ -21514,6 +22342,7 @@ return baseclass.extend({ dock: dock, riCovers: riCovers, svcPickToggle: svcPickToggle, + svcIco: svcIco, riNote: riNote, swRow: swRow, dialog: dialog, @@ -21636,6 +22465,8 @@ return view.extend({ : hostsTotal ? (hostsEnabled > 0 ? zm.badge(true, 'включено ' + hostsEnabled + ' из ' + hostsTotal, '') : E('span', { 'class': 'zm-badge zm-off' }, [ E('span', { 'class': 'zm-dot' }), 'ничего не включено' ])) : E('span', {}, '—'))); + var abSt = h && h.dots && typeof h.dots.adblock === 'number' ? h.dots.adblock : 0; + items.push(row('Блокировка рекламы', abSt === 1 ? zm.badge(true, 'работает', '') : abSt === 2 ? warnBadge('список не подключён') : offBadge('выключена'))); if (sysFlags.length) items.push(row('Система', E('span', { 'style': 'display:flex;flex-wrap:wrap;gap:6px;flex:1 1 0;min-width:0' }, sysFlags.map(function(f) { return E('span', { 'class': 'zm-badge zm-off' }, f); })))); var half = Math.ceil(items.length / 2); @@ -23087,6 +23918,7 @@ return view.extend({ zm.toast('Серверы обновлены — проверяем задержку до каждого', 'info'); } diagRes = null; + domData = {}; renderAll(); loadSub(); if (ok && data.installed && !data.stopped && done !== 'stop' && done !== 'remove') runDiag(true); @@ -23209,7 +24041,7 @@ return view.extend({ var nByp = Object.keys(cur).filter(function(k) { return cur[k].mode === 'byp'; }).length; var via = data.exit === 'vpn' ? 'VPN' + (data.sub_label ? ' (' + data.sub_label + ')' : '') : data.exit === 'warp' ? warpName() : 'туннель'; devCard.appendChild(E('h3', {}, [ 'Весь трафик устройства через Steer ', nAll ? badge('zm-ok', nAll + ' ' + plural(nAll, 'устройство', 'устройства', 'устройств')) : E([]) ])); - devCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Выбранные устройства целиком ходят через ' + via + ' — любые сайты и приложения, а не только выбранные сервисы. Подходит для телевизора, приставки или телефона, которым VPN нужен всегда. Своя сеть (роутер, принтер, NAS) остаётся напрямую. Если туннель упал — ' + (data.onfail === 'drop' ? 'у таких устройств пропадёт интернет, пока он не вернётся' : 'они пойдут напрямую, через провайдера') + ' (это задаётся в «Если туннель упал» вверху). Устройство узнаётся по MAC — адрес по DHCP может смениться.')); + devCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Весь трафик выбранных устройств идёт через ' + via + ', а не только выбранные сервисы. Локальная сеть — напрямую. Если туннель упал — ' + (data.onfail === 'drop' ? 'интернета у них не будет, пока он не вернётся' : 'пойдут напрямую') + '.')); devCard.appendChild(grid('all')); devCard.appendChild(E('h3', { 'style': 'margin-top:20px' }, [ 'Мимо Steer ', nByp ? badge('zm-warn', nByp + ' ' + plural(nByp, 'устройство', 'устройства', 'устройств')) : E([]) ])); devCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Эти устройства всегда ходят напрямую, даже к выбранным сервисам. Этот выбор важнее выбранных сервисов.')); @@ -23417,6 +24249,331 @@ return view.extend({ var CATEGORY_IDS = [ 'geoblock', 'block', 'news', 'anime', 'porn', 'hodca', 'russia_inside', 'russia_outside', 'ukraine_inside' ]; var openGroups = {}; + /* Домены списка пункта: значок «≡» на карточке раскрывает их панелью под рядом карточек (как + * раскрывается b4geoip). Открыт один список; показываем первые 500, весь — «Скачать .txt» + * (читается с роутера частями). У пункта, где в списке одни подсети, значка нет. */ + var domOpen = null, domData = {}, domCards = {}, domPanelEl = null, DOM_SHOW = 500, DOM_PART = 4000; + function domSvc(id) { + var r = null; + (data.services || []).forEach(function(x) { if (x.id === id) r = x; }); + return r; + } + function domWord(n) { return plural(n, 'домен', 'домена', 'доменов'); } + function domNum(n) { return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ' '); } + function domIcon(s) { + if (!s.dom) return null; + var b = E('button', { 'type': 'button', 'class': 'zm-svc-dom' + (domOpen === s.id ? ' zm-active' : ''), 'title': 'Домены списка', + 'aria-label': 'Домены списка «' + s.name + '»', 'click': function(ev) { ev.stopPropagation(); ev.preventDefault(); domToggle(s.id); } }, + [ E('i'), E('i'), E('i') ]); + b._zmDom = s.id; + return b; + } + function domLoad(id) { + zm.steerAction('svc_dom', id + ':0:' + DOM_SHOW).then(function(r) { + if (r.error) { domData[id] = { error: String(r.error) }; domPlace(); return; } + var n = parseInt(r.count, 10) || 0; + domData[id] = { count: n, ips: parseInt(r.ips, 10) || 0, src: String(r.src || ''), list: String(r.domains || '').split(/\s+/).filter(function(x) { return x; }) }; + if (!n) { + var sv = domSvc(id); + if (sv) sv.dom = false; + if (domOpen === id) domOpen = null; + zm.toast('В списке «' + (sv ? sv.name : id) + '» только IP-подсети' + (domData[id].ips ? ' (' + domNum(domData[id].ips) + ')' : '') + ' — доменов нет', 'info'); + renderLists(); + return; + } + domPlace(); + }).catch(function() { domData[id] = { error: 'Роутер не ответил' }; domPlace(); }); + } + function domToggle(id) { + domOpen = domOpen === id ? null : id; + if (domOpen && domData[domOpen] && domData[domOpen].error) delete domData[domOpen]; + domPlace(); + } + function domDownload(id) { + var d = domData[id], sv = domSvc(id); + if (!d || d.dl) return; + d.dl = true; domPlace(); + var parts = []; + function done(err) { + d.dl = false; domPlace(); + if (err) zm.toast(err, 'error'); + } + /* домены, затем IP-подсети — частями, как читает роутер; в файле — два раздела с заголовками «#», + * их понимают и «Свой список», и большинство программ для списков */ + var ips = [], ipTotal = 0; + function save() { + var name = (sv && sv.name || id), head = '# ' + name + ': ' + domNum(d.count) + ' ' + domWord(d.count) + (ipTotal ? ', ' + domNum(ipTotal) + ' ' + plural(ipTotal, 'IP-подсеть', 'IP-подсети', 'IP-подсетей') : ''); + var text = head + '\n\n# Домены (вместе с поддоменами)\n' + parts.join('\n') + '\n' + (ips.length ? '\n# IP-подсети\n' + ips.join('\n') + '\n' : ''); + var url = URL.createObjectURL(new Blob([ text ], { type: 'text/plain;charset=utf-8' })); + var a = E('a', { 'href': url, 'download': (ips.length ? 'list-' : 'domains-') + id + '.txt', 'style': 'display:none' }); + document.body.appendChild(a); + a.click(); + setTimeout(function() { URL.revokeObjectURL(url); if (a.parentNode) a.parentNode.removeChild(a); }, 1500); + done(); + } + function nextIp(off) { + zm.steerAction('svc_dom', id + ':' + off + ':' + DOM_PART + ':ip').then(function(r) { + if (r.error) { done(String(r.error)); return; } + var list = String(r.domains || '').split(/\s+/).filter(function(x) { return x; }); + if (list.length) ips = ips.concat(list); + ipTotal = parseInt(r.ips, 10) || 0; + if (list.length && off + DOM_PART < ipTotal) { nextIp(off + DOM_PART); return; } + ipTotal = ips.length; + save(); + }).catch(function() { done('Роутер не ответил'); }); + } + function next(off) { + zm.steerAction('svc_dom', id + ':' + off + ':' + DOM_PART).then(function(r) { + if (r.error) { done(String(r.error)); return; } + var list = String(r.domains || '').split(/\s+/).filter(function(x) { return x; }); + if (list.length) parts.push(list.join('\n')); + var total = parseInt(r.count, 10) || 0; + if (list.length && off + DOM_PART < total) { next(off + DOM_PART); return; } + if ((parseInt(r.ips, 10) || 0) > 0) nextIp(0); else save(); + }).catch(function() { done('Роутер не ответил'); }); + } + next(0); + } + function domPanel(id) { + var d = domData[id], sv = domSvc(id) || { name: id }, body = []; + var sub = !d ? 'загружаем…' : d.error ? 'не удалось получить список' + : domNum(d.count) + ' ' + domWord(d.count) + (d.ips ? ' · ' + domNum(d.ips) + ' ' + plural(d.ips, 'IP-подсеть', 'IP-подсети', 'IP-подсетей') : ''); + var subTip = d && !d.error ? 'Домены действуют вместе с поддоменами' + (d.ips ? '; IP-подсети — адреса и диапазоны, которые идут тем же путём' : '') + (d.src ? '. Источник: ' + d.src : '') : ''; + var btns = []; + if (d && d.count) btns.push(E('button', { 'type': 'button', 'class': 'cbi-button', 'title': d.ips ? 'Домены и IP-подсети одним файлом .txt' : 'Все домены списка файлом .txt', 'disabled': d.dl ? '' : null, 'click': function() { domDownload(id); } }, d.dl ? 'Скачиваем…' : d.ips ? 'Скачать список доменов и IP' : 'Скачать список доменов')); + btns.push(E('button', { 'type': 'button', 'class': 'cbi-button', 'click': function() { domToggle(id); } }, 'Свернуть')); + if (!d) body.push(E('p', { 'class': 'zm-hint zm-dom-more' }, 'Читаем список с роутера…')); + else if (d.error) body.push(E('div', { 'class': 'zm-refresh-banner zm-show', 'style': 'margin-top:12px' }, d.error)); + else { + body.push(E('div', { 'class': 'zm-dom-list' }, d.list.map(function(x) { return E('div', { 'class': 'zm-dom-item' }, x); }))); + if (d.count > d.list.length) body.push(E('p', { 'class': 'zm-hint zm-dom-more' }, 'Показаны первые ' + domNum(d.list.length) + ' из ' + domNum(d.count) + '. Весь список' + (d.ips ? ' вместе с IP-подсетями' : '') + ' — по кнопке «' + (d.ips ? 'Скачать список доменов и IP' : 'Скачать список доменов') + '».')); + } + return E('div', { 'class': 'zm-dom-panel' }, [ + E('div', { 'class': 'zm-dom-head' }, [ + E('div', { 'class': 'zm-dom-title' }, [ E('b', {}, 'Домены · ' + sv.name), E('span', { 'class': 'zm-dom-sub', 'title': subTip || null }, sub) ]), + E('div', { 'class': 'zm-dom-btns' }, btns) + ]) + ].concat(body)); + } + /* Панель встаёт сразу за последней карточкой того же ряда — ряд узнаём по offsetTop. */ + function domPlace() { + if (domPanelEl && domPanelEl.parentNode) domPanelEl.parentNode.removeChild(domPanelEl); + domPanelEl = null; + Object.keys(domCards).forEach(function(k) { + var b = domCards[k] && domCards[k].querySelector('.zm-svc-dom'); + if (b) b.classList.toggle('zm-active', k === domOpen); + }); + var card = domOpen && domCards[domOpen]; + if (!card || !card.parentNode || !document.body.contains(card)) return; + if (domData[domOpen] === undefined) { domData[domOpen] = null; domLoad(domOpen); } + domPanelEl = domPanel(domOpen); + var n = card, top = card.offsetTop; + while (n.nextElementSibling && n.nextElementSibling.offsetTop === top) n = n.nextElementSibling; + card.parentNode.insertBefore(domPanelEl, n.nextSibling); + } + var domResizeT = null; + window.addEventListener('resize', function() { + if (!domOpen) return; + clearTimeout(domResizeT); + domResizeT = setTimeout(domPlace, 150); + }); + + /* «Найти сайт»: окно поиска поверх страницы (кнопка у заголовка «Сервисы» или Ctrl+K). Ищет домен — + * вместе с поддоменами — и его адрес (подсети, b4geoip) по спискам всех пунктов и сразу даёт их + * включить тем же переключателем, что на карточке; применяется общей кнопкой «Сохранить и применить». + * Списки, которых ещё нет на роутере, докачиваются в фоне — поиск сам повторяется. */ + var findEl = null, findQ = '', findRes = null, findBusy = false, findT = null, findRetryT = null, findSeq = 0, findTries = 0, findAdding = false; + function findSel() { return pick || currentPick(); } + function findSvg(cls, svg) { var el = E('span', { 'class': cls, 'aria-hidden': 'true' }); el.innerHTML = svg; return el; } + var FIND_LOUPE = ''; + var FIND_CROSS = ''; + function findKey(ev) { if (ev.key === 'Escape' && findEl) { ev.preventDefault(); findClose(); } } + function findClose() { + clearTimeout(findT); clearTimeout(findRetryT); + document.removeEventListener('keydown', findKey); + if (findEl && findEl.parentNode) findEl.parentNode.removeChild(findEl); + findEl = null; + } + function findOpen() { + if (findEl || data.blocker) return; + var input = E('input', { 'type': 'text', 'class': 'zm-find-input', 'placeholder': 'Сайт или IP, например fast.com или 1.2.3.4', 'aria-label': 'Сайт или IP-адрес', + 'autocomplete': 'off', 'autocapitalize': 'off', 'spellcheck': 'false', 'enterkeyhint': 'search' }); + input.value = findQ; + var body = E('div', { 'class': 'zm-find-body' }); + var box = E('div', { 'class': 'zm-find zmw-modal', 'role': 'dialog', 'aria-modal': 'true', 'aria-label': 'Найти сайт или IP в списках' }, [ + E('div', { 'class': 'zm-find-field' }, [ + findSvg('zm-find-ico', FIND_LOUPE), + input, + E('button', { 'type': 'button', 'class': 'zm-find-x', 'title': 'Закрыть', 'aria-label': 'Закрыть', 'click': findClose }, [ findSvg('zm-find-x-ico', FIND_CROSS) ]) + ]), + body + ]); + findEl = E('div', { 'class': 'zm-find-wrap', 'click': function(ev) { if (ev.target === findEl) findClose(); } }, [ box ]); + findEl._body = body; + input.addEventListener('input', function() { + findQ = input.value; + clearTimeout(findT); + findT = setTimeout(function() { findRun(false); }, 600); + }); + input.addEventListener('keydown', function(ev) { + if (ev.key === 'Enter') { ev.preventDefault(); clearTimeout(findT); findRun(false); } + }); + document.body.appendChild(findEl); + document.addEventListener('keydown', findKey); + findDraw(); + setTimeout(function() { try { input.focus(); input.select(); } catch (e) {} }, 0); + } + function findRun(auto) { + var q = String(findQ || '').trim(); + clearTimeout(findRetryT); + if (!q) { findSeq++; findRes = null; findBusy = false; findDraw(); return; } + if (!auto) findTries = 0; + var seq = ++findSeq; + if (!auto) { findBusy = true; findDraw(); } + zm.steerAction('svc_find', q).then(function(r) { + if (seq !== findSeq) return; + findBusy = false; + findRes = r || {}; + findDraw(); + if (!findRes.error && (parseInt(findRes.missing, 10) || 0) > 0 && findTries < 8 && findEl) { + findTries++; + findRetryT = setTimeout(function() { if (findEl && seq === findSeq) findRun(true); }, 5000); + } + }).catch(function() { + if (seq !== findSeq) return; + findBusy = false; + findRes = { error: 'Роутер не ответил' }; + findDraw(); + }); + } + function findToggle(id) { + if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } + var sel = findSel(); + pick = {}; + for (var k in sel) if (sel[k] && k !== 'custom') pick[k] = true; + zm.svcPickToggle(pick, id); + renderLists(); + findDraw(); + } + function findAddCustom(q) { + if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } + if (customDirty) { zm.toast('Сначала сохраните изменения в «Своём списке»', 'warning'); return; } + var csvc = domSvc('custom'); + if (!csvc || findAdding) return; + findAdding = true; findDraw(); + function fail(msg) { findAdding = false; findDraw(); zm.toast(msg, 'error'); } + function go(cur) { + var lines = String(cur || '').split('\n').map(function(x) { return x.trim(); }).filter(function(x) { return x; }); + if (lines.indexOf(q) < 0) lines.push(q); + zm.steerAction('list_set', 'custom|' + lines.join('\n')).then(function(res) { + findAdding = false; + if (res.error) { fail(String(res.error)); return; } + if (findRes && findRes.hits) findRes.hits.push({ id: 'custom', name: csvc.name, group: '', on: !!csvc.on, by: 'dom', line: q, size: lines.length }); + findDraw(); + loadCustom(); + if (!res.saved) { + zm.toast(q + ' добавлен в «Свой список» — применяем правила', 'info'); + lastAction = 'domlist'; data.running = true; data.phase = 'rules'; + follow(); + return; + } + if (!csvc.on && data.installed && !data.stopped) { + var sel = findSel(), ids = []; + for (var k in sel) if (sel[k] && k !== 'custom') ids.push(k); + ids.push('custom'); + pick = null; + act('lists', ids.join(','), q + ' добавлен в «Свой список» — включаем его'); + return; + } + zm.toast(q + ' добавлен в «Свой список»' + (csvc.on ? '' : ' — включите его на карточке «Свой список»'), 'info'); + refresh(); + }).catch(function() { fail('Роутер не ответил'); }); + } + if (customData && !customLoading) go(customData.content); + else zm.steerAction('list_get', 'custom').then(function(r) { + if (r.error) { fail(String(r.error)); return; } + go(r.content); + }).catch(function() { fail('Роутер не ответил'); }); + } + function findDraw() { + if (!findEl) return; + var body = findEl._body, q = String(findQ || '').trim(), r = findRes, out = []; + body.innerHTML = ''; + if (!q) { + out.push(E('div', { 'class': 'zm-find-empty' }, [ + E('b', {}, 'Какой сайт или IP нужен?'), + E('span', {}, 'Впишите сайт или IPv4-адрес — покажем, какой пункт включить, чтобы он шёл через туннель.') + ])); + } else if (findBusy || !r) { + out.push(E('div', { 'class': 'zm-find-empty' }, [ E('span', { 'class': 'zm-find-spin', 'aria-hidden': 'true' }), E('span', {}, 'Ищем в списках…') ])); + } else if (r.error) { + out.push(E('div', { 'class': 'zm-find-empty zm-find-err' }, [ E('span', {}, String(r.error)) ])); + } else { + var sel = findSel(), hits = (r.hits || []).filter(function(h) { return h.id !== 'custom'; }); + /* по адресу ищем, только если по домену ничего нет: сайты за Cloudflare и другими CDN делят адреса + * с тысячами чужих, и включать ради них «Cloudflare» — значит пустить через туннель всё подряд */ + var domOnly = hits.filter(function(h) { return h.by === 'dom'; }); + if (domOnly.length) hits = domOnly; + var WIDE_IDS = { cloudflare: 1, c_itdoginfo_cloudflare: 1, cloudfront: 1, c_itdoginfo_cloudfront: 1, digitalocean: 1, hetzner: 1, ovh: 1, discord: 1 }; + function findWide(h) { var m = /\/(\d+)$/.exec(String(h.line || '')); return h.by === 'ip' && (!!WIDE_IDS[h.id] || (m && +m[1] <= 16)); } + var inCustom = (r.hits || []).some(function(h) { return h.id === 'custom'; }); + hits.sort(function(a, b) { return (a.by === b.by ? 0 : a.by === 'dom' ? -1 : 1) || ((+a.size || 0) - (+b.size || 0)); }); + var best = null; + /* советуем самый маленький список с этим доменом; по адресу — только если по домену нет ничего */ + hits.forEach(function(h) { if (findWide(h)) return; if (!best || (best.by !== 'dom' && h.by === 'dom') || (best.by === h.by && (+h.size || 0) < (+best.size || 0))) best = h; }); + var anyOn = hits.some(function(h) { return !!sel[h.id] || (!!sel.russia_inside && h.id !== 'russia_inside' && !h.group && zm.riCovers(h.id)); }); + var isIp = !!r.ip && !r.host; + if (hits.length) { + out.push(E('div', { 'class': 'zm-find-title' }, isIp + ? (anyOn ? 'Уже включено — адрес ' + r.ip + ' идёт через туннель' : 'Адрес ' + r.ip + ' есть в подсетях — включите любой пункт') + : (anyOn ? 'Уже включено — сайт идёт через туннель' : 'Включите любой — сайт пойдёт через туннель'))); + var listEl = E('div', { 'class': 'zm-find-list' }); + hits.forEach(function(h) { + var on = !!sel[h.id], inc = !on && !!sel.russia_inside && h.id !== 'russia_inside' && !h.group && zm.riCovers(h.id); + var size = +h.size || 0; + var why = (h.by === 'dom' ? 'в списке есть ' + h.line : findWide(h) ? (isIp ? 'общая подсеть ' + h.line : 'общий адрес ' + (r.ip || '') + ' (' + h.line + ')') + ' — через туннель пойдут и другие сайты' : (isIp ? 'в подсети ' + h.line : 'адрес ' + (r.ip || '') + ' в подсети ' + h.line)) + + (h.group ? ' · ' + String(h.group).replace(/\s*\(.*\)\s*$/, '') : '') + (size > 20000 ? ' · большой список' : ''); + var ic = zm.svcIco ? zm.svcIco(h.name, h.id) : null; + listEl.appendChild(E('div', { 'class': 'zm-find-row' + (on || inc ? ' zm-on' : ''), 'role': 'button', 'tabindex': '0', + 'click': function() { findToggle(h.id); }, + 'keydown': function(ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); findToggle(h.id); } } }, [ + E('div', { 'class': 'zm-svc-ico', 'style': ic ? 'background:' + ic.color : '' }, ic ? ic.ico : String(h.name || '?').slice(0, 2).toUpperCase()), + E('div', { 'class': 'zm-find-text' }, [ + E('div', { 'class': 'zm-find-name' }, [ String(h.name), best === h && hits.length > 1 && !anyOn ? E('span', { 'class': 'zm-find-best' }, 'лучший выбор') : '' ]), + E('div', { 'class': 'zm-find-why' }, inc ? 'входит во «Всё сразу» — уже идёт через туннель' : why) + ]), + E('span', { 'class': 'zm-find-tag zm-find-tag-' + (h.by === 'dom' ? 'dom' : 'ip') }, h.by === 'dom' ? 'домен' : 'IP-адрес'), + E('div', { 'class': 'zm-switch' + (on || inc ? ' zm-switch-on' : '') + (inc ? ' zm-switch-inc' : '') }, [ E('span') ]) + ])); + }); + out.push(listEl); + } else { + out.push(E('div', { 'class': 'zm-find-empty' }, isIp ? [ + E('b', {}, 'Адреса ' + r.ip + ' нет в подсетях списков'), + E('span', {}, 'Пункты из одних доменов по IP не проверить — попробуйте найти сайт по имени или добавьте адрес в «Свой список».') + ] : [ + E('b', {}, 'Сайта нет ни в одном списке'), + E('span', {}, r.host ? 'Добавьте ' + r.host + ' в «Свой список» — он пойдёт через туннель.' : 'Добавьте адрес в «Свой список» — он пойдёт через туннель.') + ])); + } + if ((parseInt(r.missing, 10) || 0) > 0) + out.push(E('div', { 'class': 'zm-find-note' }, [ E('span', { 'class': 'zm-find-spin', 'aria-hidden': 'true' }), + 'Докачиваем ещё ' + r.missing + ' ' + plural(+r.missing, 'список', 'списка', 'списков') + ' — результаты обновятся сами' ])); + if (domSvc('custom')) out.push(E('div', { 'class': 'zm-find-foot' }, [ + E('button', { 'type': 'button', 'class': 'cbi-button' + (inCustom ? '' : ' cbi-button-action'), 'disabled': inCustom || findAdding || busy ? '' : null, + 'click': function() { findAddCustom(r.host || r.q || q); } }, + inCustom ? 'Уже в «Своём списке»' : findAdding ? 'Добавляем…' : 'Добавить в «Свой список»') + ])); + } + out.forEach(function(n) { body.appendChild(n); }); + } + document.addEventListener('keydown', function(ev) { + if (!(ev.ctrlKey || ev.metaKey) || ev.altKey || ev.shiftKey || String(ev.key).toLowerCase() !== 'k' && ev.code !== 'KeyK') return; + if (!listCard || !document.body.contains(listCard) || listCard.style.display === 'none' || data.blocker) return; + ev.preventDefault(); + if (findEl) findClose(); else findOpen(); + }); + function catalogEdit() { var c = data.catalog || {}; var u = prompt('Ссылка на свой каталог списков (lists.json в формате splify2-lists, например из форка).\n\nПусто — каталог по умолчанию.', c.custom ? c.url : ''); @@ -23449,7 +24606,7 @@ return view.extend({ setTimeout(refresh, 1200); zm.catalogWatch(c, refresh); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); - } }, 'Обновить')); + } }, 'Обновить каталог')); if (!c.off) b.push(E('button', { 'class': 'cbi-button', 'click': catalogEdit }, 'Сменить источник')); b.push(E('button', { 'class': 'cbi-button', 'click': function() { var turnOn = !!c.off; @@ -23468,27 +24625,34 @@ return view.extend({ listCard.innerHTML = ''; listCard.style.display = data.blocker ? 'none' : ''; if (data.blocker) return; - listCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Нажмите на пункт, чтобы включить или выключить его, и затем «Сохранить и применить». Списки берутся из каталога списков (itdoginfo/allow-domains, b4geoip и другие) и обновляются при каждом применении.')); var cur = currentPick(), sel = pick || cur, changed = false; var all = data.services || []; var list = all.filter(function(s) { return s.id !== 'custom'; }).map(function(s) { if (!/^c_itdoginfo_/.test(s.id) && !/^itdoginfo/i.test(s.group || '')) return s; - return { id: s.id, name: s.name, group: '', cat: true, on: s.on, skip: s.skip }; + return { id: s.id, name: s.name, group: '', cat: true, on: s.on, skip: s.skip, dom: s.dom }; }); list.forEach(function(s) { if (!!sel[s.id] !== !!cur[s.id]) changed = true; }); + domCards = {}; function tile(s) { - return zm.svcCard({ name: s.name, key: s.id, on: !!sel[s.id], inc: !!sel.russia_inside && s.id !== 'russia_inside' && !s.group && zm.riCovers(s.id), click: function() { + var el = zm.svcCard({ name: s.name, key: s.id, on: !!sel[s.id], inc: !!sel.russia_inside && s.id !== 'russia_inside' && !s.group && zm.riCovers(s.id), extra: domIcon(s), click: function() { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } pick = {}; for (var k in sel) if (sel[k] && k !== 'custom') pick[k] = true; zm.svcPickToggle(pick, s.id); renderLists(); } }); + if (s.dom) domCards[s.id] = el; + return el; } var remote = list.filter(function(s) { return !!s.group; }); var svcs = list.filter(function(s) { return !s.group && !s.cat && CATEGORY_IDS.indexOf(s.id) < 0; }); var cats = list.filter(function(s) { return !s.group && (s.cat || CATEGORY_IDS.indexOf(s.id) >= 0); }); - listCard.appendChild(E('h4', { 'style': 'margin:0' }, 'Сервисы')); + listCard.appendChild(E('div', { 'class': 'zm-find-head' }, [ + E('h4', { 'style': 'margin:0' }, 'Сервисы'), + E('button', { 'type': 'button', 'class': 'zm-find-open', 'title': 'Найти сайт или IP-адрес в списках (Ctrl+K)', 'aria-label': 'Найти сайт или IP-адрес в списках', 'click': findOpen }, [ + findSvg('zm-find-ico', FIND_LOUPE), 'Найти сайт или IP' + ]) + ])); listCard.appendChild(zm.svcGrid(svcs.map(tile))); if (cats.length) { listCard.appendChild(E('h4', { 'style': 'margin:18px 0 0' }, 'Категории')); @@ -23520,7 +24684,7 @@ return view.extend({ listCard.appendChild(E('div', { 'class': 'zm-lists-now' }, [ E('div', { 'class': 'zm-lists-now-text' }, [ E('b', {}, 'Списки сервисов'), - E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, canRun ? 'Скачиваются заново при каждом применении. Можно обновить сейчас — в окне вывода будет видно, какой список скачался, что применилось и что всё работает.' : 'Скачаются, когда Steer включён, выбраны сервисы и подключён WARP или VPN.') + E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, canRun ? 'Обновляются при каждом применении. Можно обновить сейчас.' : 'Скачаются, когда Steer включён, выбраны сервисы и подключён WARP или VPN.') ]), E('button', { 'class': 'cbi-button cbi-button-action', 'disabled': !canRun || busy || changed ? '' : null, 'title': changed ? 'Сначала сохраните изменения' : '', 'click': function() { act('apply', '', 'Скачиваем и применяем списки'); @@ -23546,6 +24710,9 @@ return view.extend({ }); bar.set(changed, false); listCard.appendChild(bar); + if (domOpen && !domCards[domOpen]) domOpen = null; + domPlace(); + if (findEl) findDraw(); } function loadCustom() { @@ -23926,12 +25093,14 @@ return view.extend({ var x = o[i]; o[i] = o[j]; o[j] = x; act('warp_pick', 'order:' + o.join(','), 'Меняем порядок туннелей'); } - var deadN = tl.filter(function(t) { return !tunnelLive(t); }).length; + /* туннель через российский узел работает, но в дело не идёт — чинится так же, как упавший */ + function tunnelBad(t) { return !tunnelLive(t) || !!t.ru; } + var deadN = tl.filter(tunnelBad).length; var partial = deadN > 0 && deadN < tl.length; if (tl.length) { tl.forEach(function(t) { var live = tunnelLive(t); - var fix = partial && !live ? E('span', { 'style': 'display:inline-flex; gap:6px; margin-left:auto' }, [ + var fix = partial && tunnelBad(t) ? E('span', { 'style': 'display:inline-flex; gap:6px; margin-left:auto' }, [ E('button', { 'class': 'cbi-button cbi-button-action', 'disabled': busy ? '' : null, 'click': function() { act('warp_fix', String(t.n), 'Переделываем WARP ' + t.n); } }, 'Переделать'), E('button', { 'class': 'cbi-button', 'disabled': busy ? '' : null, 'click': function() { if (!confirm('Получить новые ключи для WARP ' + t.n + '?\n\nОстальные туннели не тронутся.')) return; @@ -23957,8 +25126,8 @@ return view.extend({ move, st, cur, - E('span', {}, info.join(' · ')), - fix + fix, + info.length ? E('span', { 'style': 'flex:1 1 100%; order:1; min-width:0; overflow-wrap:anywhere' }, info.join(' · ')) : '' ])); }); } else { @@ -23970,7 +25139,7 @@ return view.extend({ warpCard.appendChild(row('Получено / отправлено', E('span', {}, zm.fmtSize(+data.warp_rx || 0) + ' / ' + zm.fmtSize(+data.warp_tx || 0)))); } if (partial) { - warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Не работает ' + (deadN === 1 ? 'один туннель' : 'часть туннелей') + ' — переделайте только ' + (deadN === 1 ? 'его' : 'их') + ': «Переделать» подберёт новую точку входа, «Новые ключи» — новый аккаунт WARP. Работающие туннели не тронутся.')); + warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Не работает ' + (deadN === 1 ? 'один туннель' : 'часть туннелей') + ' (упал или попал в российский узел) — переделайте только ' + (deadN === 1 ? 'его' : 'их') + ': «Переделать» подберёт новую зарубежную точку входа, «Новые ключи» — новый аккаунт WARP. Работающие туннели не тронутся.')); return; } warpCard.appendChild(E('div', { 'class': 'zm-actions' }, [ @@ -24963,6 +26132,372 @@ return view.extend({ ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/hosts.js' +mkdir -p /www/luci-static/resources/view/zapret-manager +chmod 0755 /www/luci-static/resources/view/zapret-manager +cat > '/www/luci-static/resources/view/zapret-manager/adblock.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require view'; +'require zapret-manager.common as zm'; +var E = (function(raw) { return function() { var a = Array.prototype.slice.call(arguments), i = a.length - 1; if (i >= 1 && (typeof a[i] === 'string' || typeof a[i] === 'number')) a[i] = [ String(a[i]) ]; return raw.apply(null, a); }; })(window.E); + +var LISTS = [ + { id: 'light', name: 'Light', approx: 55, lvl: 1, text: 'Только самая явная реклама и трекеры. Почти ничего не ломает — всё работает как раньше, просто чище.' }, + { id: 'multi', name: 'Multi', approx: 159, lvl: 2, text: 'Обычная защита: реклама, трекеры, счётчики, телеметрия и известные вредоносные сайты.' }, + { id: 'pro', name: 'Pro', approx: 195, lvl: 3, rec: true, text: 'Заметно строже Multi, а сайты почти не ломаются. Лучший выбор для большинства.' }, + { id: 'proplus', name: 'Pro++', approx: 208, lvl: 4, text: 'Ещё строже. Иногда перестают открываться ссылки из рекламы и редкие сервисы — их можно вернуть белым списком.' }, + { id: 'ultimate', name: 'Ultimate', approx: 231, lvl: 5, text: 'Максимум. Понадобится белый список: часть сайтов и приложений может работать не полностью.' } +]; +var LIST_BY_ID = {}; +LISTS.forEach(function(l) { LIST_BY_ID[l.id] = l; }); + +function num(n) { return String(Math.round(+n || 0)).replace(/\B(?=(\d{3})+(?!\d))/g, ' '); } +function badge(cls, text) { return E('span', { 'class': 'zm-badge ' + cls }, [ E('span', { 'class': 'zm-dot' }), text ]); } +function mb(kb) { return Math.max(0, Math.round((+kb || 0) / 1024)); } +function two(n) { return (n < 10 ? '0' : '') + n; } + +function when(ts, now) { + if (!ts) return '—'; + var d = new Date(ts * 1000), n = new Date((now || ts) * 1000); + var t = two(d.getHours()) + ':' + two(d.getMinutes()); + var day = function(x) { return new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime(); }; + var diff = Math.round((day(n) - day(d)) / 86400000); + if (diff === 0) return 'сегодня в ' + t; + if (diff === 1) return 'вчера в ' + t; + return two(d.getDate()) + '.' + two(d.getMonth() + 1) + ' в ' + t; +} + +return view.extend({ + load: function() { + zm.injectCss(); + return zm.adblockStatus().catch(function() { return { error: 'роутер не ответил' }; }); + }, + + render: function(data) { + data = data || {}; + var wrap = E('div', { 'class': 'zm-wrap' }); + var heroCard = E('div', { 'class': 'zm-card zm-ab-hero' }); + var levelCard = E('div', { 'class': 'zm-card' }); + var allowCard = E('div', { 'class': 'zm-card' }); + var checkCard = E('div', { 'class': 'zm-card' }); + var logEl = E('pre', { 'class': 'zm-log' }); + var busy = false, pick = null; + + function cur() { return { list: LIST_BY_ID[data.list] ? data.list : 'pro', tif: !!data.tif, doh: !!data.doh }; } + function sel() { return pick || cur(); } + function changed() { + if (!pick || !data.on) return []; + var c = cur(), out = []; + if (pick.list !== c.list) out.push('список ' + LIST_BY_ID[c.list].name + ' → ' + LIST_BY_ID[pick.list].name); + if (pick.tif !== c.tif) out.push(pick.tif ? 'защита от фишинга включается' : 'защита от фишинга выключается'); + if (pick.doh !== c.doh) out.push(pick.doh ? 'запрет чужих DoH включается' : 'запрет чужих DoH выключается'); + return out; + } + function modeStr(s) { return 'list=' + s.list + ' tif=' + (s.tif ? 1 : 0) + ' doh=' + (s.doh ? 1 : 0) + ' auto=' + (data.auto === false ? 0 : 1); } + function edit(fn) { + if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } + var s = sel(), p = { list: s.list, tif: s.tif, doh: s.doh }; + fn(p); + pick = p; + var c = cur(); + if (data.on && p.list === c.list && p.tif === c.tif && p.doh === c.doh) pick = null; + renderHero(); renderLevel(); + } + + var bar = zm.saveBar({ + saveLabel: 'Применить', + onCancel: function() { pick = null; renderLevel(); }, + onSave: function() { var s = sel(); job('apply', modeStr(s), 'Применяем новый список — ход работы виден в окне вывода'); } + }); + + function refresh() { + return zm.adblockStatus().then(function(d) { + data = d || {}; + if (!busy && data.job) { follow(); return; } + if (data.on) pick = changed().length ? pick : null; + renderAll(); + }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); + } + + function follow() { + busy = true; + renderAll(); + zm.pollJob('adblock', logEl, function(ok) { + busy = false; + pick = null; + zm.toast(ok ? 'Готово — реклама блокируется для всех устройств в сети' : 'Не получилось — подробности в окне вывода', ok ? 'info' : 'error'); + refresh(); + }); + } + + function job(action, mode, text) { + if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } + busy = true; renderAll(); + zm.adblockAction(action, mode || '').then(function(res) { + if (res.error) { busy = false; renderAll(); zm.toast(res.error, 'error'); return; } + zm.toast(text, 'warning'); + follow(); + }).catch(function() { busy = false; renderAll(); zm.toast('Роутер не ответил', 'error'); }); + } + + function turnOff() { + zm.dialog({ + title: 'Выключить блокировку рекламы?', danger: true, okText: 'Выключить', + blocks: [ { type: 'list', title: 'Что произойдёт', items: [ + 'Реклама и трекеры снова будут загружаться на всех устройствах', + 'Скачанные списки удалятся с роутера; белый список и настройки останутся', + 'dnsmasq перезапустится — на пару секунд пропадут ответы DNS' + ] } ] + }).then(function(v) { + if (!v) return; + busy = true; renderAll(); + zm.adblockAction('off', '').then(function(res) { + busy = false; + if (res.error) zm.toast(res.error, 'error'); else zm.toast('Блокировка рекламы выключена', 'info'); + pick = null; + refresh(); + }).catch(function() { busy = false; zm.toast('Роутер не ответил', 'error'); refresh(); }); + }); + } + + function btn(cls, text, fn) { + return E('button', { 'class': 'cbi-button ' + cls, 'disabled': busy ? '' : null, 'click': fn }, text); + } + + function listTitle(s) { + return LIST_BY_ID[s.list].name + (s.tif ? ' + защита от фишинга' : '') + (s.doh ? ' + запрет чужих DoH' : ''); + } + + function renderHero() { + heroCard.innerHTML = ''; + var st = busy || data.job ? badge('zm-warn', data.on ? 'обновляется…' : 'включается…') + : data.on && data.applied ? badge('zm-ok', 'работает') + : data.on ? badge('zm-bad', 'список не подключён') + : badge('zm-off', 'выключена'); + heroCard.appendChild(E('div', { 'class': 'zm-ab-head' }, [ E('h3', {}, 'Блокировка рекламы'), st ])); + heroCard.appendChild(E('p', { 'class': 'zm-hint zm-ab-lead' }, 'Реклама, трекеры и счётчики отсекаются прямо в DNS роутера — сразу для всех устройств в сети: телефонов, телевизоров, приставок, без программ на них. Списки HaGeZi обновляются сами каждый день.')); + + if (data.error) { + heroCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show zm-block-banner' }, [ E('span', {}, 'Не удалось узнать состояние: ' + data.error) ])); + return; + } + + if (!data.on) { + heroCard.appendChild(E('div', { 'class': 'zm-ab-chips' }, [ 'Реклама в сайтах и приложениях', 'Трекеры и счётчики', 'Телеметрия', 'Фишинг и вирусы — по желанию' ].map(function(t, i) { + return E('span', { 'class': 'zm-chip' + (i < 3 ? ' zm-chip-ok' : '') }, [ E('span', { 'class': 'zm-dot' }), t ]); + }))); + heroCard.appendChild(E('div', { 'class': 'zm-actions zm-ab-actions' }, [ + btn('cbi-button-positive', 'Включить: ' + listTitle(sel()), function() { job('apply', modeStr(sel()), 'Скачиваем списки и включаем блокировку — ход работы виден в окне вывода'); }), + E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Уровень строгости можно выбрать ниже') + ])); + return; + } + + if (data.err && data.errat >= (data.updated || 0)) { + heroCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show zm-block-banner' }, [ E('span', {}, + 'Последнее обновление не удалось (' + when(data.errat, data.now) + '): ' + data.err + '. ' + (data.applied ? 'Пока работает прежний список.' : 'Нажмите «Обновить сейчас».')) ])); + } else if (!data.applied && !busy && !data.job) { + heroCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show zm-block-banner' }, [ E('span', {}, 'Список сейчас не подключён к dnsmasq — так бывает сразу после перезагрузки роутера. Через пару минут панель подключит его сама, или нажмите «Обновить сейчас».') ])); + } + + var c = cur(); + heroCard.appendChild(E('div', { 'class': 'zm-sub-facts zm-ab-facts' }, [ + E('div', { 'class': 'zm-sub-fact' }, [ E('span', {}, 'Список'), E('b', {}, listTitle(c)) ]), + E('div', { 'class': 'zm-sub-fact' }, [ E('span', {}, 'Доменов в блокировке'), E('b', {}, data.count ? num(data.count) : '—') ]), + E('div', { 'class': 'zm-sub-fact' }, [ E('span', {}, 'Обновлено'), E('b', {}, when(data.updated, data.now)) ]), + E('div', { 'class': 'zm-sub-fact' }, [ E('span', {}, 'Автообновление'), E('b', {}, data.auto ? 'каждый день в 04:' + two(+data.minute || 0) : 'выключено') ]) + ])); + + heroCard.appendChild(E('div', { 'class': 'zm-actions zm-ab-actions' }, [ + btn('cbi-button-action', busy ? 'Обновляем…' : 'Обновить сейчас', function() { job('update', '', 'Скачиваем свежие списки — ход работы виден в окне вывода'); }), + btn('cbi-button-remove', 'Выключить', turnOff) + ])); + } + + function pips(n) { + var out = []; + for (var i = 1; i <= 5; i++) out.push(E('i', { 'class': i <= n ? 'zm-on' : '' })); + return E('span', { 'class': 'zm-ab-pips', 'title': 'Строгость: ' + n + ' из 5', 'aria-label': 'Строгость: ' + n + ' из 5' }, out); + } + + function renderLevel() { + levelCard.innerHTML = ''; + var s = sel(), l = LIST_BY_ID[s.list], src = data.src || {}; + levelCard.appendChild(E('h3', {}, 'Насколько строго')); + levelCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Чем строже список, тем больше рекламы и слежки он режет — и тем чаще может понадобиться белый список.')); + + var seg = E('div', { 'class': 'zm-seg zm-ab-seg', 'role': 'radiogroup', 'aria-label': 'Список HaGeZi' }); + LISTS.forEach(function(it) { + var on = it.id === s.list; + var choose = function() { if (!on) edit(function(p) { p.list = it.id; }); }; + seg.appendChild(E('div', { + 'class': 'zm-seg-item' + (on ? ' zm-active' : ''), 'role': 'radio', 'tabindex': '0', 'aria-checked': on ? 'true' : 'false', + 'click': choose, + 'keydown': function(ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); choose(); } } + }, it.name)); + }); + levelCard.appendChild(seg); + + var n = src[l.id] && src[l.id].n ? num(src[l.id].n) + ' доменов' : '≈ ' + l.approx + ' тыс. доменов'; + levelCard.appendChild(E('div', { 'class': 'zm-ab-desc' }, [ + E('div', { 'class': 'zm-ab-desc-head' }, [ + E('b', {}, l.name), + pips(l.lvl), + l.rec ? badge('zm-ok', 'рекомендуем') : E([]), + E('span', { 'class': 'zm-ab-desc-n' }, n) + ]), + E('div', { 'class': 'zm-hint' }, l.text) + ])); + + var tifN = src.tif && src.tif.n ? num(src.tif.n) : '≈ 240 тыс.'; + levelCard.appendChild(zm.swRow(s.tif, 'Защита от фишинга и вирусов', + 'Добавляет список TIF mini: ещё ' + tifN + ' доменов мошенников, фишинга и вредоносных сайтов. Нужна свободная память роутера.', + function() { edit(function(p) { p.tif = !p.tif; }); }, busy)); + levelCard.appendChild(zm.swRow(s.doh, 'Не давать устройствам обходить блокировку', + E('span', {}, [ 'Закрывает чужие серверы зашифрованного DNS (DoH/DoT): браузеры и приложения перестанут спрашивать адреса в обход роутера. DNS over HTTPS самого роутера не затрагивается. ', + E('span', { 'class': 'zm-ab-warn' }, 'Если на телефоне вручную задан «Частный DNS» (например, dns.google), выключите его — иначе на телефоне пропадёт интернет.') ]), + function() { edit(function(p) { p.doh = !p.doh; }); }, busy)); + var autoOn = data.auto !== false; + levelCard.appendChild(zm.swRow(autoOn, 'Обновлять списки каждый день', + (data.minute !== '' && data.minute != null ? 'В 04:' + two(+data.minute || 0) + ' по времени роутера. ' : 'Ночью, по времени роутера. ') + 'После перезагрузки роутера список подключается сам.', + function() { + if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } + zm.adblockAction('auto', autoOn ? '0' : '1').then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return; } + data.auto = !!res.auto; data.minute = res.minute; + zm.toast(res.auto ? 'Списки будут обновляться каждый день' : 'Автообновление выключено', 'info'); + renderHero(); renderLevel(); + }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); + }, busy)); + + var ch = changed(); + levelCard.appendChild(bar); + bar.set(ch.length > 0, false, ch.length ? 'Изменения: ' + ch.join(', ') : ''); + } + + /* ---------- Белый список ---------- */ + var allowTa = E('textarea', { 'class': 'zm-config-editor zm-ab-editor', 'spellcheck': 'false', 'autocapitalize': 'off', 'rows': '9', 'aria-label': 'Белый список' }); + allowTa.value = data.allow || ''; + var allowBar = zm.editorBar(allowTa, { + saveLabel: 'Сохранить', + onSave: function(v) { + zm.toast(data.on && data.applied ? 'Сохраняем и пересобираем список' : 'Сохраняем белый список', 'warning'); + return zm.adblockAction('allow_set', v).then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return false; } + zm.toast('Белый список сохранён' + (res.count ? ' — в блокировке ' + num(res.count) + ' доменов' : ''), 'info'); + if (res.count) data.count = res.count; + data.allow = v; + renderHero(); + return true; + }).catch(function() { zm.toast('Роутер не ответил', 'error'); return false; }); + } + }); + + function allowAdd(dom) { + if (allowBar.dirty()) { zm.toast('Сначала сохраните или отмените правки белого списка', 'warning'); return; } + var lines = allowTa.value.split('\n').map(function(x) { return x.trim().toLowerCase(); }); + if (lines.indexOf(dom) >= 0) { zm.toast(dom + ' уже в белом списке', 'info'); return; } + var v = allowTa.value.replace(/\s+$/, '') + (allowTa.value.trim() ? '\n' : '') + dom + '\n'; + zm.toast('Добавляем ' + dom + ' в белый список', 'warning'); + zm.adblockAction('allow_set', v).then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return; } + allowBar.reset(v); + data.allow = v; + if (res.count) data.count = res.count; + zm.toast(dom + ' больше не блокируется', 'info'); + renderHero(); + runCheck(dom); + }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); + } + + function renderAllow() { + allowCard.innerHTML = ''; + allowCard.appendChild(E('h3', {}, 'Белый список')); + allowCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Эти домены не блокируются никогда — вместе со всеми поддоменами. По одному в строке, после # — комментарий.')); + allowCard.appendChild(allowTa); + allowCard.appendChild(E('div', { 'class': 'zm-actions' }, [ + E('button', { 'class': 'cbi-button', 'click': function() { + if (allowTa.value === (data.allow_default || '')) { zm.toast('Уже стоит список по умолчанию', 'info'); return; } + allowTa.value = data.allow_default || ''; + allowTa.dispatchEvent(new Event('input')); + zm.toast('Подставлен список по умолчанию — нажмите «Сохранить»', 'info'); + } }, 'Вернуть по умолчанию') + ])); + allowCard.appendChild(E('p', { 'class': 'zm-hint' }, 'По умолчанию здесь ссылки, без которых не открываются переходы из поисковой рекламы, писем и кешбэк-сервисов.')); + allowCard.appendChild(allowBar); + } + + /* ---------- Проверка домена ---------- */ + var chkInp = E('input', { 'type': 'text', 'class': 'cbi-input-text zm-route-input', 'spellcheck': 'false', 'autocapitalize': 'off', 'autocomplete': 'off', 'inputmode': 'url', 'placeholder': 'например, doubleclick.net', 'aria-label': 'Домен для проверки' }); + var chkBtn = E('button', { 'type': 'button', 'class': 'cbi-button cbi-button-action', 'click': function() { runCheck(); } }, 'Проверить'); + var chkOut = E('div', { 'class': 'zm-route-out', 'aria-live': 'polite' }); + var chkBusy = false; + chkInp.addEventListener('keydown', function(ev) { if (ev.key === 'Enter') { ev.preventDefault(); runCheck(); } }); + + function chkRes(tone, cls, label, what, lines, note) { + var box = E('div', { 'class': 'zm-route-res zm-route-' + tone }, [ + E('div', { 'class': 'zm-route-head' }, [ badge(cls, label), E('span', { 'class': 'zm-route-what' }, what) ]) + ]); + (lines || []).forEach(function(l) { box.appendChild(E('div', { 'class': 'zm-route-line' }, [ E('span', { 'class': 'zm-route-k' }, l[0]), E('span', { 'class': 'zm-route-v' }, l[1]) ])); }); + if (note) box.appendChild(E('div', { 'class': 'zm-route-note' }, note)); + chkOut.innerHTML = ''; + chkOut.appendChild(box); + } + + function runCheck(v) { + v = String(v != null ? v : chkInp.value).trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/[\/?#:].*$/, '').toLowerCase(); + if (chkBusy) return; + if (!v) { zm.toast('Введите домен, например doubleclick.net', 'warning'); chkInp.focus(); return; } + chkInp.value = v; + chkBusy = true; chkBtn.disabled = true; chkBtn.textContent = 'Ищем…'; + chkOut.innerHTML = ''; + chkOut.appendChild(E('div', { 'class': 'zm-hint zm-route-wait' }, 'Ищем ' + v + ' в списках…')); + zm.adblockAction('check', v).then(function(r) { + r = r || {}; + var d = E('b', {}, r.domain || v); + if (r.error) chkRes('bad', 'zm-bad', 'ошибка', r.error); + else if (r.state === 'off') chkRes('off', 'zm-off', 'выключено', [ d, ' — блокировка выключена, домен открывается' ]); + else if (r.state === 'clean') chkRes('ok', 'zm-ok', 'не блокируется', [ d, ' — его нет в выбранных списках' ], null, + E('span', { 'class': 'zm-hint' }, 'Если сайт всё равно не открывается — дело не в блокировке рекламы.')); + else if (r.state === 'allowed') chkRes('ok', 'zm-ok', 'разрешён', [ d, ' — есть в списке ' + r.source + ', но стоит в белом списке' ], + [ [ 'Правило списка', r.by ], [ 'Белый список', r.allow ] ]); + else chkRes('bad', 'zm-bad', 'блокируется', [ d, ' — есть в списке ' + r.source ], + [ [ 'Правило', r.by === r.domain ? r.by : r.by + ' и все его поддомены' ] ], + E('div', { 'class': 'zm-actions', 'style': 'margin:8px 0 0' }, [ + E('button', { 'class': 'cbi-button', 'click': function() { allowAdd(r.by); } }, 'Разблокировать ' + r.by), + E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'если из-за этого не работает нужный сайт') + ])); + }).catch(function() { chkRes('bad', 'zm-bad', 'ошибка', 'роутер не ответил'); }).then(function() { + chkBusy = false; chkBtn.disabled = false; chkBtn.textContent = 'Проверить'; + }); + } + + function renderCheck() { + checkCard.innerHTML = ''; + checkCard.appendChild(E('h3', {}, 'Проверить домен')); + checkCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Сайт или приложение перестали работать? Проверьте, не режет ли их список, и разблокируйте одной кнопкой.')); + var chips = E('div', { 'class': 'zm-route-chips' }); + [ 'doubleclick.net', 'mc.yandex.ru', 'an.yandex.ru', 'googlesyndication.com' ].forEach(function(x) { + chips.appendChild(E('button', { 'type': 'button', 'class': 'zm-route-chip', 'click': function() { runCheck(x); } }, x)); + }); + checkCard.appendChild(E('div', { 'class': 'zm-route' }, [ E('div', { 'class': 'zm-route-form' }, [ chkInp, chkBtn ]), chips, chkOut ])); + } + + function renderAll() { renderHero(); renderLevel(); } + + wrap.appendChild(heroCard); + wrap.appendChild(logEl); + wrap.appendChild(levelCard); + wrap.appendChild(E('div', { 'class': 'zm-cards zm-cards-2 zm-ab-pair' }, [ allowCard, checkCard ])); + renderAll(); + renderAllow(); + renderCheck(); + if (data.job) follow(); + return wrap; + } +}); +ZM_INSTALLER_EOF +chmod 0644 '/www/luci-static/resources/view/zapret-manager/adblock.js' + mkdir -p /www/luci-static/resources/view/zapret-manager chmod 0755 /www/luci-static/resources/view/zapret-manager cat > '/www/luci-static/resources/view/zapret-manager/mixomo.js' << 'ZM_INSTALLER_EOF' @@ -27808,6 +29343,85 @@ html.zm-theme-dark .zm-seg { background: #22272e; border-color: rgba(255,255,255 .zm-svc-group { width: 100%; } .zm-svc-chev { display: inline-block; font-size: 20px; line-height: 1; opacity: .55; transition: transform .15s; } .zm-svc-open .zm-svc-chev { transform: rotate(90deg); } +.zm-svc-dom { flex-shrink: 0; box-sizing: border-box; width: 28px; height: 28px; min-width: 0; min-height: 0; margin: 0; padding: 0; line-height: 1; box-shadow: none; -webkit-appearance: none; appearance: none; display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; + border-radius: 9px; border: 1px solid rgba(110,118,129,.32); background: transparent; color: inherit; cursor: pointer; transition: background .15s, border-color .15s, color .15s; } +.zm-svc-dom > i { display: block; width: 11px; height: 2px; border-radius: 2px; background: currentColor; opacity: .8; } +.zm-svc-dom:hover { border-color: #1aa3ff; } +.zm-svc-dom:focus-visible { outline: 2px solid #1aa3ff; outline-offset: 2px; } +.zm-svc-dom.zm-active { background: #1aa3ff; border-color: #1aa3ff; color: #fff; } +.zm-dom-panel { grid-column: 1 / -1; min-width: 0; padding: 14px 16px; border-radius: 12px; border: 1px solid rgba(26,163,255,.45); background: rgba(26,163,255,.05); animation: zmDomIn .16s ease-out; } +.zm-dom-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } +.zm-dom-title { display: flex; flex-direction: column; gap: 2px; flex: 1 1 220px; min-width: 0; } +.zm-dom-title b { font-size: 14px; } +.zm-dom-sub { font-size: 12px; opacity: .65; } +.zm-dom-btns { display: flex; gap: 8px; flex-wrap: wrap; } +.zm-dom-btns .cbi-button { margin: 0; } +.zm-dom-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); column-gap: 24px; margin-top: 12px; padding: 6px 14px; max-height: 360px; overflow: auto; + border-radius: 10px; border: 1px solid rgba(110,118,129,.18); background: var(--background-color-medium, #fff); } +html.zm-theme-dark .zm-dom-list { background: #1b2027; } +.zm-dom-item { min-width: 0; padding: 5px 0; border-bottom: 1px solid rgba(110,118,129,.12); font-family: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace; font-size: 12.5px; overflow-wrap: anywhere; } +.zm-dom-more { margin: 10px 0 0; font-size: 12px; } +@keyframes zmDomIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } } +@media (prefers-reduced-motion: reduce) { .zm-dom-panel { animation: none; } } +.zm-find-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } +.zm-find-head > h4 { flex: 1 1 auto; } +.zm-find-open { display: inline-flex; align-items: center; gap: 8px; box-sizing: border-box; height: 38px; margin: 0; padding: 0 14px 0 12px; border-radius: 11px; border: 1px solid rgba(110,118,129,.3); + background: transparent; color: inherit; font: inherit; font-weight: 700; font-size: 13.5px; line-height: 1; white-space: nowrap; flex-shrink: 0; cursor: pointer; box-shadow: none; -webkit-appearance: none; appearance: none; transition: border-color .15s, background .15s; } +.zm-find-open:hover { border-color: #1aa3ff; } +.zm-find-open:focus-visible { outline: 2px solid #1aa3ff; outline-offset: 2px; } +.zm-find-ico, .zm-find-x-ico { display: inline-flex; flex-shrink: 0; width: 17px; height: 17px; } +.zm-find-ico > svg, .zm-find-x-ico > svg { display: block; width: 100%; height: 100%; } +.zm-find-open .zm-find-ico { color: #6d4cff; } +.zm-find-x { flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 36px; height: 36px; margin: 0; padding: 0; border: 0; border-radius: 10px; + background: rgba(110,118,129,.12); color: inherit; cursor: pointer; box-shadow: none; -webkit-appearance: none; appearance: none; transition: background .15s, transform .15s; } +.zm-find-x:hover { background: rgba(110,118,129,.22); } +.zm-find-x:focus-visible { outline: 2px solid #6d4cff; outline-offset: 2px; } +.zm-find-x .zm-find-x-ico { width: 16px; height: 16px; opacity: .8; } +.zm-find-wrap { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: flex-start; justify-content: center; padding: 9vh 16px 16px; box-sizing: border-box; + background: rgba(15,23,42,.45); -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px); animation: zmFindFade .15s ease-out; } +.zm-find { width: 100%; max-width: 640px; max-height: 82vh; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box; border-radius: 22px; border: 1px solid rgba(124,92,255,.38); + background: var(--background-color-high, #fff); color: var(--text-color-highest, #0f172a); box-shadow: 0 30px 80px -20px rgba(15,23,42,.55); animation: zmFindIn .18s ease-out; } +html.zm-theme-dark .zm-find { background: #1c2128; color: #e6edf3; } +.zm-find-field { flex-shrink: 0; display: flex; align-items: center; gap: 14px; padding: 0 18px 0 22px; height: 66px; border-bottom: 1px solid rgba(110,118,129,.18); } +.zm-find-field > .zm-find-ico { width: 21px; height: 21px; color: #6d4cff; } +.zm-find-input, .zm-find-input:focus { flex: 1; min-width: 0; height: 44px; margin: 0; padding: 0; border: 0 !important; outline: none; box-shadow: none !important; background: transparent !important; color: inherit; + font-family: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace; font-size: 19px; } +.zm-find-body { overflow: auto; padding: 14px 14px 16px; } +.zm-find-title { font-size: 11.5px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; opacity: .6; padding: 2px 10px 8px; } +.zm-find-list { display: flex; flex-direction: column; gap: 4px; } +.zm-find-row { display: flex; align-items: center; gap: 13px; padding: 11px 12px; border-radius: 14px; cursor: pointer; border: 1px solid transparent; transition: background .12s, border-color .12s; } +.zm-find-row:hover, .zm-find-row:focus-visible { background: rgba(124,92,255,.07); border-color: rgba(124,92,255,.25); outline: none; } +.zm-find-row.zm-on { background: rgba(26,163,255,.08); border-color: rgba(26,163,255,.35); } +.zm-find-row.zm-find-taken { opacity: .6; } +.zm-find-row .zm-svc-ico { width: 36px; height: 36px; } +.zm-find-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.zm-find-name { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-weight: 700; font-size: 14.5px; } +.zm-find-why { font-size: 12.5px; opacity: .7; overflow-wrap: anywhere; } +.zm-find-best { font-size: 11px; font-weight: 800; padding: 2px 8px; border-radius: 999px; color: #fff; background: linear-gradient(135deg, #7c5cff 0%, #3b82f6 100%); } +.zm-find-tag { flex-shrink: 0; font-size: 11.5px; font-weight: 700; padding: 4px 9px; border-radius: 999px; } +.zm-find-tag-dom { background: rgba(124,92,255,.13); color: #5b21b6; } +.zm-find-tag-ip { background: rgba(14,165,233,.14); color: #075985; } +html.zm-theme-dark .zm-find-tag-dom { color: #c4b5fd; } html.zm-theme-dark .zm-find-tag-ip { color: #7dd3fc; } +.zm-find-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; padding: 26px 16px; text-align: center; font-size: 13.5px; } +.zm-find-empty > span { opacity: .7; } +.zm-find-empty > b { font-size: 15px; } +.zm-find-err > span { color: #cf222e; opacity: 1; } +.zm-find-note { display: flex; align-items: center; gap: 9px; margin: 10px 10px 0; font-size: 12.5px; opacity: .75; } +.zm-find-foot { display: flex; margin-top: 14px; padding: 14px 10px 0; border-top: 1px solid rgba(110,118,129,.16); } +.zm-find-foot .cbi-button { flex: 1; margin: 0; min-height: 44px; font-weight: 700; } +.zm-find-spin { display: inline-block; flex-shrink: 0; width: 14px; height: 14px; border-radius: 50%; border: 2px solid rgba(124,92,255,.25); border-top-color: #7c5cff; animation: zmFindSpin .8s linear infinite; } +.zm-find-empty > .zm-find-spin { width: 20px; height: 20px; opacity: 1; } +@keyframes zmFindFade { from { opacity: 0; } to { opacity: 1; } } +@keyframes zmFindIn { from { opacity: 0; transform: translateY(-8px) scale(.985); } to { opacity: 1; transform: none; } } +@keyframes zmFindSpin { to { transform: rotate(360deg); } } +@media (max-width: 600px) { + .zm-find-wrap { padding: 12px 10px; } + .zm-find { max-height: calc(100vh - 24px); border-radius: 18px; } + .zm-find-field { height: 58px; padding: 0 12px 0 16px; } + .zm-find-input { font-size: 16px; } + .zm-find-tag { display: none; } +} +@media (prefers-reduced-motion: reduce) { .zm-find-wrap, .zm-find { animation: none; } .zm-find-spin { animation: zmFindSpin .9s linear infinite !important; } } .zm-st-stats.zm-st-stats-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } .zm-st-stats.zm-fk-secs { grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr)); } .zm-fk-sec { cursor: pointer; transition: border-color .15s, transform .1s; } @@ -28097,6 +29711,30 @@ html.zm-theme-dark .zm-fold { border-top-color: rgba(255,255,255,.1); } html.zm-theme-dark .zm-dlg-note-warn, html.zm-theme-dark .zm-hint.zm-dlg-warn { color: #e3b341; } html.zm-theme-dark .zm-dlg-note-bad { color: #ff7b72; } @media (max-width: 600px) { .zm-dlg-actions .cbi-button { flex: 1 1 auto; } } +/* Блокировка рекламы */ +.zm-ab-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 0 0 10px; } +.zm-ab-head h3 { margin: 0 !important; } +.zm-ab-head > .zm-badge { margin-left: auto; } +.zm-ab-lead { margin: 0; max-width: 760px; } +.zm-ab-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 14px 0 2px; } +.zm-ab-hero .zm-refresh-banner { margin: 12px 0 0; } +.zm-ab-facts { margin: 16px 0 0; gap: 14px 36px; } +.zm-ab-facts .zm-sub-fact > b { font-size: 15px; } +.zm-ab-actions { margin: 16px 0 0; } +.zm-ab-seg { display: flex; flex-wrap: wrap; width: max-content; max-width: 100%; box-sizing: border-box; } +.zm-ab-seg .zm-seg-item { user-select: none; } +.zm-ab-seg .zm-seg-item:focus-visible { outline: 2px solid #1aa3ff; outline-offset: 1px; } +.zm-ab-desc { margin: 12px 0 6px; padding: 12px 14px; border-radius: 12px; border: 1px solid rgba(127,127,127,.22); background: rgba(127,127,127,.05); max-width: 760px; box-sizing: border-box; } +.zm-ab-desc-head { display: flex; align-items: center; gap: 8px 10px; flex-wrap: wrap; font-size: 14px; } +.zm-ab-desc-n { margin-left: auto; font-size: 12px; opacity: .65; font-variant-numeric: tabular-nums; } +.zm-ab-desc .zm-hint { margin-top: 6px; } +.zm-ab-pips { display: inline-flex; gap: 3px; } +.zm-ab-pips > i { display: block; width: 14px; height: 5px; border-radius: 3px; background: rgba(127,127,127,.28); } +.zm-ab-pips > i.zm-on { background: #1a7f37; } +.zm-ab-warn { color: #9a6700; } +html.zm-theme-dark .zm-ab-warn { color: #e3b341; } +.zm-config-editor.zm-ab-editor { min-height: 190px; height: 190px; resize: vertical; } +@media (max-width: 600px) { .zm-ab-seg { width: 100%; } .zm-ab-seg .zm-seg-item { flex: 1 1 auto; text-align: center; padding: 6px 10px; } .zm-ab-desc-n { margin-left: 0; width: 100%; } } ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/style.css' @@ -30586,7 +32224,7 @@ return view.extend({ setTimeout(function() { reload(); }, 1200); zm.catalogWatch(c, function() { reload(); }); }); - } }, 'Обновить')); + } }, 'Обновить каталог')); if (!c.off) b.push(E('button', { 'class': 'cbi-button', 'click': function() { var u = prompt('Ссылка на свой каталог списков (lists.json в формате splify2-lists, например из форка).\n\nПусто — каталог по умолчанию.', c.custom ? c.url : ''); if (u === null) return; @@ -30635,24 +32273,340 @@ return view.extend({ function riAbove() { takenMap(); return takenMemo.ri; } + /* Домены списка пункта и «Найти сайт» — как на странице Steer: списки те же (каталог и списки + * сервисов), поэтому домены и поиск берутся у той же части роутера (steer_action svc_dom / svc_find). + * Включение — через карточку секции (те же правила «одна секция на сервис»), применение — «Сохранить». */ + var domOpen = null, domData = {}, domCards = {}, domPanelEl = null, domFlags = null, domFlagsBusy = false, DOM_SHOW = 500, DOM_PART = 4000; + function domItem(id) { + var r = null; + (draft.items || []).forEach(function(x) { if (x.id === id) r = x; }); + return r; + } + function domWord(n) { return plural(n, 'домен', 'домена', 'доменов'); } + function domNum(n) { return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ' '); } + function domFlagsLoad() { + if (domFlags || domFlagsBusy) return; + domFlagsBusy = true; + zm.steerAction('svc_domflags', '').then(function(r) { + domFlagsBusy = false; + if (!r || r.error) return; + domFlags = r; + if (document.body.contains(svcCard)) renderSvc(); + }).catch(function() { domFlagsBusy = false; }); + } + function domIcon(s) { + if (!domFlags || !domFlags[s.id]) return null; + var b = E('button', { 'type': 'button', 'class': 'zm-svc-dom' + (domOpen === s.id ? ' zm-active' : ''), 'title': 'Домены списка', + 'aria-label': 'Домены списка «' + s.name + '»', 'click': function(ev) { ev.stopPropagation(); ev.preventDefault(); domToggle(s.id); } }, + [ E('i'), E('i'), E('i') ]); + return b; + } + function domLoad(id) { + zm.steerAction('svc_dom', id + ':0:' + DOM_SHOW).then(function(r) { + if (r.error) { domData[id] = { error: String(r.error) }; domPlace(); return; } + var n = parseInt(r.count, 10) || 0; + domData[id] = { count: n, ips: parseInt(r.ips, 10) || 0, src: String(r.src || ''), list: String(r.domains || '').split(/\s+/).filter(function(x) { return x; }) }; + if (!n) { + var it = domItem(id); + if (domFlags) domFlags[id] = false; + if (domOpen === id) domOpen = null; + zm.toast('В списке «' + (it ? it.name : id) + '» только IP-подсети' + (domData[id].ips ? ' (' + domNum(domData[id].ips) + ')' : '') + ' — доменов нет', 'info'); + renderSvc(); + return; + } + domPlace(); + }).catch(function() { domData[id] = { error: 'Роутер не ответил' }; domPlace(); }); + } + function domToggle(id) { + domOpen = domOpen === id ? null : id; + if (domOpen && domData[domOpen] && domData[domOpen].error) delete domData[domOpen]; + domPlace(); + } + function domDownload(id) { + var d = domData[id]; + if (!d || d.dl) return; + d.dl = true; domPlace(); + var parts = []; + function done(err) { d.dl = false; domPlace(); if (err) zm.toast(err, 'error'); } + /* домены, затем IP-подсети — частями, как читает роутер; в файле — два раздела с заголовками «#», + * их понимают и «Свой список», и большинство программ для списков */ + var ips = [], ipTotal = 0; + function save() { + var name = ((domItem(id) || {}).name || id), head = '# ' + name + ': ' + domNum(d.count) + ' ' + domWord(d.count) + (ipTotal ? ', ' + domNum(ipTotal) + ' ' + plural(ipTotal, 'IP-подсеть', 'IP-подсети', 'IP-подсетей') : ''); + var text = head + '\n\n# Домены (вместе с поддоменами)\n' + parts.join('\n') + '\n' + (ips.length ? '\n# IP-подсети\n' + ips.join('\n') + '\n' : ''); + var url = URL.createObjectURL(new Blob([ text ], { type: 'text/plain;charset=utf-8' })); + var a = E('a', { 'href': url, 'download': (ips.length ? 'list-' : 'domains-') + id + '.txt', 'style': 'display:none' }); + document.body.appendChild(a); + a.click(); + setTimeout(function() { URL.revokeObjectURL(url); if (a.parentNode) a.parentNode.removeChild(a); }, 1500); + done(); + } + function nextIp(off) { + zm.steerAction('svc_dom', id + ':' + off + ':' + DOM_PART + ':ip').then(function(r) { + if (r.error) { done(String(r.error)); return; } + var list = String(r.domains || '').split(/\s+/).filter(function(x) { return x; }); + if (list.length) ips = ips.concat(list); + ipTotal = parseInt(r.ips, 10) || 0; + if (list.length && off + DOM_PART < ipTotal) { nextIp(off + DOM_PART); return; } + ipTotal = ips.length; + save(); + }).catch(function() { done('Роутер не ответил'); }); + } + function next(off) { + zm.steerAction('svc_dom', id + ':' + off + ':' + DOM_PART).then(function(r) { + if (r.error) { done(String(r.error)); return; } + var list = String(r.domains || '').split(/\s+/).filter(function(x) { return x; }); + if (list.length) parts.push(list.join('\n')); + var total = parseInt(r.count, 10) || 0; + if (list.length && off + DOM_PART < total) { next(off + DOM_PART); return; } + if ((parseInt(r.ips, 10) || 0) > 0) nextIp(0); else save(); + }).catch(function() { done('Роутер не ответил'); }); + } + next(0); + } + function domPanel(id) { + var d = domData[id], it = domItem(id) || { name: id }, body = []; + var sub = !d ? 'загружаем…' : d.error ? 'не удалось получить список' + : domNum(d.count) + ' ' + domWord(d.count) + (d.ips ? ' · ' + domNum(d.ips) + ' ' + plural(d.ips, 'IP-подсеть', 'IP-подсети', 'IP-подсетей') : ''); + var subTip = d && !d.error ? 'Домены действуют вместе с поддоменами' + (d.ips ? '; IP-подсети — адреса и диапазоны, которые идут тем же путём' : '') + (d.src ? '. Источник: ' + d.src : '') : ''; + var btns = []; + if (d && d.count) btns.push(E('button', { 'type': 'button', 'class': 'cbi-button', 'title': d.ips ? 'Домены и IP-подсети одним файлом .txt' : 'Все домены списка файлом .txt', 'disabled': d.dl ? '' : null, 'click': function() { domDownload(id); } }, d.dl ? 'Скачиваем…' : d.ips ? 'Скачать список доменов и IP' : 'Скачать список доменов')); + btns.push(E('button', { 'type': 'button', 'class': 'cbi-button', 'click': function() { domToggle(id); } }, 'Свернуть')); + if (!d) body.push(E('p', { 'class': 'zm-hint zm-dom-more' }, 'Читаем список с роутера…')); + else if (d.error) body.push(E('div', { 'class': 'zm-refresh-banner zm-show', 'style': 'margin-top:12px' }, d.error)); + else { + body.push(E('div', { 'class': 'zm-dom-list' }, d.list.map(function(x) { return E('div', { 'class': 'zm-dom-item' }, x); }))); + if (d.count > d.list.length) body.push(E('p', { 'class': 'zm-hint zm-dom-more' }, 'Показаны первые ' + domNum(d.list.length) + ' из ' + domNum(d.count) + '. Весь список' + (d.ips ? ' вместе с IP-подсетями' : '') + ' — по кнопке «' + (d.ips ? 'Скачать список доменов и IP' : 'Скачать список доменов') + '».')); + } + return E('div', { 'class': 'zm-dom-panel' }, [ + E('div', { 'class': 'zm-dom-head' }, [ + E('div', { 'class': 'zm-dom-title' }, [ E('b', {}, 'Домены · ' + it.name), E('span', { 'class': 'zm-dom-sub', 'title': subTip || null }, sub) ]), + E('div', { 'class': 'zm-dom-btns' }, btns) + ]) + ].concat(body)); + } + function domPlace() { + if (domPanelEl && domPanelEl.parentNode) domPanelEl.parentNode.removeChild(domPanelEl); + domPanelEl = null; + Object.keys(domCards).forEach(function(k) { + var b = domCards[k] && domCards[k].querySelector('.zm-svc-dom'); + if (b) b.classList.toggle('zm-active', k === domOpen); + }); + var card = domOpen && domCards[domOpen]; + if (!card || !card.parentNode || !document.body.contains(card)) return; + if (domData[domOpen] === undefined) { domData[domOpen] = null; domLoad(domOpen); } + domPanelEl = domPanel(domOpen); + var n = card, top = card.offsetTop; + while (n.nextElementSibling && n.nextElementSibling.offsetTop === top) n = n.nextElementSibling; + card.parentNode.insertBefore(domPanelEl, n.nextSibling); + } + var domResizeT = null; + window.addEventListener('resize', function() { + if (!domOpen) return; + clearTimeout(domResizeT); + domResizeT = setTimeout(domPlace, 150); + }); + + var findEl = null, findQ = '', findRes = null, findBusy = false, findT = null, findRetryT = null, findSeq = 0, findTries = 0; + function findSvg(cls, svg) { var el = E('span', { 'class': cls, 'aria-hidden': 'true' }); el.innerHTML = svg; return el; } + var FIND_LOUPE = ''; + var FIND_CROSS = ''; + function findKey(ev) { if (ev.key === 'Escape' && findEl) { ev.preventDefault(); findClose(); } } + function findClose() { + clearTimeout(findT); clearTimeout(findRetryT); + document.removeEventListener('keydown', findKey); + if (findEl && findEl.parentNode) findEl.parentNode.removeChild(findEl); + findEl = null; + } + function findOpen() { + if (findEl) return; + var input = E('input', { 'type': 'text', 'class': 'zm-find-input', 'placeholder': 'Сайт или IP, например fast.com или 1.2.3.4', 'aria-label': 'Сайт или IP-адрес', + 'autocomplete': 'off', 'autocapitalize': 'off', 'spellcheck': 'false', 'enterkeyhint': 'search' }); + input.value = findQ; + var body = E('div', { 'class': 'zm-find-body' }); + var box = E('div', { 'class': 'zm-find zmw-modal', 'role': 'dialog', 'aria-modal': 'true', 'aria-label': 'Найти сайт или IP в списках' }, [ + E('div', { 'class': 'zm-find-field' }, [ + findSvg('zm-find-ico', FIND_LOUPE), + input, + E('button', { 'type': 'button', 'class': 'zm-find-x', 'title': 'Закрыть', 'aria-label': 'Закрыть', 'click': findClose }, [ findSvg('zm-find-x-ico', FIND_CROSS) ]) + ]), + body + ]); + findEl = E('div', { 'class': 'zm-find-wrap', 'click': function(ev) { if (ev.target === findEl) findClose(); } }, [ box ]); + findEl._body = body; + input.addEventListener('input', function() { + findQ = input.value; + clearTimeout(findT); + findT = setTimeout(function() { findRun(false); }, 600); + }); + input.addEventListener('keydown', function(ev) { + if (ev.key === 'Enter') { ev.preventDefault(); clearTimeout(findT); findRun(false); } + }); + document.body.appendChild(findEl); + document.addEventListener('keydown', findKey); + findDraw(); + setTimeout(function() { try { input.focus(); input.select(); } catch (e) {} }, 0); + } + function findRun(auto) { + var q = String(findQ || '').trim(); + clearTimeout(findRetryT); + if (!q) { findSeq++; findRes = null; findBusy = false; findDraw(); return; } + if (!auto) findTries = 0; + var seq = ++findSeq; + if (!auto) { findBusy = true; findDraw(); } + zm.steerAction('svc_find', q).then(function(r) { + if (seq !== findSeq) return; + findBusy = false; + findRes = r || {}; + findDraw(); + if (!findRes.error && (parseInt(findRes.missing, 10) || 0) > 0 && findTries < 8 && findEl) { + findTries++; + findRetryT = setTimeout(function() { if (findEl && seq === findSeq) findRun(true); }, 5000); + } + }).catch(function() { + if (seq !== findSeq) return; + findBusy = false; + findRes = { error: 'Роутер не ответил' }; + findDraw(); + }); + } + /* включение — тем же обработчиком, что у карточки: он и откажет, если пункт уже в другой секции */ + function findToggle(id) { + if (busy || saving) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } + var it = domItem(id); + if (!it) return; + var el = svcTile(it); + if (el && el.click) el.click(); + findDraw(); + } + function findOwnHas(r) { + var h = String(r.host || ''), ip = String(r.ip || ''); + if (h) { + var hit = tokLines(draft.domains).some(function(d) { + d = String(d).toLowerCase().replace(/^(domain:|full:)/, '').replace(/^(\*\.|\.)/, ''); + return d && (h === d || (h.length > d.length && h.slice(-(d.length + 1)) === '.' + d)); + }); + if (hit) return true; + } + return !h && !!ip && tokLines(draft.subnets).indexOf(ip) >= 0; + } + function findAddOwn(r) { + if (busy || saving) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } + var h = String(r.host || ''), ip = String(r.ip || r.q || ''); + if (h) { + var dl = String(draft.domains || '').replace(/\s+$/, ''); + draft.domains = (dl ? dl + '\n' : '') + h; + taDomains.value = draft.domains; + } else { + var sl = String(draft.subnets || '').replace(/\s+$/, ''); + draft.subnets = (sl ? sl + '\n' : '') + ip; + taSubnets.value = draft.subnets; + } + draft.own_on = true; + touch(); + renderPanes(); + zm.toast((h || ip) + ' добавлен в «Свой список» секции «' + secName() + '» — сохраните изменения', 'info'); + findDraw(); + } + function findDraw() { + if (!findEl) return; + var body = findEl._body, q = String(findQ || '').trim(), r = findRes, out = []; + body.innerHTML = ''; + if (!q) { + out.push(E('div', { 'class': 'zm-find-empty' }, [ + E('b', {}, 'Какой сайт или IP нужен?'), + E('span', {}, 'Впишите сайт или IPv4-адрес — покажем, какой пункт включить, чтобы он шёл через VPN.') + ])); + } else if (findBusy || !r) { + out.push(E('div', { 'class': 'zm-find-empty' }, [ E('span', { 'class': 'zm-find-spin', 'aria-hidden': 'true' }), E('span', {}, 'Ищем в списках…') ])); + } else if (r.error) { + out.push(E('div', { 'class': 'zm-find-empty zm-find-err' }, [ E('span', {}, String(r.error)) ])); + } else { + var sel = draft.sel || {}, taken = takenMap(); + var hits = (r.hits || []).filter(function(h) { return h.id !== 'custom' && !!domItem(h.id); }); + /* по адресу ищем, только если по домену ничего нет: сайты за Cloudflare и другими CDN делят адреса + * с тысячами чужих, и включать ради них «Cloudflare» — значит пустить через туннель всё подряд */ + var domOnly = hits.filter(function(h) { return h.by === 'dom'; }); + if (domOnly.length) hits = domOnly; + var WIDE_IDS = { cloudflare: 1, c_itdoginfo_cloudflare: 1, cloudfront: 1, c_itdoginfo_cloudfront: 1, digitalocean: 1, hetzner: 1, ovh: 1, discord: 1 }; + function findWide(h) { var m = /\/(\d+)$/.exec(String(h.line || '')); return h.by === 'ip' && (!!WIDE_IDS[h.id] || (m && +m[1] <= 16)); } + var inOwn = findOwnHas(r), many = secs().length > 1 || draft.sec === 'new'; + hits.sort(function(a, b) { return (a.by === b.by ? 0 : a.by === 'dom' ? -1 : 1) || ((+a.size || 0) - (+b.size || 0)); }); + var best = null; + hits.forEach(function(h) { if (findWide(h)) return; if (!best || (best.by !== 'dom' && h.by === 'dom') || (best.by === h.by && (+h.size || 0) < (+best.size || 0))) best = h; }); + var anyOn = inOwn || hits.some(function(h) { return !!sel[h.id] || (!!sel.russia_inside && h.id !== 'russia_inside' && !h.group && zm.riCovers(h.id)); }); + var isIp = !!r.ip && !r.host; + if (hits.length) { + out.push(E('div', { 'class': 'zm-find-title' }, anyOn ? (isIp ? 'Уже включено — адрес ' + r.ip + ' идёт через VPN' : 'Уже включено — сайт идёт через VPN') + : many ? (isIp ? 'Адрес ' + r.ip + ' есть в подсетях — включите любой пункт в секции «' : 'Включите любой в секции «') + secName() + '»' + : (isIp ? 'Адрес ' + r.ip + ' есть в подсетях — включите любой пункт' : 'Включите любой — сайт пойдёт через VPN'))); + var listEl = E('div', { 'class': 'zm-find-list' }); + hits.forEach(function(h) { + var on = !!sel[h.id], inc = !on && !!sel.russia_inside && h.id !== 'russia_inside' && !h.group && zm.riCovers(h.id); + var by = !on ? taken[h.id] : ''; + var size = +h.size || 0; + var why = by ? 'уже идёт через секцию «' + by + '»' + : inc ? 'входит во «Всё сразу» — уже идёт через VPN' + : (h.by === 'dom' ? 'в списке есть ' + h.line : findWide(h) ? (isIp ? 'общая подсеть ' + h.line : 'общий адрес ' + (r.ip || '') + ' (' + h.line + ')') + ' — через VPN пойдут и другие сайты' : (isIp ? 'в подсети ' + h.line : 'адрес ' + (r.ip || '') + ' в подсети ' + h.line)) + + (h.group ? ' · ' + String(h.group).replace(/\s*\(.*\)\s*$/, '') : '') + (size > 20000 ? ' · большой список' : ''); + var ic = zm.svcIco ? zm.svcIco(h.name, h.id) : null; + listEl.appendChild(E('div', { 'class': 'zm-find-row' + (on || inc ? ' zm-on' : '') + (by ? ' zm-find-taken' : ''), 'role': 'button', 'tabindex': '0', + 'click': function() { findToggle(h.id); }, + 'keydown': function(ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); findToggle(h.id); } } }, [ + E('div', { 'class': 'zm-svc-ico', 'style': ic ? 'background:' + ic.color : '' }, ic ? ic.ico : String(h.name || '?').slice(0, 2).toUpperCase()), + E('div', { 'class': 'zm-find-text' }, [ + E('div', { 'class': 'zm-find-name' }, [ String((domItem(h.id) || h).name), best === h && hits.length > 1 && !anyOn ? E('span', { 'class': 'zm-find-best' }, 'лучший выбор') : '' ]), + E('div', { 'class': 'zm-find-why' }, why) + ]), + E('span', { 'class': 'zm-find-tag zm-find-tag-' + (h.by === 'dom' ? 'dom' : 'ip') }, h.by === 'dom' ? 'домен' : 'IP-адрес'), + E('div', { 'class': 'zm-switch' + (on || inc ? ' zm-switch-on' : '') + (inc ? ' zm-switch-inc' : '') }, [ E('span') ]) + ])); + }); + out.push(listEl); + } else { + out.push(E('div', { 'class': 'zm-find-empty' }, [ + E('b', {}, inOwn ? (isIp ? 'Адрес уже в «Своём списке»' : 'Сайт уже в «Своём списке»') : isIp ? 'Адреса ' + r.ip + ' нет в подсетях списков' : 'Сайта нет ни в одном списке'), + E('span', {}, inOwn ? 'Он идёт через VPN' + (many ? ' секции «' + secName() + '»' : '') + (draft.own_on === false ? ', когда «Свой список» включён' : '') + '.' + : isIp ? 'Пункты из одних доменов по IP не проверить — попробуйте найти сайт по имени или добавьте адрес в «Свой список».' + : 'Добавьте ' + (r.host || 'адрес') + ' в «Свой список» — он пойдёт через VPN.') + ])); + } + if ((parseInt(r.missing, 10) || 0) > 0) + out.push(E('div', { 'class': 'zm-find-note' }, [ E('span', { 'class': 'zm-find-spin', 'aria-hidden': 'true' }), + 'Докачиваем ещё ' + r.missing + ' ' + plural(+r.missing, 'список', 'списка', 'списков') + ' — результаты обновятся сами' ])); + out.push(E('div', { 'class': 'zm-find-foot' }, [ + E('button', { 'type': 'button', 'class': 'cbi-button' + (inOwn ? '' : ' cbi-button-action'), 'disabled': inOwn || busy || saving ? '' : null, + 'click': function() { findAddOwn(r); } }, inOwn ? 'Уже в «Своём списке»' : 'Добавить в «Свой список»') + ])); + } + out.forEach(function(n) { body.appendChild(n); }); + } + document.addEventListener('keydown', function(ev) { + if (!(ev.ctrlKey || ev.metaKey) || ev.altKey || ev.shiftKey || String(ev.key).toLowerCase() !== 'k' && ev.code !== 'KeyK') return; + if (!svcCard || !document.body.contains(svcCard) || svcCard.offsetParent === null) return; + ev.preventDefault(); + if (findEl) findClose(); else findOpen(); + }); + function svcTile(s) { var by = takenMap()[s.id], ra = riAbove(); var shadowed = !by && ra && !s.group && s.id !== 'russia_inside' && zm.riCovers(s.id); - if (shadowed && !draft.sel[s.id]) return zm.svcCard({ name: s.name, key: s.id, on: false, sub: 'входит в «Всё сразу» секции «' + ra + '»', cls: 'zm-svc-taken', + if (shadowed && !draft.sel[s.id]) return domKeep(s, zm.svcCard({ name: s.name, key: s.id, on: false, sub: 'входит в «Всё сразу» секции «' + ra + '»', cls: 'zm-svc-taken', extra: domIcon(s), title: s.name + ' уже идёт через «Всё сразу» секции «' + ra + '»', click: function() { zm.toast('«' + s.name + '» входит в «Всё сразу» секции «' + ra + '». Чтобы пустить «' + s.name + '» здесь, выключите «Всё сразу» там.', 'warning'); - } }); - if (by && !draft.sel[s.id]) return zm.svcCard({ name: s.name, key: s.id, on: false, sub: 'в секции «' + by + '»', cls: 'zm-svc-taken', + } })); + if (by && !draft.sel[s.id]) return domKeep(s, zm.svcCard({ name: s.name, key: s.id, on: false, sub: 'в секции «' + by + '»', cls: 'zm-svc-taken', extra: domIcon(s), title: s.name + ' уже идёт через секцию «' + by + '»', click: function() { zm.toast('«' + s.name + '» уже идёт через секцию «' + by + '». Сначала выключите его там — один сервис работает только в одной секции.', 'warning'); - } }); - return zm.svcCard({ name: s.name, key: s.id, on: !!draft.sel[s.id], sub: by ? 'и в секции «' + by + '» — сработает верхняя' : shadowed ? 'перекрыт «Всё сразу» секции «' + ra + '»' : '', inc: !!draft.sel.russia_inside && s.id !== 'russia_inside' && !s.group && zm.riCovers(s.id), click: function() { + } })); + return domKeep(s, zm.svcCard({ name: s.name, key: s.id, on: !!draft.sel[s.id], sub: by ? 'и в секции «' + by + '» — сработает верхняя' : shadowed ? 'перекрыт «Всё сразу» секции «' + ra + '»' : '', inc: !!draft.sel.russia_inside && s.id !== 'russia_inside' && !s.group && zm.riCovers(s.id), extra: domIcon(s), click: function() { var nsel = {}; for (var k in draft.sel) if (draft.sel[k]) nsel[k] = true; zm.svcPickToggle(nsel, s.id); set('sel', nsel); - } }); + } })); } + function domKeep(s, el) { if (domFlags && domFlags[s.id]) domCards[s.id] = el; return el; } function renderSvc() { svcCard.innerHTML = ''; @@ -30660,11 +32614,15 @@ return view.extend({ var many = secs().length > 1 || draft.sec === 'new'; svcCard.appendChild(E('h3', {}, [ many ? 'Что пускать через «' + secName() + '» ' : 'Что пускать через Forkozz ', n ? badge('zm-ok', 'выбрано ' + n) : badge('zm-off', 'ничего') ])); if (many) svcCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, [ 'Подключение этой секции: ', E('b', {}, draft.mode === 'iface' ? (draft.iface ? (/^zmwarp/.test(draft.iface) ? 'WARP · ' : 'туннель ') + draft.iface : 'туннель не выбран') : draft.mode === 'sub' ? 'подписка' : nn(textLines(draft.links).length, 'сервер', 'сервера', 'серверов')), '. Сервисы, выбранные в других секциях, помечены их названием.' ])); - svcCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Нажмите на пункт, чтобы включить или выключить его, и затем «Сохранить». Списки те же, что в Steer: берутся из каталога списков (itdoginfo/allow-domains, b4geoip и другие) и обновляются сами.')); var remote = list.filter(function(s) { return !!s.group; }); var svcs = list.filter(function(s) { return !s.group && !s.cat && CATEGORY_IDS.indexOf(s.id) < 0; }); var cats = list.filter(function(s) { return !s.group && (s.cat || CATEGORY_IDS.indexOf(s.id) >= 0); }); - svcCard.appendChild(E('h4', { 'style': 'margin:0' }, 'Сервисы')); + domCards = {}; + domFlagsLoad(); + svcCard.appendChild(E('div', { 'class': 'zm-find-head' }, [ + E('h4', { 'style': 'margin:0' }, 'Сервисы'), + E('button', { 'type': 'button', 'class': 'zm-find-open', 'title': 'Найти сайт или IP-адрес в списках (Ctrl+K)', 'aria-label': 'Найти сайт или IP-адрес в списках', 'click': findOpen }, [ findSvg('zm-find-ico', FIND_LOUPE), 'Найти сайт или IP' ]) + ])); svcCard.appendChild(zm.svcGrid(svcs.map(svcTile))); if (cats.length) { svcCard.appendChild(E('h4', { 'style': 'margin:18px 0 0' }, 'Категории')); @@ -30698,6 +32656,9 @@ return view.extend({ svcCard.appendChild(E('p', { 'class': 'zm-hint' }, '⚠ Discord вместе с Cloudflare уводят в Forkozz много лишнего, даже торренты.')); svcCard.appendChild(catalogBlock()); svcCard.appendChild(listsNow()); + if (domOpen && !domCards[domOpen]) domOpen = null; + domPlace(); + if (findEl) findDraw(); var meta = (cfg && cfg.catalog && cfg.catalog.meta) || {}; if (!meta.off && !remote.length && catWait < 4 && (meta.busy || !meta.version)) { catWait++; @@ -30797,7 +32758,7 @@ return view.extend({ } function renderDev() { - devCard(allCard, 'all', 'Весь трафик устройства через VPN · «' + secName() + '»', 'Выбранные устройства целиком ходят через подключение этой секции — любые сайты и приложения, а не только выбранные сервисы. Подходит для телевизора, приставки или телефона, которым VPN нужен всегда. Если подключение секции недоступно, такое устройство остаётся без интернета, а не идёт напрямую. Одно устройство можно отдать только одной секции. Чтобы устройство не потерялось, закрепите за ним IP-адрес в DHCP.'); + devCard(allCard, 'all', 'Весь трафик устройства через VPN · «' + secName() + '»', 'Весь трафик выбранных устройств идёт через подключение этой секции, а не только выбранные сервисы. Если подключение недоступно — интернета у них не будет. Одно устройство — только в одной секции.'); if (!draft.isMain && !draft.enabled) allCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Секция выключена: устройства из этого списка ходят как обычно.')); devCard(fullCard, 'full', 'Только для этих устройств · «' + secName() + '»', 'Ограничение для сервисов секции: если выбрать устройства, её сервисы, домены и адреса пойдут через VPN только с них, остальные устройства — напрямую. Ничего не выбрано — сервисы секции идут через VPN со всех устройств. На список «Весь трафик» выше это не влияет.'); if (!draft.isMain && !draft.enabled) fullCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Секция выключена: её правила не действуют. Выбор устройств в остальных секциях сохраняется.')); @@ -33221,6 +35182,7 @@ var ICONS = { dashboard: '', shield: '', bolt: '', + adblock: '', list: '', globe: '', send: '', @@ -33423,6 +35385,7 @@ var ROUTES = [ { id: 'tgproxy', title: 'TG WS Proxy', sub: 'Прокси для Telegram', icon: 'send', group: 'Обход блокировок', dot: 'tg' }, { id: 'mixomo', title: 'Mixomo', sub: 'Mihomo, MagiTrickle и WARP', icon: 'layers', group: 'Обход блокировок', dot: 'mixomo' }, { id: 'hosts', title: 'Hosts', sub: 'Домены в hosts и списки GeoHide', icon: 'list', group: 'Сеть', dot: 'hosts' }, + { id: 'adblock', title: 'Блокировка рекламы', sub: 'Реклама, трекеры и фишинг — для всех устройств в сети', icon: 'adblock', group: 'Сеть', dot: 'adblock' }, { id: 'doh', title: 'DNS over HTTPS', sub: 'Шифрованный DNS для всей сети', icon: 'globe', group: 'Сеть', dot: 'doh' }, { id: 'awg', title: 'AmneziaWG', sub: 'Туннели AmneziaWG и WARP: установка, ключи, интерфейсы', icon: 'anarchy', group: 'Сеть', dot: 'awg' }, { id: 'terminal', title: 'Терминал', sub: 'Командная строка роутера в браузере (ttyd)', icon: 'terminal', group: 'Сеть', dot: 'term' }, @@ -35439,6 +37402,41 @@ html[data-theme="dark"] .zm-stopbar, html[data-theme="depth"] .zm-stopbar { back #zmw-view .zm-swrow-label { color: var(--text); } #zmw-view .zm-svc.zm-svc-inc { border-style: dashed; } #zmw-view .zm-svc.zm-svc-taken { opacity: .5; cursor: not-allowed; } +#zmw-view .zm-svc-dom { border-color: var(--border-2, var(--border)); color: var(--text-2, var(--text)); } +#zmw-view .zm-svc-dom:hover { border-color: var(--a1); } +#zmw-view .zm-svc-dom:focus-visible { outline-color: var(--a1); } +#zmw-view .zm-svc-dom.zm-active { background: var(--grad); border-color: transparent; color: #fff; } +#zmw-view .zm-dom-panel { background: var(--surface-2); border-color: var(--border-2, var(--border)); border-radius: 14px; } +#zmw-view .zm-dom-title b { color: var(--text); } +#zmw-view .zm-dom-sub, #zmw-view .zm-dom-more { color: var(--muted); opacity: 1; } +#zmw-view .zm-dom-list, html.zm-theme-dark #zmw-view .zm-dom-list { background: var(--surface-solid); border-color: var(--border); } +#zmw-view .zm-dom-item { font-family: var(--mono); color: var(--text); border-bottom-color: var(--border); } +#zmw-view .zm-find-open { border-color: var(--border-2, var(--border)); color: var(--text); } +#zmw-view .zm-find-open:hover { border-color: var(--a1); background: var(--surface-2); } +#zmw-view .zm-find-open .zm-find-ico { color: var(--a1, #6d4cff); } +.zm-find-wrap { background: rgba(5,7,12,.5); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); } +.zm-find { background: var(--surface-solid, #fff); color: var(--text, #0f172a); border: 1px solid rgba(124,92,255,.42); box-shadow: var(--shadow-lg); font-family: var(--font, inherit); } +html.zm-theme-dark .zm-find { background: var(--surface-solid, #1c2128); color: var(--text, #e6edf3); } +.zm-find-field { border-bottom-color: var(--border, rgba(110,118,129,.18)); } +.zm-find-field > .zm-find-ico { color: var(--a1, #6d4cff); } +.zm-find-input, .zm-find-input:focus { font-family: var(--mono); color: var(--text); } +.zm-find-x { background: var(--surface-2); color: var(--text-2, var(--text)); } +.zm-find-x:hover { background: var(--surface-3, var(--surface-2)); } +/* крутилка поиска — индикатор работы, а не украшение: крутится и при «уменьшить движение» */ +.zm-find-spin { animation: zmFindSpin .9s linear infinite !important; } +.zm-find-title, .zm-find-why, .zm-find-empty > span, .zm-find-note { color: var(--muted); opacity: 1; } +.zm-find-row:hover, .zm-find-row:focus-visible { background: var(--surface-2); border-color: var(--border-2, var(--border)); } +.zm-find-row.zm-on { background: var(--grad-soft, rgba(124,92,255,.1)); border-color: rgba(124,92,255,.45); } +.zm-find-best { background: var(--grad); } +.zm-find-foot { border-top-color: var(--border); } +.zm-find .zm-switch { background: var(--border-2, rgba(110,118,129,.35)); } +.zm-find .zm-switch.zm-switch-on { background: var(--grad, #1aa3ff); } +.zm-find .cbi-button { border-radius: 12px; } +.zm-find .cbi-button-action { background: var(--grad); border-color: transparent; color: #fff; } +.zm-find .cbi-button[disabled] { opacity: .55; } +.zmw-modal.zm-find { width: 100%; max-width: 640px; max-height: 82vh; padding: 0; overflow: hidden; border-radius: 22px; border: 1px solid rgba(124,92,255,.42); } +.zmw-modal.zm-find .zm-find-input, .zmw-modal.zm-find .zm-find-input:focus { width: auto; height: 44px; padding: 0; border: 0; border-radius: 0; background: transparent; box-shadow: none; font-size: 19px; } +@media (max-width: 600px) { .zmw-modal.zm-find { max-height: calc(100vh - 24px); border-radius: 18px; } .zmw-modal.zm-find .zm-find-input { font-size: 16px; } } #zmw-view .zm-sb-left, #zmw-view .zm-lists-now { background: var(--surface-2); border-color: var(--border); border-radius: var(--radius-sm); color: var(--text); } #zmw-view .zm-sb-left { background: var(--bad-bg); border-color: rgba(239,68,68,.3); } .zm-stopbar .cbi-button.zm-stopbar-btn { padding: 10px 18px; border-radius: 12px; font-size: 14px; line-height: 1.2; box-shadow: 0 8px 20px -10px rgba(220,38,38,.8); } @@ -35471,6 +37469,11 @@ html[data-theme="dark"] .zm-stopbar, html[data-theme="depth"] .zm-stopbar { back #zmw-view .zm-hide-q { border-color: var(--border); color: var(--text); } #zmw-view .zm-hide-q.zm-hide-q-on { border-color: rgba(239,68,68,.7); background: rgba(239,68,68,.1); color: #ef4444; } +#zmw-view .zm-ab-desc { background: var(--surface-2); border-color: var(--border); border-radius: var(--radius-sm); } +#zmw-view .zm-ab-pips > i { background: var(--surface-3); } +#zmw-view .zm-ab-pips > i.zm-on { background: var(--grad); } +#zmw-view .zm-ab-warn { color: var(--warn); } + ZM_INSTALLER_EOF cat > '/www/zm-webui.html' << 'ZM_INSTALLER_EOF'