diff --git a/ZapretManager_LuCI.sh b/ZapretManager_LuCI.sh index 04366d4..4facd37 100644 --- a/ZapretManager_LuCI.sh +++ b/ZapretManager_LuCI.sh @@ -1,6 +1,6 @@ #!/bin/sh read -r _ _ ZM_NEW_VER <<'ZM_VERSION_EOF' -# Version: 4.34 +# Version: 4.38 ZM_VERSION_EOF set -e @@ -1544,6 +1544,8 @@ zm_watch() { _fk_mlkem_patch _fk_serial_patch _fk_listsvia_patch + _fk_ghdl_patch + _fk_b4_watch fi _st_colo_guard _st_vpn_watch @@ -6944,15 +6946,26 @@ _zo_doh_hosts() { }' } _zo_doh_doms() { local d; d="$(_zo_doh_hosts)"; echo "${d:-$ZO_DOH_DEF}"; } +# Блок DoH по умолчанию. Прежний (маскировка под ya.ru) у тех, кто его не менял, панель сама +# заменяет на новый (под common.dot.dns.yandex.net); изменённый вручную блок не трогаем +ZO_DOH_OLD="--filter-tcp=443 --dpi-desync=hostfakesplit --dpi-desync-hostfakesplit-mod=host=ya.ru --dpi-desync-hostfakesplit-midhost=host-2 --dpi-desync-split-seqovl=726 --dpi-desync-fooling=badsum,badseq --dpi-desync-badseq-increment=0" +ZO_DOH_NEW="--filter-tcp=443 --dpi-desync=hostfakesplit --dpi-desync-hostfakesplit-mod=host=common.dot.dns.yandex.net --dpi-desync-hostfakesplit-midhost=host-2 --dpi-desync-split-seqovl=726 --dpi-desync-fooling=badsum,badseq --dpi-desync-badseq-increment=0" _zo_doh_tpl() { local t t="$(grep '^--' "$ZO_DOH_FLAG" 2>/dev/null | grep -v '^--hostlist-domains=' | grep -vx -- '--new')" - if ! printf '%s\n' "$t" | grep -q '^--filter-tcp=' || ! printf '%s\n' "$t" | grep -q '^--dpi-desync='; then - t="$(printf '%s\n' --filter-tcp=443 --dpi-desync=hostfakesplit --dpi-desync-hostfakesplit-mod=host=ya.ru --dpi-desync-hostfakesplit-midhost=host-2 \ - --dpi-desync-split-seqovl=726 --dpi-desync-fooling=badsum,badseq --dpi-desync-badseq-increment=0)" + if ! printf '%s\n' "$t" | grep -q '^--filter-tcp=' || ! printf '%s\n' "$t" | grep -q '^--dpi-desync=' || [ "$(echo $t)" = "$ZO_DOH_OLD" ]; then + t="$(printf '%s\n' $ZO_DOH_NEW)" fi printf '%s\n' "$t" } +# В стратегии стоит прежний блок DoH по умолчанию — его пора заменить новым +_zo_doh_stale() { + [ "$(_nfq_opt_body | awk -v M="$ZO_DOH_MK" ' + $0 ~ M { c++; d = (c == 1); next } + d && $0 == "--new" { d = 0 } + d && /^[ \t]*--/ && !/^[ \t]*--hostlist-domains=/ { l = $0; gsub(/^[ \t]+|[ \t\r]+$/, "", l); o = o (o == "" ? "" : " ") l } + END { print o }')" = "$ZO_DOH_OLD" ] +} _zo_doh_scan() { _nfq_opt_body | awk -v M="$ZO_DOH_MK" ' NR == 1 { next } @@ -6992,7 +7005,7 @@ _zo_doh_sync() { grep -q "^[[:space:]]*option NFQWS_OPT '[[:space:]]*\$" "$CONF" || return 1 _nochange_strategy && return 1 doms="$(_zo_doh_doms)" - [ "$(ZM_NFQ_BODY=""; _zo_doh_scan)" = "top $doms" ] && return 1 + [ "$(ZM_NFQ_BODY=""; _zo_doh_scan)" = "top $doms" ] && ! (ZM_NFQ_BODY=""; _zo_doh_stale) && return 1 cap="$ZO_DOH_FLAG.cap" rm -f "$cap" _zo_doh_cut "$cap" @@ -11360,6 +11373,32 @@ _doh_cur_id() { p="$(_doh_provider_of "$u")" echo "${p:-$u}" } +# Переход на сервер, через который DNS на роутере так и не заработал (напрямую сервер отвечает, а через +# https-dns-proxy — нет), не повторяем каждые 2 минуты: каждая такая попытка перезапускает DNS роутера и +# рвёт его на 10–15 секунд. Пауза 10 минут, затем 20, 40… до 2 часов; ручной выбор сервера её сбрасывает. +_doh_bad_key() { printf 'bad%s' "$(printf '%s' "$1" | md5sum | cut -c1-10)"; } +_doh_bad_wait() { + local v n w=600 + v="$(_doh_st_get "$(_doh_bad_key "$1")" "")"; n="${v#*:}" + case "$n" in ''|*[!0-9]*) echo 0; return ;; esac + while [ "$n" -gt 1 ] && [ "$w" -lt 7200 ]; do w=$((w * 2)); n=$((n - 1)); done + [ "$w" -gt 7200 ] && w=7200 + echo "$w" +} +_doh_bad_skip() { + local v t w + v="$(_doh_st_get "$(_doh_bad_key "$1")" "")"; [ -n "$v" ] || return 1 + t="${v%%:*}"; w="$(_doh_bad_wait "$1")" + case "$t" in ''|*[!0-9]*) return 1 ;; esac + [ $(( $(date +%s) - t )) -lt "$w" ] +} +_doh_bad_mark() { + local v n + v="$(_doh_st_get "$(_doh_bad_key "$1")" "")"; n="${v#*:}" + case "$n" in ''|*[!0-9]*) n=0 ;; esac + _doh_st_put "$(_doh_bad_key "$1")" "$(date +%s):$((n + 1))" +} +_doh_bad_clear() { [ -f "$DOH_FO_STATE" ] && grep -q "^$(_doh_bad_key "$1")=" "$DOH_FO_STATE" && _doh_st_put "$(_doh_bad_key "$1")" ""; return 0; } _doh_ok() { case "$(_doh_probe "$(_doh_url_of "$1")" "$(_doh_boots_for "$1")" "/tmp/zm-dohfo.$$")" in "ok "*) return 0 ;; esac; return 1; } _doh_cur_alive() { local port @@ -11368,6 +11407,8 @@ _doh_cur_alive() { sleep 2 _doh_live_real "$port" && return 0 _doh_ok "$1" || return 1 + # перезапуск уже не помог два раза подряд — не дёргаем DNS роутера снова, решает переключение + [ "${2:-0}" -lt 2 ] || return 1 _zm_run 30 /etc/init.d/https-dns-proxy restart >/dev/null 2>&1 _doh_wait_real "$port" 10 && { _doh_fo_event "$(_doh_name "$1") отвечает, а DNS на роутере завис — перезапустили https-dns-proxy"; return 0; } return 1 @@ -11402,8 +11443,10 @@ doh_watch() { _doh_fo_put active "$cur" if [ "$mode" = direct ]; then for c in "$pref" $([ "$(_doh_fo_get fo 0)" = 1 ] && _doh_fo_pool "$pref" ""); do + _doh_bad_skip "$c" && continue _doh_ok "$c" || continue - [ "$c" = "$cur" ] || _doh_apply "$c" >/dev/null 2>&1 || continue + [ "$c" = "$cur" ] || _doh_apply "$c" >/dev/null 2>&1 || { _doh_bad_mark "$c"; continue; } + _doh_bad_clear "$c" _doh_fo_put active "$c" /etc/init.d/https-dns-proxy start >/dev/null 2>&1 t=0; while ! _doh_local_ok 5053 && [ "$t" -lt 8 ]; do sleep 1; t=$((t + 1)); done @@ -11425,18 +11468,23 @@ doh_watch() { fi fi fails="$(_doh_st_get fails 0)"; okc="$(_doh_st_get okc 0)" - if [ "$cur" != "$pref" ] && [ "$(_doh_fo_get fo 0)" = 1 ]; then + if [ "$cur" != "$pref" ] && [ "$(_doh_fo_get fo 0)" = 1 ] && ! _doh_bad_skip "$pref"; then if _doh_ok "$pref"; then okc=$((okc + 1)); else okc=0; fi - if [ "$okc" -ge 2 ] && _doh_apply "$pref" >/dev/null 2>&1; then - _doh_fo_put active "$pref" - _doh_st_put mode normal fails 0 okc 0 since "$(date +%s)" - _doh_fo_event "$(_doh_name "$pref") снова отвечает — вернулись на него с $(_doh_name "$cur")" - rmdir "$lk" 2>/dev/null - return 0 + if [ "$okc" -ge 2 ]; then + if _doh_apply "$pref" >/dev/null 2>&1; then + _doh_bad_clear "$pref" + _doh_fo_put active "$pref" + _doh_st_put mode normal fails 0 okc 0 since "$(date +%s)" + _doh_fo_event "$(_doh_name "$pref") снова отвечает — вернулись на него с $(_doh_name "$cur")" + rmdir "$lk" 2>/dev/null + return 0 + fi + _doh_bad_mark "$pref"; okc=0 + _doh_fo_event "$(_doh_name "$pref") отвечает, но DNS роутера через него не заработал — остаёмся на $(_doh_name "$cur"), снова попробуем через $(( $(_doh_bad_wait "$pref") / 60 )) мин" fi _doh_st_put okc "$okc" fi - if /etc/init.d/https-dns-proxy running >/dev/null 2>&1 && _doh_cur_alive "$cur"; then + if /etc/init.d/https-dns-proxy running >/dev/null 2>&1 && _doh_cur_alive "$cur" "$fails"; then _doh_st_put fails 0 rmdir "$lk" 2>/dev/null return 0 @@ -11445,8 +11493,10 @@ doh_watch() { [ "$fails" -ge 2 ] || { rmdir "$lk" 2>/dev/null; return 0; } if [ "$(_doh_fo_get fo 0)" = 1 ]; then for c in $([ "$cur" != "$pref" ] && echo "$pref") $(_doh_fo_pool "$pref" "$cur"); do + _doh_bad_skip "$c" && continue _doh_ok "$c" || continue - _doh_apply "$c" >/dev/null 2>&1 || continue + _doh_apply "$c" >/dev/null 2>&1 || { _doh_bad_mark "$c"; continue; } + _doh_bad_clear "$c" _doh_fo_put active "$c" _doh_st_put mode "$([ "$c" = "$pref" ] && echo normal || echo backup)" fails 0 okc 0 since "$(date +%s)" _doh_fo_event "$(_doh_name "$cur") не отвечает — DNS переключён на $(_doh_name "$c")" @@ -11523,6 +11573,12 @@ doh_failover_set() { /etc/init.d/https-dns-proxy start >/dev/null 2>&1 _doh_fo_event "автопереключение выключено — DNS снова идёт через DoH" fi + # выключили, стоя на запасном: возвращаем основной, если он отвечает, — иначе запасной так и остался бы навсегда + v="$(_doh_cur_id)"; pref="$(_doh_fo_get pref "")" + if [ -n "$v" ] && [ -n "$pref" ] && [ "$v" != "$pref" ] && _doh_ok "$pref" && _doh_apply "$pref" >/dev/null 2>&1; then + _doh_fo_put active "$pref" + _doh_fo_event "автопереключение выключено — вернулись на основной $(_doh_name "$pref")" + fi rm -f "$DOH_FO_STATE" fi _zo_doh_follow @@ -12333,7 +12389,7 @@ stl_apply() { else rm -f "$new" echo "ОШИБКА: ядро steer отвергло настройку" - printf '%s\n' "$err" | tail -n 5 + printf '%s\n' "$err" | tail -n 5 | _st_outs_text return 1 fi fi @@ -12345,7 +12401,7 @@ stl_apply() { if ! err="$(_stl_t 300 steer apply 2>&1 >/dev/null)"; then if [ -s "$prev" ]; then mv -f "$prev" "$STL_SPEC"; else rm -f "$STL_SPEC"; fi echo "ОШИБКА: ядро steer не применило правила — остались прежние" - printf '%s\n' "$err" | tail -n 5 + printf '%s\n' "$err" | tail -n 5 | _st_outs_text return 1 fi rm -f "$prev" @@ -12410,19 +12466,170 @@ stl_diag() { d="$(_stl_t 30 steer diag 2>/dev/null | tr '\n' ' ')" case "$d" in '{'*'}'*) printf '%s' "$d" ;; *) echo null ;; esac } -stl_diag_lines() { - local f="$STL_TMP/stl-diag.json" n i=0 - mkdir -p "$STL_TMP" - _stl_t 30 steer diag > "$f" 2>/dev/null - n="$(jsonfilter -i "$f" -e '@.checks[*].id' 2>/dev/null | wc -l)" +_stl_diag_tsv() { + local n i=0 + n="$(jsonfilter -i "$1" -e '@.checks[*].id' 2>/dev/null | wc -l)" while [ "$i" -lt "$n" ]; do - printf '%s\t%s\t%s\n' "$(jsonfilter -i "$f" -e "@.checks[$i].verdict" 2>/dev/null)" \ - "$(jsonfilter -i "$f" -e "@.checks[$i].what" 2>/dev/null)" "$(jsonfilter -i "$f" -e "@.checks[$i].why" 2>/dev/null)" + printf '%s\t%s\t%s\n' "$(jsonfilter -i "$1" -e "@.checks[$i].verdict" 2>/dev/null)" \ + "$(jsonfilter -i "$1" -e "@.checks[$i].what" 2>/dev/null)" "$(jsonfilter -i "$1" -e "@.checks[$i].why" 2>/dev/null)" i=$((i + 1)) done +} +stl_diag_lines() { + local f="$STL_TMP/stl-diag.json" + mkdir -p "$STL_TMP" + _stl_t 30 steer diag > "$f" 2>/dev/null + _stl_diag_tsv "$f" rm -f "$f" } +# Проверка ядра steer — понятными словами. Ядро называет выходы внутренними именами: zm_vpn — VPN +# секции «Основная», zm_vpn2 и zm_vpn3 — VPN секций 2 и 3, zmv2_vless и т. п. — узлы этих секций, +# zm_if2 — туннель роутера у секции 2, zm_warp — общий WARP. Вместо них пишем имя секции. Поломка +# секции, в которой ничего не выбрано (или которая выключена), — предупреждение: через неё ничего не +# идёт. К отказу узла — коротко, что это значит и что делать. +# Таблица секций: «id подпись состояние(work|idle|off) туннель»; подпись пустая, если секция одна. +_st_diag_map() { + local s many="" st + [ -n "$(_st_secs)" ] && many=1 + for s in main $(_st_secs); do + if [ "$s" = main ]; then + if _st_sec_off main; then st=off; elif _st_sec_with main _st_work_main; then st=work; else st=idle; fi + printf '%s\t%s\t%s\t%s\n' main "${many:+Секция «$(_st_sec_name main)»}" "$st" "$(_st_main_iface)" + else + if _st_sec_off "$s"; then st=off; elif _st_sec_work "$s"; then st=work; else st=idle; fi + printf '%s\t%s\t%s\t%s\n' "$s" "Секция «$(_st_sec_name "$s")»" "$st" "$(_st_sec_iface "$s")" + fi + done +} +# stdin: «verdict what why» от ядра; stdout через \037 (не табуляция: пустые поля не должны слипаться при read): +# «verdict, what, why, что это и что делать, заметка, исходный verdict, исходный what» +ZM_ST_DIAG_AWK=' +function secof(o) { + if (o == "zm_vpn" || o == "zm_if" || o ~ /^zmv_/) return "main" + if (o ~ /^zm_(vpn|if)[2-9]$/) return "s" substr(o, length(o), 1) + if (o ~ /^zmv[2-9]_/) return "s" substr(o, 4, 1) + return "" +} +function tipof(r, c) { + if (r ~ /вместо 101/) { + c = r; sub(/^[^0-9]*/, "", c); c = substr(c, 1, 3) + 0 + if (c == 400 || c == 404) return "Сервер не знает такого адреса подключения — в ссылке неверный path или host" + if (c == 403) return "Сервер или CDN перед ним не пускает — проверьте host и sni в ссылке или возьмите другой узел" + if (c >= 500) return "Сервер узла (или CDN перед ним) не смог принять подключение — обычно в ссылке неверный path или host, реже сбой у поставщика" + return "Сервер не принял подключение WebSocket — проверьте path и host в ссылке" + } + if (r ~ /Upgrade|Sec-WebSocket|кадр WebSocket/) return "По ссылке отвечает не VPN-сервер, а что-то другое (сайт-заглушка, прокси) — проверьте path, host и порт" + if (r ~ /не разрешил/) return "Адрес сервера из ссылки не находится — проверьте адрес в ссылке и DNS роутера" + if (r ~ /TCP не соединился|нет сокета/) return "До сервера не достучаться: он выключен, сменил порт или его блокирует провайдер" + if (r ~ /ClientHello|таймаут|не ответил за|узел не ответил/) return "Сервер молчит — так выглядит блокировка по имени сайта (SNI) или лежащий узел; попробуйте другой узел" + if (r ~ /не признал ключ|UUID|отверг авторизацию|отказал в авторизации|авторизации негоден|pbk или sid/) return "Сервер не принял ключ или пароль из ссылки — ссылка устарела: обновите подписку или ссылку" + if (r ~ /подлинност|сертификат|pinSHA256/) return "Сертификат сервера не прошёл проверку — в ссылке неверный sni, или соединение подменяют" + if (r ~ /ECH/) return "Ключ ECH в ссылке устарел — обновите ссылку или подписку" + if (r ~ /VLESS encryption/) return "Строка encryption в ссылке не совпадает с сервером — обновите ссылку" + if (r ~ /HTTP\/2|HTTP\/1\.1|gRPC/) return "Сервер не поддерживает транспорт из ссылки (ws, grpc, xhttp) — проверьте тип транспорта" + if (r ~ /QUIC не открылся/) return "Подключение по QUIC (UDP) не открылось — проверьте порт и sni в ссылке; иногда провайдер режет UDP" + if (r ~ /соединения не открываются/) return "Узел отвечает на проверку, но настоящие соединения не держит — выберите другой узел" + if (r ~ /закрыл соединение|TLS закрыт|обрыв|поток закрыт|GOAWAY|не прислал данных|ответа нет|ответ не по протоколу|соединение закрыто/) return "Сервер обрывает соединение — узел перегружен или не пускает с этого адреса; попробуйте другой узел" + return "Узел не отвечает — выберите другой узел или проверьте ссылку" +} +BEGIN { + FS = "\t"; OFS = U + while ((getline l < M) > 0) { split(l, a, "\t"); LB[a[1]] = a[2]; ST[a[1]] = a[3]; IF[a[1]] = a[4] } + close(M) + T = "(zm_vpn[2-9]?|zmv[2-9]?_[A-Za-z0-9]+|zm_warp[0-9]*|zmwarp[0-9]*)" +} +{ + v = $1; w = $2; y = $3; v0 = v; tip = ""; note = ""; s = ""; lab = "" + if (w ~ /^выход [A-Za-z0-9_.-]+/) { + o = w; sub(/^выход /, "", o); rest = o; sub(/[: ].*$/, "", o) + rest = substr(rest, length(o) + 1); sub(/^:? */, "", rest) + s = secof(o) + if (s != "") { + k = (o ~ /^zm_if/) ? "туннель" (IF[s] != "" ? " " IF[s] : "") : "VPN" + lab = (LB[s] != "" ? LB[s] " · " : "") k + } else if (o ~ /^zm_warp/) lab = "WARP" + else if (o == "zm_direct") lab = "Мимо Steer" + if (lab != "") { + sub("^устройство " T " в зоне, NAT есть$", "туннель подключён, NAT есть", rest) + sub("^устройство " T " в зоне$", "туннель подключён", rest) + sub("^устройства " T " нет$", "туннель не поднят", rest) + sub("^устройство " T " выключено$", "туннель выключен", rest) + sub("^" T " вне зоны фаервола$", "туннель вне зоны фаервола", rest) + sub("^у " T " нет masquerade", "у туннеля нет masquerade", rest) + sub("^" T " не отвечает", "туннель не отвечает", rest) + cl = "" + if (match(rest, /, трафик канала /)) { + cl = substr(rest, RSTART + RLENGTH); rest = substr(rest, 1, RSTART - 1) + if (cl == "идёт напрямую") cl = "сервисы идут напрямую" + else if (cl == "остановлен") cl = "сервисы остановлены" + else if (cl == "идёт через обход") cl = "сервисы идут через обход блокировок" + } + if (rest ~ /^узел перестал отвечать/) { + rest = "узел не отвечает" + r = y; sub(/; выход вернётся сам, когда узел ответит$/, "", r); sub(/ \(проверьте path и host\)$/, "", r) + if (r == "узел не отвечает") r = "" + y = r; tip = tipof(r) + } else if (rest ~ /^туннель не отвечает/) { + y = "проверьте туннель и его сервер" + tip = "Туннель есть, но трафик через него не проходит — обычно не отвечает сервер туннеля" + } else if (rest == "туннель не поднят") sub(/^туннель не поднят — /, "", y) + if (s != "" && ST[s] != "" && ST[s] != "work") cl = "" + w = lab ": " rest (cl != "" ? ", " cl : "") + } + } else if (w ~ /^канал s[2-9]_/) { + c = w; sub(/^канал /, "", c); s = substr(c, 1, 2); sub(/^s[2-9]_/, "", c); sub(/:.*$/, "", c) + t = w; sub(/^канал s[2-9]_[^:]*/, "", t) + if (LB[s] != "") w = LB[s] " · список «" c "»" t + s = "" + } + if (v == "fail" && s != "" && ST[s] != "" && ST[s] != "work") { + v = "warn" + note = ST[s] == "off" ? "Секция выключена — на работу это сейчас не влияет" : "В секции ничего не выбрано — на работу это сейчас не влияет" + if (LB[s] == "") note = ST[s] == "off" ? "Steer выключен — на работу это сейчас не влияет" : "Сервисы не выбраны — на работу это сейчас не влияет" + } else if (v == "fail" && tip != "" && s != "") tip = tip ". Как только он ответит, всё заработает само" + print v, w, y, tip, note, v0, $2 +}' +_st_diag_human() { + local m="$ST_RUN/diag.map.$$" + mkdir -p "$ST_RUN" + _st_diag_map > "$m" 2>/dev/null + awk -v M="$m" -v U="$(printf '\037')" "$ZM_ST_DIAG_AWK" + rm -f "$m" +} +# Любой текст ядра (ошибки apply, журнал): «выход zm_vpn2» → «Секция «чайна» · VPN» +_st_outs_text() { + local m="$ST_RUN/diag.map.$$" + mkdir -p "$ST_RUN" + _st_diag_map > "$m" 2>/dev/null + awk -v M="$m" ' + function secof(o) { + if (o == "zm_vpn" || o == "zm_if" || o ~ /^zmv_/) return "main" + if (o ~ /^zm_(vpn|if)[2-9]$/) return "s" substr(o, length(o), 1) + if (o ~ /^zmv[2-9]_/) return "s" substr(o, 4, 1) + return "" + } + BEGIN { while ((getline l < M) > 0) { split(l, a, "\t"); LB[a[1]] = a[2]; IF[a[1]] = a[4] } close(M) } + { + out = ""; t = $0 + while (match(t, /выход (zm_vpn[2-9]?|zm_if[2-9]?|zmv[2-9]?_[A-Za-z0-9]+|zm_warp[0-9]*|zm_direct)/)) { + o = substr(t, RSTART, RLENGTH); sub(/^выход /, "", o); s = secof(o) + if (s != "") lab = (LB[s] != "" ? LB[s] " · " : "") ((o ~ /^zm_if/) ? "туннель" (IF[s] != "" ? " " IF[s] : "") : "VPN") + else lab = (o == "zm_direct") ? "Мимо Steer" : "WARP" + out = out substr(t, 1, RSTART - 1) lab; t = substr(t, RSTART + RLENGTH) + } + print out t + }' + rm -f "$m" +} +# Одна строка ядра → понятный текст (для списков «что сломано» перед починкой) +_st_diag_what() { printf 'fail\t%s\t\n' "$1" | _st_diag_human | awk -F"$(printf '\037')" '{ print $2 }'; } +# Для вкладки Steer → «Проверка»: [{"v","what","why","tip","note"}] +_st_diag_hjson() { + _st_diag_human | awk -F"$(printf '\037')" "$ZM_JESC_AWK"' + $1 ~ /^(ok|warn|fail)$/ { printf "%s{\"v\":\"%s\",\"what\":\"%s\",\"why\":\"%s\",\"tip\":\"%s\",\"note\":\"%s\"}", (n++ ? "," : ""), $1, jesc($2), jesc($3), jesc($4), jesc($5) }' +} + _stl_explain_run() { local t="" x="$STL_ETC/.zm-explain.$$.json" if [ -s "$STL_SPEC" ] && grep -q '"scope":"device"' "$STL_SPEC" 2>/dev/null; then @@ -14090,6 +14297,9 @@ _st_tgws_warp() { } ST_LISTS_MANIFEST="https://github.com/xyzmean/splify2-lists/releases/latest/download/lists.json" +# Тот же lists.json каталог кладёт и в ветку main. Берём его оттуда (raw.githubusercontent.com): файлы релизов +# отдаёт release-assets.githubusercontent.com, а он у части провайдеров не отвечает. Не вышло — из релиза +ST_LISTS_MANIFEST_RAW="https://raw.githubusercontent.com/xyzmean/splify2-lists/main/lists.json" ST_CAT_SKIP=" mydyson itdoginfo_meta " ST_SRS_FALLBACK="https://github.com/itdoginfo/allow-domains/releases/latest/download" @@ -14109,6 +14319,28 @@ _st_fetch() { curl -fsSL --interface "$i" --connect-timeout 6 --max-time 60 -o "$2" "$1" 2>/dev/null && [ -s "$2" ] } +# Набор b4geoip из релиза Greeg0ry/b4geoip-forkop → тот же файл из ветки main (srs/ИМЯ.srs); остальное — как есть +_st_b4_raw() { + case "$1" in + https://github.com/[Gg]reeg0ry/b4geoip-forkop/releases/*.srs) echo "https://raw.githubusercontent.com/Greeg0ry/b4geoip-forkop/main/srs/${1##*/}" ;; + *) echo "$1" ;; + esac +} + +# .srs набора: b4geoip — сначала из ветки main, не вышло — из релиза, как раньше. Если в этом обновлении +# не ответили ни raw, ни релиз, следующие наборы b4geoip 20 минут пробуют только raw — без двойного ожидания +_st_srs_dl() { + local u r + u="$(_st_srs_url "$1")"; r="$(_st_b4_raw "$u")" + if [ "$r" != "$u" ]; then + _st_fetch "$r" "$2" && [ "$(head -c 3 "$2")" = SRS ] && return 0 + [ -n "$(find "$ST_RUN/b4.down" -mmin -20 2>/dev/null)" ] && return 1 + fi + _st_fetch "$u" "$2" && [ "$(head -c 3 "$2")" = SRS ] && return 0 + [ "$r" != "$u" ] && { mkdir -p "$ST_RUN"; : > "$ST_RUN/b4.down"; } + return 1 +} + _json_flat() { awk 'BEGIN { RS = "\001" } function emit(v) { p = ""; for (k = 1; k <= d; k++) p = p (k > 1 ? "." : "") key[k]; print p "\t" v; if (arr[d]) key[d]++ } @@ -14212,6 +14444,17 @@ _st_cat_build() { _st_cat_src() { [ -s "$ST_CAT_URL" ] && head -n1 "$ST_CAT_URL" || echo "$ST_LISTS_MANIFEST"; } +# Каталог по умолчанию — сначала из ветки main, затем из релиза; свой каталог — по его ссылке +_st_cat_dl() { + local u + u="$(_st_cat_src)" + if [ "$u" = "$ST_LISTS_MANIFEST" ]; then + _st_fetch "$ST_LISTS_MANIFEST_RAW" "$1" && _st_cat_build "$1" && return 0 + rm -f "$1" + fi + _st_fetch "$u" "$1" && _st_cat_build "$1" +} + _st_cat_refresh() { local m="$ST_RUN/lists.json" lk="$ST_RUN/cat.lock" rc=0 t [ -f "$ST_CAT_OFF" ] && return 0 @@ -14230,7 +14473,7 @@ _st_cat_refresh() { rmdir "$lk" 2>/dev/null; mkdir "$lk" 2>/dev/null || return 0 fi fi - if _st_fetch "$(_st_cat_src)" "$m.tmp" && _st_cat_build "$m.tmp"; then + if _st_cat_dl "$m.tmp"; then mv -f "$m.tmp" "$m" touch "$ST_CAT_SVC" rm -f "$ST_DIR/catalog.err" @@ -14312,7 +14555,7 @@ _st_srs_get() { echo " ✓ $set: скачан, доменов $(wc -l < "$d/$set.dom.tmp" | tr -d ' '), подсетей $(wc -l < "$d/$set.pfx.tmp" | tr -d ' ')" >&2 else if [ ! -f "$ST_RUN/dl.$set" ]; then - if _st_fetch "$(_st_srs_url "$set")" "$f" && [ "$(head -c 3 "$f")" = SRS ]; then + if _st_srs_dl "$set" "$f"; then mv -f "$f" "$d/$set.srs" touch "$ST_RUN/dl.$set" echo " ✓ $set.srs: скачан, $(ls -l "$d/$set.srs" | awk '{ printf "%d КБ", ($5 + 1023) / 1024 }')" >&2 @@ -14802,7 +15045,7 @@ _st_selfcheck0() { if _st_failopen; then if [ "$(_st_onfail)" = drop ]; then echo "[FAIL] Туннель: ядро steer не может пустить через него трафик — выбранные сервисы остановлены" else echo "[FAIL] Туннель: ядро steer не может пустить через него трафик — сервисы идут напрямую, через провайдера"; fi - logread 2>/dev/null | grep -F 'steer[warn]' | tail -n 6 | sed 's/^[^]]*\]: / /' + logread 2>/dev/null | grep -F 'steer[warn]' | tail -n 6 | sed 's/^[^]]*\]: / /' | _st_outs_text bad=1 else echo "[ OK ] Ядро steer снова ведёт трафик в туннель" @@ -14814,12 +15057,11 @@ _st_selfcheck0() { while IFS="$(printf '\t')" read -r v what why; do [ "$v" = fail ] && ! _st_diag_node "$what" && core=1 done < "$f" - if [ -z "$core" ] && [ -z "$moved" ] && grep -q '^fail' "$f"; then - echo " · Ядро steer исправно — не отвечает узел или сервер туннеля; перезапуск и переустановка ядра тут не помогут" - fi + local nodeonly="" + [ -z "$core" ] && [ -z "$moved" ] && _st_diag_human < "$f" | awk -F"$(printf '\037')" '$1 == "fail" { f = 1 } END { exit !f }' && nodeonly=1 if [ -n "$core" ] && [ -z "$ST_HEALING" ] && _st_work && [ ! -f "$ST_OFF" ]; then while IFS="$(printf '\t')" read -r v what why; do - [ "$v" = fail ] && ! _st_diag_node "$what" && echo "!! $what" + [ "$v" = fail ] && ! _st_diag_node "$what" && echo "!! $(_st_diag_what "$what")" done < "$f" ST_HEALING=1 if _st_heal "$f"; then healed=1; fi @@ -14827,19 +15069,29 @@ _st_selfcheck0() { did="$ST_HEAL_DID" _rb_say "Проверяем ещё раз" fi - while IFS="$(printf '\t')" read -r v what why; do + local raw tip note v0 warned="" + _st_diag_human < "$f" > "$f.h" + while IFS="$(printf '\037')" read -r v what why tip note v0 raw; do case "$v" in ok) echo "[ OK ] $what" ;; - warn) echo "!! $what${why:+ — $why}" ;; + warn) + [ -n "$moved" ] && [ "$v0" = fail ] && _st_diag_node "$raw" && continue + echo "!! $what${why:+ — $why}"; warned=1 + [ -n "$tip" ] && echo " · $tip" + [ -n "$note" ] && echo " · $note" ;; fail) - [ -n "$moved" ] && _st_diag_node "$what" && continue + [ -n "$moved" ] && _st_diag_node "$raw" && continue case "$why" in *'init.d/steer'*|*ерезапустите*|*ереустановите*) [ -n "$did" ] && why="$did; пришлите журнал в чат поддержки" ;; esac - echo "[FAIL] $what${why:+ — $why}"; bad=1 ;; + echo "[FAIL] $what${why:+ — $why}"; bad=1 + [ -n "$tip" ] && echo " · $tip" ;; esac - done < "$f" - rm -f "$f" + done < "$f.h" + rm -f "$f" "$f.h" + [ -n "$nodeonly" ] && echo " · Само ядро steer исправно — не отвечает узел или сервер туннеля; перезапуск и переустановка ядра тут не помогут" [ -n "$healed" ] && [ "$bad" = 0 ] && echo " · Поломку ядра панель исправила сама" - if [ "$bad" = 0 ]; then _rb_say "Проверка пройдена"; else _rb_warn "Проверка нашла поломку — подробности выше"; fi + if [ "$bad" != 0 ]; then _rb_warn "Проверка нашла поломку — подробности выше" + elif [ -n "$warned" ]; then _rb_say "Проверка пройдена — работе ничего не мешает, замечания выше" + else _rb_say "Проверка пройдена"; fi return $bad } @@ -17833,8 +18085,17 @@ steer_action() { isep="," done fi - stl_present && _st_owns "steer-spec" && d="$(stl_diag)" - printf '{"warp":"%s","colo":"%s","vpn":"%s","vpn_ip":"%s","vpn_loc":"%s","tunnels":[%s],"ifs":[%s],"diag":%s}\n' "$warp" "$(esc "$colo")" "$vpn" "$(esc "$vip")" "$(esc "$vloc")" "$tun" "$itj" "${d:-null}" + local hj="" + if stl_present && _st_owns "steer-spec"; then + d="$(stl_diag)" + if [ "$d" != null ]; then + mkdir -p "$ST_RUN" + printf '%s\n' "$d" > "$ST_RUN/diag.ui.$$" + hj="$(_stl_diag_tsv "$ST_RUN/diag.ui.$$" | _st_diag_hjson)" + rm -f "$ST_RUN/diag.ui.$$" + fi + fi + printf '{"warp":"%s","colo":"%s","vpn":"%s","vpn_ip":"%s","vpn_loc":"%s","tunnels":[%s],"ifs":[%s],"diag":%s,"human":[%s]}\n' "$warp" "$(esc "$colo")" "$vpn" "$(esc "$vip")" "$(esc "$vloc")" "$tun" "$itj" "${d:-null}" "$hj" ;; dns_fix) do_steer_dns_fix @@ -21405,6 +21666,65 @@ ZM_FK_FAST_EOF return 0 } +# Секции Forkozz со списками b4geoip по ссылкам релизов (сохранены до 4.38 или вернулись из резервной копии): +# переводим их на raw-ссылки и перезапускаем Forkozz — не чаще раза в 30 минут +_fk_b4_watch() { + local f="$JOBS_DIR/fk-b4.last" + grep -qi 'greeg0ry/b4geoip-forkop/releases/' /etc/config/netshift 2>/dev/null || return 0 + [ -n "$(find "$f" -mmin -30 2>/dev/null)" ] && return 0 + _job_running forkop && return 0 + mkdir -p "$JOBS_DIR"; : > "$f" + ( _fk_device_setup apply >/dev/null 2>&1 > "$f" << 'ZM_FK_GHDL_EOF' +# zm-ghdl-begin 1 +# Zapret Manager: списки с GitHub. У части провайдеров файлы релизов GitHub (их отдаёт +# release-assets.githubusercontent.com) не открываются, хотя сам github.com открыт, — и каждый список +# ждал по минуте, а потом не скачивался. Если в панели «Доступ к GitHub» стоит «Авто», такой файл +# качается через зеркало. После первого провала напрямую следующие списки этого обновления сразу идут +# через зеркало, без минуты ожидания на каждом. Через VPN (http_proxy) и не с GitHub — как раньше. +download_to_file() { + local zm_url="$1" zm_fp="$2" zm_m zm_slow=/tmp/netshift.zm-ghslow zm_skip="" + case "$zm_url" in + https://github.com/*|https://raw.githubusercontent.com/*|https://objects.githubusercontent.com/*|https://release-assets.githubusercontent.com/*|https://codeload.github.com/*) ;; + *) zm_download_to_file_raw "$@"; return ;; + esac + [ -n "$3" ] && { zm_download_to_file_raw "$@"; return; } + [ -n "${ZM_GHDL_MS+x}" ] || ZM_GHDL_MS="$(/opt/zapret-manager-luci/backend.sh gh_mirrors 2>/dev/null)" + [ -n "$ZM_GHDL_MS" ] || { zm_download_to_file_raw "$@"; return; } + if [ -n "$(find "$zm_slow" -mmin -30 2>/dev/null)" ]; then zm_skip=1 + else + zm_download_to_file_raw "$@" && return 0 + : > "$zm_slow" + fi + for zm_m in $ZM_GHDL_MS; do + log "Downloading via GitHub mirror $zm_m: $(redact_url_for_log "$zm_url")" "warn" + if zm_download_to_file_raw "https://$zm_m/$zm_url" "$zm_fp" "" 1 1 15 && [ -s "$zm_fp" ] && ! head -c 512 "$zm_fp" | grep -qiE '<(!doctype|html)'; then + log "Downloaded via GitHub mirror $zm_m" "info" + return 0 + fi + done + if [ -n "$zm_skip" ] && zm_download_to_file_raw "$zm_url" "$zm_fp" "" 1 1 10; then rm -f "$zm_slow"; return 0; fi + return 1 +} +# zm-ghdl-end +ZM_FK_GHDL_EOF + return 0 +} + _fk_listsvia_patch() { local f=/usr/bin/netshift tmp [ -f "$f" ] || return 0 @@ -21731,6 +22051,8 @@ _fk_lists_progress() { /Importing community subnet lists for/ { print " → Адреса встроенных сервисов — заносим в файрвол"; next } /Importing domains from URL: / { u = $0; sub(/^.*URL: /, "", u); print " → Домены из списка " base(u); next } /Importing subnets from URL: / { u = $0; sub(/^.*URL: /, "", u); print " → Адреса из списка " base(u) " — заносим в файрвол"; next } + /^Downloading via GitHub mirror / { m = $0; sub(/^Downloading via GitHub mirror /, "", m); sub(/:.*$/, "", m); print " · GitHub не отдал файл напрямую — качаем через зеркало " m; next } + /^Downloaded via GitHub mirror / { m = $0; sub(/^Downloaded via GitHub mirror /, "", m); print " ✓ Скачано через зеркало " m; next } /^Download .* list failed/ { u = $2; print " ✗ Не скачался список " base(u); next } /Failed to decompile binary rule set/ { print " ✗ Набор правил не раскрылся — его адреса пропущены"; next } /Lists update completed successfully/ { print " ✓ Все списки загружены"; next } @@ -21845,6 +22167,12 @@ _fk_lists_errors() { [ -n "$e" ] || return 0 echo "!! Не все списки загрузились:" printf '%s\n' "$e" | sed 's/^/ ✗ /' + case "$e" in *"сервер не ответил вовремя"*|*"сервер недоступен"*) + if [ "$(uci -q get netshift.settings.download_lists_via_proxy)" != 1 ] && [ -z "$(gh_mirrors)" ]; then + echo " · Похоже, провайдер не пускает к файлам GitHub (их отдаёт release-assets.githubusercontent.com). Включите" + echo " «Скачивать списки сервисов через VPN» в настройках Forkozz или «Авто» в Система → Доступ к GitHub и нажмите «Перезапустить»" + fi ;; + esac return 0 } @@ -22046,6 +22374,7 @@ do_fk_install() { _fk_mlkem_patch _fk_serial_patch _fk_listsvia_patch + _fk_ghdl_patch rm -rf "$tmp" if [ ! -s /etc/config/netshift ]; then @@ -22383,6 +22712,7 @@ do_fk_service() { _fk_mlkem_patch _fk_serial_patch _fk_listsvia_patch + _fk_ghdl_patch mkdir -p /etc/sing-box _fk_log_mark : > "$JOBS_DIR/forkop-svc.out" @@ -22453,6 +22783,7 @@ do_fk_lists() { _fk_mlkem_patch _fk_serial_patch _fk_listsvia_patch + _fk_ghdl_patch _fk_log_mark /usr/bin/netshift list_update > "$out" 2>&1 & lp=$! @@ -22538,6 +22869,7 @@ do_fk_subs() { _fk_mlkem_patch _fk_serial_patch _fk_listsvia_patch + _fk_ghdl_patch _zm_quiet "" /usr/bin/netshift subscription_update all || { echo "ОШИБКА: подписка не обновилась — проверьте ссылку"; return 1; } _fk_ext_auto all "$lx" _fk_say "Готово: подписки обновлены" @@ -25011,6 +25343,7 @@ case "$cmd" in ui_flags_get) ui_flags_get ;; ui_flag_set) ui_flag_set "$1" ;; adblock_dns_sync) _ab_dns_sync ;; + zapret_doh_migrate) [ -f "$ZO_DOH_FLAG" ] && [ -f "$CONF" ] && _zo_doh_stale && { _zo_doh_follow; echo '{"changed":true}'; } || echo '{"changed":false}' ;; multi) _zm_multi "$1" ;; *) echo '{"error":"неизвестная команда"}'; exit 1 ;; esac @@ -25051,6 +25384,17 @@ const SRS_MAIN = "https://github.com/itdoginfo/allow-domains/releases/latest/dow const ITD_SUBNETS = { cloudflare: true, cloudfront: true, digitalocean: true, discord: true, google_meet: true, hetzner: true, meta: true, ovh: true, roblox: true, telegram: true, twitter: true }; const CAT_SKIP = { mydyson: true, itdoginfo_meta: true }; +// b4geoip (Greeg0ry/b4geoip-forkop). Файлы его релизов отдаёт release-assets.githubusercontent.com — у части +// провайдеров он не отвечает, и списки не скачивались. Те же .srs каждый день кладутся в ветку main +// репозитория — берём их оттуда, с raw.githubusercontent.com (как и списки itdoginfo). +const B4_RAW = "https://raw.githubusercontent.com/Greeg0ry/b4geoip-forkop/main/srs/"; +// Что внутри наборов: twitch и xbox — только домены, остальные (сборка из geoip.dat) — только подсети. +// Каждый подключается туда, где он работает, и качается один раз. Незнакомый новый набор — как раньше: +// и доменами, и подсетями, так он сработает в любом случае. +const B4_DOMAINS = { twitch: true, xbox: true }; +const B4_SUBNETS = { adobe: true, anthropic: true, apple: true, blizzard: true, bungie: true, ccp: true, electronicarts: true, + epicgames: true, google: true, nintendo: true, play2go: true, riot: true, roblox: true, sony: true, taketwo: true, + ubisoft: true, valve: true, wargaming: true }; const PKG_LOCAL_DIR = "/usr/share/zm-redbtn/svc/"; const V2F_RAW = "https://raw.githubusercontent.com/v2fly/domain-list-community/master/data"; const PKG_LISTS = { @@ -25175,6 +25519,28 @@ function latest_url(u) { return replace(s(u), /^(https:\/\/github\.com\/[^\/]+\/[^\/]+\/releases\/)download\/[^\/]+\//, "$1latest/download/"); } +// Имя набора b4geoip по ссылке (релиз — последний или с датой — или ветка main), иначе null +function b4_name(u) { + let m = match(s(u), /^https:\/\/(github\.com\/greeg0ry\/b4geoip-forkop\/releases\/(latest\/download|download\/[^\/]+)|raw\.githubusercontent\.com\/greeg0ry\/b4geoip-forkop\/(refs\/heads\/)?main\/srs)\/([a-z0-9_-]+)\.srs$/i); + return m ? lc(m[4]) : null; +} + +// Набор b4geoip → { k, u }: k — куда он подключается (s — домены, n — подсети, r — и то и другое), u — raw-ссылка +function b4_ref(n) { + return { k: B4_DOMAINS[n] ? "s" : B4_SUBNETS[n] ? "n" : "r", u: B4_RAW + n + ".srs" }; +} + +// Наборы правил секции (s — домены, r — домены и подсети, n — только подсети): b4geoip — по raw-ссылке и своего типа +function refs_fix(r) { + let o = { c: r.c || [], s: [], r: [], n: [], l: r.l || [] }; + for (let k in [ "s", "r", "n" ]) for (let u in (r[k] || [])) { + let b = b4_name(u), x = b ? b4_ref(b) : { k, u }; + push(o[x.k], x.u); + } + o.s = uniq(o.s); o.r = uniq(o.r); o.n = uniq(o.n); + return o; +} + function kind_hint(u) { u = lc(s(u)); for (let w in [ "geosite", "domain", "adguard", "filter" ]) if (index(u, w) >= 0) return "domains"; @@ -25204,6 +25570,8 @@ function idx_tokens(e, known) { if (n && known[n]) return [ "c:" + n ]; if (e.fmt == "lst") return [ "l:" + e.url ]; if (n) return itd_plain(n); + let b4 = b4_name(e.url); + if (b4) { let x = b4_ref(b4); return [ x.k + ":" + x.u ]; } let u = latest_url(e.url); if (e.kind == "d") return [ "s:" + u ]; if (kind_hint(u) == "domains") return []; @@ -25421,7 +25789,7 @@ function url_ext(u) { function is_rs(u) { let e = url_ext(u); return e == "srs" || e == "json"; } function sec_refs(m) { - let r = { c: uniq(words(m.community_lists)), s: [], r: [], l: [] }, zr = rawlist(m.zm_refs); + let r = { c: uniq(words(m.community_lists)), s: [], r: [], n: [], l: [] }, zr = rawlist(m.zm_refs); if (length(zr)) { for (let t in zr) { let k = substr(t, 0, 1), v = substr(t, 2); @@ -25434,11 +25802,11 @@ function sec_refs(m) { else if (index(sn, u) >= 0) push(r.r, u); else push(r.s, u); } - for (let u in sn) if (index(d, u) < 0) push(is_rs(u) ? r.r : r.l, u); + for (let u in sn) if (index(d, u) < 0) push(is_rs(u) ? r.n : r.l, u); for (let u in [ ...words(m.local_domain_lists), ...words(m.local_subnet_lists) ]) push(r.l, u); } - r.s = uniq(r.s); r.r = uniq(r.r); r.l = uniq(r.l); - return r; + r.l = uniq(r.l); + return refs_fix(r); } function sec_configured(m) { @@ -25459,7 +25827,7 @@ function sec_summary(c, sec, main) { return { name: sec, main, label: sec_label(m, main), enabled: main || s(m.disabled) != "1", mode, iface: s(m.interface), links: length(sec_links(m)), - refs: { c: rf.c, s: rf.s, r: rf.r }, lists: rf.l, + refs: { c: rf.c, s: rf.s, r: rf.r, n: rf.n }, lists: rf.l, domains: length(domain_lines(m)), subnets: length(subnet_lines(m)), own_off: own_off(m), full: device_ips(m), all: all_ips(m) }; @@ -25754,8 +26122,39 @@ function patch_device_engine() { } +// Секции, сохранённые до 4.38 (или пришедшие из резервной копии): b4geoip — по ссылкам релизов, и каждый +// набор подключён и доменами, и подсетями. Переводим на raw-ссылки и правильный тип; остальное не трогаем +function b4_migrate() { + let c = cursor(), ch = false; + for (let x in sections(c, "section")) { + let n = x[".name"], zr = rawlist(x.zm_refs), d = rawlist(x.remote_domain_lists), sn = rawlist(x.remote_subnet_lists); + let zr2 = [], d2 = [], sn2 = []; + for (let t in zr) { + let k = substr(t, 0, 1), b = substr(t, 1, 1) == ":" && index([ "s", "r", "n" ], k) >= 0 ? b4_name(substr(t, 2)) : null; + if (b) { let y = b4_ref(b); push(zr2, y.k + ":" + y.u); } else push(zr2, t); + } + for (let u in d) { + let b = b4_name(u), y = b ? b4_ref(b) : null; + if (!y) push(d2, u); else push(y.k == "n" ? sn2 : d2, y.u); + } + for (let u in sn) { + let b = b4_name(u), y = b ? b4_ref(b) : null; + if (!y) push(sn2, u); else push(y.k == "s" ? d2 : sn2, y.u); + } + zr2 = uniq(zr2); d2 = uniq(d2); sn2 = uniq(sn2); + if (join("\n", zr2) == join("\n", zr) && join("\n", d2) == join("\n", d) && join("\n", sn2) == join("\n", sn)) continue; + if (length(zr)) set_list(c, n, "zm_refs", zr2); + set_list(c, n, "remote_domain_lists", d2); + set_list(c, n, "remote_subnet_lists", sn2); + ch = true; + } + if (ch) { c.save(CFG); c.commit(CFG); } + return ch; +} + function cmd_device_setup() { - out({ ok: true, changed: patch_device_engine() }); + let eng = patch_device_engine(), lists = b4_migrate(); + out({ ok: true, changed: eng || lists, lists }); } function del_keys(c, sec, keys) { for (let k in keys) c.delete(CFG, sec, k); } @@ -25785,11 +26184,11 @@ function apply_servers(c, sec) { function set_lists(c, sec, rf, services, domains, subnets) { set_list(c, sec, "community_lists", services); let zr = []; - for (let k in [ "s", "r", "l" ]) for (let v in rf[k]) push(zr, k + ":" + v); + for (let k in [ "s", "r", "n", "l" ]) for (let v in (rf[k] || [])) push(zr, k + ":" + v); set_list(c, sec, "zm_refs", zr); let rl = filter(rf.l, (u) => !pkg_local(u)), ll = filter(rf.l, (u) => pkg_local(u)); set_list(c, sec, "remote_domain_lists", uniq([ ...rf.s, ...rf.r, ...rl ])); - set_list(c, sec, "remote_subnet_lists", uniq([ ...rf.r, ...rl ])); + set_list(c, sec, "remote_subnet_lists", uniq([ ...rf.r, ...(rf.n || []), ...rl ])); del_keys(c, sec, [ "local_domain_lists", "local_subnet_lists", "user_domains", "user_subnets" ]); set_list(c, sec, "local_domain_lists", uniq(ll)); if (length(domains)) { @@ -26210,7 +26609,7 @@ function cmd_get(want) { exclude: s(m.zm_exclude), hide_names: rawlist(m.zm_hide), jsons: mode == "json" ? 1 : 0, - refs: { c: rf.c, s: rf.s, r: rf.r }, + refs: { c: rf.c, s: rf.s, r: rf.r, n: rf.n }, catalog: { items: catalog_items(services_list()), meta: cat_meta() }, own_on: !own_off(m), domains: own_view(m, rf.l).domains, @@ -26285,10 +26684,11 @@ function cmd_set() { let known = services_list(), refs = type(d.refs) == "object" ? d.refs : {}; let services = filter(uniq(words(refs.c)), (x) => index(known, x) >= 0); - let rsets = uniq(words(refs.s)), rsubs = uniq(words(refs.r)), rplain = uniq(words(refs.l)); + let rsets = uniq(words(refs.s)), rsubs = uniq(words(refs.r)), rnets = uniq(words(refs.n)), rplain = uniq(words(refs.l)); for (let x in uniq(words(refs.c))) if (index(known, x) < 0 && EXTRA_SRS[x]) push(rsets, EXTRA_SRS[x]); - rsets = uniq(rsets); - for (let x in [ ...rsets, ...rsubs ]) + let rfx = refs_fix({ s: rsets, r: rsubs, n: rnets }); + rsets = rfx.s; rsubs = rfx.r; rnets = rfx.n; + for (let x in [ ...rsets, ...rsubs, ...rnets ]) if (!match(x, /^https?:\/\/[^ \t]+$/i)) fail("неверная ссылка на набор правил: " + substr(x, 0, 60)); for (let x in rplain) if (!valid_url(x) && !pkg_local(x)) fail("неверная ссылка на список: " + substr(x, 0, 60)); @@ -26306,7 +26706,7 @@ function cmd_set() { let all = d.all == null ? all_ips(c.get_all(CFG, sec) || {}) : uniq(tokens(d.all)); for (let x in [ ...full, ...excl, ...all ]) if (!valid_ip(x)) fail("неверный адрес устройства: " + x); if (length(all) > 64) fail("устройств с полным трафиком через VPN можно выбрать не больше 64"); - if (!length(all) && !length(services) && !length(rsets) && !length(rsubs) && !length(domains) && !length(subnets) && !length(lists) && (length(full) || created || !sec_configured(c.get_all(CFG, sec) || {}))) + if (!length(all) && !length(services) && !length(rsets) && !length(rsubs) && !length(rnets) && !length(domains) && !length(subnets) && !length(lists) && (length(full) || created || !sec_configured(c.get_all(CFG, sec) || {}))) fail(!own_on && (length(keep.domains) || length(keep.subnets) || length(keep.lists)) ? "«Свой список» выключен, а других сервисов в секции нет — включите его или выберите сервис" : "выберите для секции хотя бы один сервис, домен или адрес — устройства только ограничивают её правила"); @@ -26319,7 +26719,7 @@ function cmd_set() { if (!device_scopes_overlap(full, device_ips(x))) continue; let other = sec_label(x, n == p0.sec), orf = sec_refs(x); for (let v in services) if (index(words(x.community_lists), v) >= 0) fail("список «" + v + "» уже идёт через секцию «" + other + "» — один список можно включить только в одной секции"); - for (let v in [ ...rsets, ...rsubs ]) if (index([ ...orf.s, ...orf.r ], v) >= 0) fail("набор правил уже идёт через секцию «" + other + "»: " + substr(v, 0, 60)); + for (let v in [ ...rsets, ...rsubs, ...rnets ]) if (index([ ...orf.s, ...orf.r, ...orf.n ], v) >= 0) fail("набор правил уже идёт через секцию «" + other + "»: " + substr(v, 0, 60)); let od = domain_lines(x); for (let v in domains) if (index(od, v) >= 0) fail("домен " + v + " уже есть в секции «" + other + "» — оставьте его в одной секции"); let os = subnet_lines(x); @@ -26378,7 +26778,7 @@ function cmd_set() { } write_sec_ext(c, sec, mode, d.x); - set_lists(c, sec, { s: rsets, r: rsubs, l: lists }, services, domains, subnets); + set_lists(c, sec, { s: rsets, r: rsubs, n: rnets, l: lists }, services, domains, subnets); if (own_on || !(length(keep.domains) || length(keep.subnets) || length(keep.lists))) { del_keys(c, sec, [ "zm_own_off", "zm_own_domains", "zm_own_subnets", "zm_own_lists" ]); } else { @@ -26395,7 +26795,7 @@ function cmd_set() { c.set(CFG, BYPASS, "zm_label", "Исключения"); c.set(CFG, BYPASS, "connection_type", "exclusion"); c.delete(CFG, BYPASS, "disabled"); - set_lists(c, BYPASS, { s: [], r: [], l: [] }, [], bdom, bsub); + set_lists(c, BYPASS, { s: [], r: [], n: [], l: [] }, [], bdom, bsub); c.delete(CFG, BYPASS, "zm_refs"); c.reorder(CFG, BYPASS, 1); } @@ -27643,7 +28043,7 @@ function where_scan(host, ip, win) { if (xe != null) add(names["c:" + cn] ?? cn, cn + "_extra.lst", xe); } } - for (let k in [ "s", "r" ]) for (let u in rf[k]) { + for (let k in [ "s", "r", "n" ]) for (let u in rf[k]) { used[k + ":" + u] = true; if (srs_hit(url_rs(u), host, ip, on)) add(names[k + ":" + u] ?? ref_base(u), ref_base(u), ""); } @@ -27663,7 +28063,7 @@ function where_scan(host, ip, win) { if (e != null) { hit = { list: ref_base(v), entry: e }; break; } } else if (k == "c") { if (srs_hit({ type: "remote", tag: "zmw-" + v + "-community-ruleset", url: SRS_MAIN + "/" + v + ".srs", format: "binary" }, host, ip, false)) { hit = { list: v + ".srs", entry: "" }; break; } - } else if (k == "s" || k == "r") { + } else if (k == "s" || k == "r" || k == "n") { if (srs_hit(url_rs(v), host, ip, false)) { hit = { list: ref_base(v), entry: "" }; break; } } } @@ -33808,7 +34208,15 @@ return view.extend({ if (data.stopped) return badge('zm-off', 'Steer выключен'); if (secOff(id)) return badge('zm-off', 'секция выключена'); if (ex === 'none') return badge('zm-warn', s && s.kind === 'vpn' ? 'нет подписки' : s && s.kind === 'iface' ? 'нет туннеля' : s ? 'WARP не подключён' : 'нет подключения'); - if (!secWork(id)) return badge('zm-off', 'ничего не выбрано'); + if (!secWork(id)) { + var iv = s && ex === 'vpn' && data.steer_running ? (s.vpn && s.vpn.state) || {} : null; + if (iv && !iv.up && (iv.failed || iv.why || (iv.probe && iv.probe.state && iv.probe.state !== 'probing'))) { + var ib = badge('zm-warn', 'узел не отвечает'); + ib.title = 'В секции ничего не выбрано, поэтому на работу это не влияет' + (iv.why ? '. Причина: ' + iv.why : ''); + return ib; + } + return badge('zm-off', 'ничего не выбрано'); + } if (!data.steer_running) return badge('zm-bad', 'не работает'); if (ex === 'vpn') { if (!s) return data.failopen ? badge('zm-bad', 'VPN не принят') : data.vpn_live === false && data.vpn_up ? badge('zm-bad', 'нет связи') : data.vpn_up ? badge('zm-ok', 'работает') : badge('zm-warn', 'подключаемся'); @@ -34816,14 +35224,18 @@ return view.extend({ else items.push([ 'fail', who + ': трафик через туннель не идёт', t.kind === 'awg' ? 'почините его на вкладке AmneziaWG' : 'проверьте подключение в Сеть → Интерфейсы' ]); }); var d = diagRes.diag; - if (d && d.checks) d.checks.forEach(function(c) { + if (Array.isArray(diagRes.human) && diagRes.human.length) diagRes.human.forEach(function(c) { items.push([ c.v, c.what, c.why, c.tip, c.note ]); }); + else if (d && d.checks) d.checks.forEach(function(c) { if (c.verdict === 'ok' || c.verdict === 'warn' || c.verdict === 'fail') items.push([ c.verdict, c.what, c.why ]); }); var bad = items.filter(function(i) { return i[0] === 'fail'; }).length, warn = items.filter(function(i) { return i[0] === 'warn'; }).length; checkCard.appendChild(chkRow(E('span', { 'class': 'zm-label' }, 'Итог'), bad ? badge('zm-bad', 'есть поломка') : warn ? badge('zm-warn', 'работает, есть замечания') : badge('zm-ok', 'всё в порядке'))); var CLS = { ok: 'zm-ok', warn: 'zm-warn', fail: 'zm-bad' }, TXT = { ok: 'ок', warn: 'внимание', fail: 'ошибка' }; items.forEach(function(i) { - checkCard.appendChild(chkRow(badge(CLS[i[0]], TXT[i[0]]), E('span', {}, [].concat(i[1], i[2] ? ' — ' + i[2] : '')))); + var body = [ E('span', {}, [].concat(i[1], i[2] ? ' — ' + i[2] : '')) ]; + if (i[3]) body.push(E('span', { 'class': 'zm-chk-tip' }, i[3])); + if (i[4]) body.push(E('span', { 'class': 'zm-chk-tip zm-chk-note' }, i[4])); + checkCard.appendChild(chkRow(badge(CLS[i[0]], TXT[i[0]]), body.length > 1 ? E('span', {}, body) : body[0])); }); } checkCard.appendChild(E('div', { 'class': 'zm-actions' }, [ @@ -40042,6 +40454,9 @@ html.zm-theme-dark .zm-card { .zm-row.zm-chk { flex-wrap: nowrap; align-items: baseline; } .zm-row.zm-chk > .zm-chk-h { flex: 0 0 104px; min-width: 0; } .zm-row.zm-chk > .zm-chk-b { flex: 1 1 auto; min-width: 0; line-height: 1.5; } +.zm-chk-tip { display: block; margin-top: 3px; font-size: 12px; line-height: 1.45; opacity: .78; } +.zm-chk-tip::before { content: "→ "; opacity: .7; } +.zm-chk-note::before { content: "· "; } .zm-badge { display: inline-flex; align-items: center; gap: 6px; padding: 3px 11px; border-radius: 999px; font-size: 12px; font-weight: 600; white-space: nowrap; } .zm-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; } @@ -44169,7 +44584,7 @@ function pickFromCfg(c) { c = c || {}; var items = ((c.catalog && c.catalog.items) || []).filter(function(it) { return it && it.id && (it.targets || []).length; }); var refs = c.refs || {}, toks = [], present = {}, sel = {}, covered = {}, extra = []; - [ 'c', 's', 'r' ].forEach(function(k) { (refs[k] || []).forEach(function(v) { toks.push(k + ':' + v); }); }); + [ 'c', 's', 'r', 'n' ].forEach(function(k) { (refs[k] || []).forEach(function(v) { toks.push(k + ':' + v); }); }); var lists = (c.lists || []).slice(); toks.forEach(function(t) { present[refNorm(t)] = true; }); lists.forEach(function(u) { present[refNorm('l:' + u)] = true; }); @@ -44189,7 +44604,7 @@ function pickFromCfg(c) { } function refsOf(d) { - var out = { c: [], s: [], r: [], l: [] }, seen = {}; + var out = { c: [], s: [], r: [], n: [], l: [] }, seen = {}; (d.items || []).forEach(function(it) { if (!d.sel[it.id]) return; it.targets.forEach(function(t) { @@ -44485,7 +44900,7 @@ function validCfg(x) { function sig(d) { var srt = function(a) { return (a || []).slice().sort(); }; var tl = function(v) { return String(v || '').split(/\n/).map(function(x) { return x.trim(); }).filter(Boolean).join('\n'); }; - return JSON.stringify([ extSig(d, SEC_EXT), extSig(d, SET_EXT), d.sec, d.mode, tl(d.links), tl(d.sub), d.sub_interval, d.iface, d.fastest, (function(r) { return [ srt(r.c), srt(r.s), srt(r.r), srt(r.l) ]; })(refsOf(d)), tl(d.domains), d.own_on !== false, + return JSON.stringify([ extSig(d, SEC_EXT), extSig(d, SET_EXT), d.sec, d.mode, tl(d.links), tl(d.sub), d.sub_interval, d.iface, d.fastest, (function(r) { return [ srt(r.c), srt(r.s), srt(r.r), srt(r.n), srt(r.l) ]; })(refsOf(d)), tl(d.domains), d.own_on !== false, tl(d.subnets), tl(d.lists), srt(d.full), srt(d.all || []), srt(d.excl), d.mode === 'iface' ? '' : String(d.exclude || ''), d.dns.type, d.dns.servers, d.dns.bootstraps, d.dns.detour, d.quic_off, d.list_interval, d.lists_via, String(d.label || '').trim(), d.isMain || d.enabled, tl(d.byDomains), tl(d.bySubnets) ]); } @@ -44555,7 +44970,7 @@ function newCfg(c) { var n = ((c && c.sections) || []).length + 1; x.sec = 'new'; x.is_main = false; x.exists = false; x.enabled = true; x.label = warp && /^zmwarp/.test(warp.name) && !((c && c.sections) || []).some(function(s) { return s.label === 'WARP'; }) ? 'WARP' : 'Секция ' + n; x.mode = 'iface'; x.iface = warp ? warp.name : ''; x.links = []; x.sub = ''; x.sub_interval = '12h'; x.exclude = ''; x.hide_names = []; - x.refs = { c: [], s: [], r: [] }; x.own_on = false; x.domains = []; x.subnets = []; x.lists = []; x.subs = []; x.x = {}; + x.refs = { c: [], s: [], r: [], n: [] }; x.own_on = false; x.domains = []; x.subnets = []; x.lists = []; x.subs = []; x.x = {}; x.full = []; x.all = []; return x; @@ -44825,7 +45240,7 @@ return view.extend({ } var rf = refsOf(draft); var ownN = textLines(draft.domains).length + textLines(draft.subnets).length + textLines(draft.lists).length; - if (!rf.c.length && !rf.s.length && !rf.r.length && !rf.l.length && (!ownN || draft.own_on === false) && !draft.all.length && (draft.full.length || !secExists())) + if (!rf.c.length && !rf.s.length && !rf.r.length && !rf.n.length && !rf.l.length && (!ownN || draft.own_on === false) && !draft.all.length && (draft.full.length || !secExists())) return [ 'svc', ownN ? '«Свой список» выключен, а других сервисов в секции «' + secName() + '» нет — включите его или выберите сервис' : 'Выберите хотя бы один сервис, домен или адрес для секции «' + secName() + '» — устройства только ограничивают её правила' ]; var badOwn = tokLines(draft.domains).map(domainProblem).filter(Boolean)[0]; @@ -52016,6 +52431,10 @@ if [ -x /usr/bin/netshift ] && [ -f /usr/lib/netshift/constants.sh ] && ! _zmi_h else _zmi_warn "Сверить не удалось — откройте вкладку Forkozz и нажмите «Перезапустить»"; fi fi +case "$(/opt/zapret-manager-luci/backend.sh zapret_doh_migrate 2>/dev/null || true)" in + *'"changed":true'*) _zmi_ok "Блок DoH в стратегии Zapret обновлён до новой версии" ;; +esac + if [ -x /usr/bin/bytetube ]; then case "$(/opt/zapret-manager-luci/backend.sh bytetube_sync 2>/dev/null || true)" in *'"restarted":true'*) _zmi_ok "Скрипты ByeTube обновлены, ByeTube перезапущен" ;;