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'