StressOzz authored and GitHub committed 2026-10-10 22:28:25 +05:00
1 parent b22d7be71f
commit 20181fcbda
1 file changed
+480 -32
+480 -32
View File
@@ -1,10 +1,11 @@
#!/bin/sh #!/bin/sh
# Version: 2.70 read -r _ _ ZM_NEW_VER <<'ZM_VERSION_EOF'
# Version: 2.73
ZM_VERSION_EOF
set -e set -e
GREEN="\033[1;32m"; CYAN="\033[1;36m"; YELLOW="\033[1;33m"; MAGENTA="\033[1;35m"; BLUE="\033[0;34m"; NC="\033[0m"; DGRAY="\033[38;5;244m" GREEN="\033[1;32m"; CYAN="\033[1;36m"; YELLOW="\033[1;33m"; MAGENTA="\033[1;35m"; BLUE="\033[0;34m"; NC="\033[0m"; DGRAY="\033[38;5;244m"
ZM_NEW_VER="2.70"
_zmi_say() { echo -e "${CYAN}==>${NC} $*"; } _zmi_say() { echo -e "${CYAN}==>${NC} $*"; }
_zmi_ok() { echo -e " ${GREEN}✓${NC} $*"; } _zmi_ok() { echo -e " ${GREEN}✓${NC} $*"; }
_zmi_step() { echo -e " → $*"; } _zmi_step() { echo -e " → $*"; }
@@ -129,7 +130,7 @@ if [ "$1" = zm_watch ]; then
fi fi
CONF="/etc/config/zapret" CONF="/etc/config/zapret"
ZM_VERSION="2.70" ZM_VERSION="@ZM_VERSION@"
ZM_SCRIPT_URL="https://raw.githubusercontent.com/StressOzz/Zapret-Manager/refs/heads/main/ZapretManager_LuCI.sh" ZM_SCRIPT_URL="https://raw.githubusercontent.com/StressOzz/Zapret-Manager/refs/heads/main/ZapretManager_LuCI.sh"
GH_RAW="https://raw.githubusercontent.com" GH_RAW="https://raw.githubusercontent.com"
GH_MAIN="https://github.com" GH_MAIN="https://github.com"
@@ -8757,7 +8758,7 @@ _stl_vpn_outs() {
_stl_spec() { _stl_spec() {
local tab k a b c lists="" rules="" outs="" lan="" sep="" lid="" lab="" srs="" dom="" pfx="" d m local tab k a b c lists="" rules="" outs="" lan="" sep="" lid="" lab="" srs="" dom="" pfx="" d m
local clients="" drules="" cid dout byp="" local clients="" drules="" cid dout byp="" xd="" xp="" xc="" xr=""
tab="$(printf '\t')" tab="$(printf '\t')"
local out="" body wp=latency local out="" body wp=latency
while IFS="$tab" read -r k a b c; do while IFS="$tab" read -r k a b c; do
@@ -8783,18 +8784,35 @@ _stl_spec() {
srs) srs="$srs${srs:+,}$(_stl_js "$a")" ;; srs) srs="$srs${srs:+,}$(_stl_js "$a")" ;;
dom) dom="$dom${dom:+,}$(_stl_js "$a")" ;; dom) dom="$dom${dom:+,}$(_stl_js "$a")" ;;
pfx) pfx="$pfx${pfx:+,}$(_stl_js "$a")" ;; pfx) pfx="$pfx${pfx:+,}$(_stl_js "$a")" ;;
xdom) xd="$xd${xd:+,}$(_stl_js "$a")" ;;
xpfx) xp="$xp${xp:+,}$(_stl_js "$a")" ;;
dev) dev)
# устройство целиком: клиент (MAC или один адрес) и правило «scope: device» # устройство целиком: клиент (MAC или один адрес) и правило «scope: device»
cid="$(_stl_ident "d_$b")" cid="$(_stl_ident "d_$b")"
case "$clients" in *"\"$cid\":"*) continue ;; esac case "$clients" in *"\"$cid\":"*) continue ;; esac
case "$b" in *:*) d="\"mac\":[$(_stl_js "$b")]" ;; *) d="\"addr\":[$(_stl_js "$b")]" ;; esac case "$b" in *:*) d="\"mac\":[$(_stl_js "$b")]" ;; *) d="\"addr\":[$(_stl_js "$b")]" ;; esac
clients="$clients${clients:+,}\"$cid\":{$d}" clients="$clients${clients:+,}\"$cid\":{$d}"
if [ "$a" = byp ]; then dout="$ST_DIRECT_OUT"; byp=1; else dout="$out"; fi if [ "$a" = byp ]; then dout="$ST_DIRECT_OUT"; byp=1; else dout="$out"; xc="$xc $cid"; fi
drules="$drules${drules:+,}{\"name\":$(_stl_js "$(_stl_ident "${a}_$b")"),\"for\":[\"$cid\"],\"to\":\"all\",\"out\":\"$dout\",\"scope\":\"device\"}" ;; drules="$drules${drules:+,}{\"name\":$(_stl_js "$(_stl_ident "${a}_$b")"),\"for\":[\"$cid\"],\"to\":\"all\",\"out\":\"$dout\",\"scope\":\"device\"}" ;;
esac esac
done < "$1" done < "$1"
_stl_rule _stl_rule
[ -n "$rules$drules" ] || return 1 [ -n "$rules$drules" ] || return 1
# Исключения — напрямую и с настоящими адресами (realip): первым общим правилом и правилом на каждое
# устройство «весь трафик через туннель» (правила на устройство старше общих, без своего исключения
# такое устройство увело бы исключённый сайт в туннель).
if [ -n "$xd$xp" ]; then
body=""
[ -n "$xd" ] && body="\"domains_file\":[$xd]"
[ -n "$xp" ] && body="$body${body:+,}\"prefixes_file\":[$xp]"
lists="\"$ST_EXCL_LIST\":{$body}${lists:+,}$lists"
rules="{\"name\":\"Исключения\",\"to\":\"$ST_EXCL_LIST\",\"out\":\"$ST_DIRECT_OUT\",\"resolve\":\"realip\"}${rules:+,}$rules"
for cid in $xc; do
xr="$xr${xr:+,}{\"name\":$(_stl_js "$(_stl_ident "x_$cid")"),\"for\":[\"$cid\"],\"to\":\"$ST_EXCL_LIST\",\"out\":\"$ST_DIRECT_OUT\",\"scope\":\"device\",\"resolve\":\"realip\"}"
done
[ -n "$xr" ] && drules="$xr${drules:+,}$drules"
byp=1
fi
[ -n "$byp" ] && outs="$outs${outs:+,}\"$ST_DIRECT_OUT\":{\"kind\":\"direct\"}" [ -n "$byp" ] && outs="$outs${outs:+,}\"$ST_DIRECT_OUT\":{\"kind\":\"direct\"}"
[ -n "$drules" ] && [ -n "$rules" ] && drules="$drules," [ -n "$drules" ] && [ -n "$rules" ] && drules="$drules,"
rules="$drules$rules" rules="$drules$rules"
@@ -9186,6 +9204,28 @@ _st_devs_all() { grep -q "^all " "$ST_DEVS" 2>/dev/null; }
_st_devs_n() { local n; n="$(grep -c "^$1 " "$ST_DEVS" 2>/dev/null)"; echo "${n:-0}"; } _st_devs_n() { local n; n="$(grep -c "^$1 " "$ST_DEVS" 2>/dev/null)"; echo "${n:-0}"; }
# Есть ли у Steer работа: выбран сервис или устройство целиком # Есть ли у Steer работа: выбран сервис или устройство целиком
_st_work() { [ -n "$(_st_sel)" ] || _st_devs_all; } _st_work() { [ -n "$(_st_sel)" ] || _st_devs_all; }
# Исключения Steer: домены и подсети, которые всегда идут напрямую. Правило стоит первым — выше сервисов,
# а у устройств «весь трафик через туннель» есть своё такое же правило на устройство (они старше общих).
ST_EXCL_LIST="zm_excl"
_st_excl_model() {
[ -s "$ST_DIR/user/exclude.lst" ] && printf 'xdom\t%s\n' "$ST_DIR/user/exclude.lst"
[ -s "$ST_DIR/user/exclude.pfx" ] && printf 'xpfx\t%s\n' "$ST_DIR/user/exclude.pfx"
return 0
}
do_steer_excl() {
local rc nd np
_st_phase rules
rm -f "$ST_STOP_FLAG"
nd="$(cat "$ST_DIR/user/exclude.lst" 2>/dev/null | grep -c .)"; np="$(cat "$ST_DIR/user/exclude.pfx" 2>/dev/null | grep -c .)"
_rb_say "Исключения: доменов $nd, IP и подсетей $np"
if _st_owns "steer-spec" && _st_work; then
if _st_use_vpn; then _st_spec_quick; else _st_spec_quick_warp; fi
rc=$?
if [ "$rc" = 0 ]; then _rb_say "Готово: исключения применены, списки сервисов не перекачивались"; return 0; fi
[ "$rc" = 1 ] && return 1
fi
do_steer_apply
}
_st_dev_model() { _st_dev_model() {
[ -s "$ST_DEVS" ] || return 0 [ -s "$ST_DEVS" ] || return 0
awk -F'\t' '($1 == "all" || $1 == "byp") && $2 != "" && !s[$2]++ { printf "dev\t%s\t%s\t%s\n", $1, $2, $3 }' "$ST_DEVS" awk -F'\t' '($1 == "all" || $1 == "byp") && $2 != "" && !s[$2]++ { printf "dev\t%s\t%s\t%s\n", $1, $2, $3 }' "$ST_DEVS"
@@ -10823,6 +10863,7 @@ _st_model() {
echo echo
fi fi
_st_dev_model _st_dev_model
_st_excl_model
for id; do for id; do
_st_svc_lists "$id" _st_svc_lists "$id"
done done
@@ -10836,7 +10877,7 @@ _st_spec_apply() {
if [ $# -gt 0 ] || ! grep -q '^dev all ' "$m"; then rm -f "$m"; echo "ОШИБКА: для выбранных сервисов нет ни одного списка"; return 1; fi if [ $# -gt 0 ] || ! grep -q '^dev all ' "$m"; then rm -f "$m"; echo "ОШИБКА: для выбранных сервисов нет ни одного списка"; return 1; fi
fi fi
stl_apply "$m" || { rm -f "$m"; return 1; } stl_apply "$m" || { rm -f "$m"; return 1; }
grep -v '^\(lan\|warp\|wpick\|vpn\|tun\|dev\) ' "$m" > "$ST_DIR/model.svc" 2>/dev/null grep -v '^\(lan\|warp\|wpick\|vpn\|tun\|dev\|xdom\|xpfx\) ' "$m" > "$ST_DIR/model.svc" 2>/dev/null
rm -f "$m" rm -f "$m"
_st_own "steer-spec" _st_own "steer-spec"
} }
@@ -11496,6 +11537,7 @@ _st_spec_quick() {
printf 'vpn\t%s\n' "$ST_SUB" printf 'vpn\t%s\n' "$ST_SUB"
_st_sub_filter _st_sub_filter
_st_dev_model _st_dev_model
_st_excl_model
cat "$ST_DIR/model.svc" cat "$ST_DIR/model.svc"
} > "$m" } > "$m"
stl_apply "$m"; f=$? stl_apply "$m"; f=$?
@@ -11514,6 +11556,7 @@ _st_spec_quick_warp() {
printf 'wpick\t%s\n' "$(_st_warp_pick)" printf 'wpick\t%s\n' "$(_st_warp_pick)"
printf 'warp'; for id in $(_st_warp_use); do printf '\t%s' "$id"; done; echo printf 'warp'; for id in $(_st_warp_use); do printf '\t%s' "$id"; done; echo
_st_dev_model _st_dev_model
_st_excl_model
cat "$ST_DIR/model.svc" cat "$ST_DIR/model.svc"
} > "$m" } > "$m"
stl_apply "$m"; f=$? stl_apply "$m"; f=$?
@@ -11885,6 +11928,7 @@ ST_USER_DIR="$ST_DIR/user"
_st_list_effective() { _st_list_effective() {
local id="$1" set f any=0 local id="$1" set f any=0
if [ "$id" = exclude ]; then ST_LIST_SRC=user; cat "$ST_USER_DIR/$id.lst" "$ST_USER_DIR/$id.pfx" 2>/dev/null; return 0; fi
if [ -s "$ST_USER_DIR/$id.lst" ] || [ -s "$ST_USER_DIR/$id.pfx" ]; then ST_LIST_SRC=user; cat "$ST_USER_DIR/$id.lst" "$ST_USER_DIR/$id.pfx" 2>/dev/null; return 0; fi if [ -s "$ST_USER_DIR/$id.lst" ] || [ -s "$ST_USER_DIR/$id.pfx" ]; then ST_LIST_SRC=user; cat "$ST_USER_DIR/$id.lst" "$ST_USER_DIR/$id.pfx" 2>/dev/null; return 0; fi
for set in $(_rb_svc_field "$id" 8 | tr ',' ' '); do for set in $(_rb_svc_field "$id" 8 | tr ',' ' '); do
f="$ST_DIR/lists/$set.dom" f="$ST_DIR/lists/$set.dom"
@@ -11895,9 +11939,12 @@ _st_list_effective() {
for f in $(_rb_svc_field "$id" 3 | tr ',' ' '); do _rb_list_get "$f" 2>/dev/null && [ -s "$ZM_LISTS_DIR/$f" ] && cat "$ZM_LISTS_DIR/$f"; done for f in $(_rb_svc_field "$id" 3 | tr ',' ' '); do _rb_list_get "$f" 2>/dev/null && [ -s "$ZM_LISTS_DIR/$f" ] && cat "$ZM_LISTS_DIR/$f"; done
} }
# Списки, которые правит человек: «Свой список» (custom — через Steer) и «Исключения» (exclude — всегда напрямую).
_st_user_list() { [ "$1" = exclude ] || { [ "$1" = custom ] && _rb_routable "$1"; }; }
steer_list_get() { steer_list_get() {
local id="$1" body n tmp="$JOBS_DIR/steer-list.$$" local id="$1" body n tmp="$JOBS_DIR/steer-list.$$"
[ "$id" = custom ] && _rb_routable "$id" || { echo '{"error":"редактируется только свой список"}'; return 1; } _st_user_list "$id" || { echo '{"error":"редактируется только свой список или исключения"}'; return 1; }
ST_LIST_SRC="" ST_LIST_SRC=""
mkdir -p "$JOBS_DIR" mkdir -p "$JOBS_DIR"
_st_list_effective "$id" > "$tmp" _st_list_effective "$id" > "$tmp"
@@ -11911,11 +11958,11 @@ steer_list_get() {
steer_list_set() { steer_list_set() {
local id="${1%%|*}" body="${1#*|}" f tmp n local id="${1%%|*}" body="${1#*|}" f tmp n
case "$1" in *'|'*) ;; *) echo '{"error":"нет списка"}'; return 1 ;; esac case "$1" in *'|'*) ;; *) echo '{"error":"нет списка"}'; return 1 ;; esac
[ "$id" = custom ] && _rb_routable "$id" || { echo '{"error":"редактируется только свой список"}'; return 1; } _st_user_list "$id" || { echo '{"error":"редактируется только свой список или исключения"}'; return 1; }
mkdir -p "$ST_USER_DIR" mkdir -p "$ST_USER_DIR"
f="$ST_USER_DIR/$id.lst"; tmp="$f.tmp" f="$ST_USER_DIR/$id.lst"; tmp="$f.tmp"
printf '%s\n' "$body" | tr -d '\r' | tr 'A-Z' 'a-z' | tr ',;' '\n\n' | printf '%s\n' "$body" | tr -d '\r' | tr 'A-Z' 'a-z' | tr ',;' '\n\n' |
sed 's/[[:space:]]*#.*$//; s/^[[:space:]]*//; s/[[:space:]]*$//; s/^domain://; s/^full://; s/^suffix://; s/^\*\.//; s/^\.//' > "$tmp.all" sed 's/[[:space:]]*#.*$//; s/^[[:space:]]*//; s/[[:space:]]*$//; s#^[a-z][a-z0-9+.-]*://\([^/?]*\).*#\1#; s/^domain://; s/^full://; s/^suffix://; s/^\*\.//; s/^\.//' > "$tmp.all"
grep -E '^[a-z0-9]([a-z0-9_-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9_-]*[a-z0-9])?)+$' "$tmp.all" | grep -vE '^[0-9.]+$' | awk '!s[$0]++' > "$tmp" grep -E '^[a-z0-9]([a-z0-9_-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9_-]*[a-z0-9])?)+$' "$tmp.all" | grep -vE '^[0-9.]+$' | awk '!s[$0]++' > "$tmp"
grep -E '^[0-9]{1,3}(\.[0-9]{1,3}){3}(/[0-9]{1,2})?$' "$tmp.all" | awk -F'[./]' '$1 < 256 && $2 < 256 && $3 < 256 && $4 < 256 && ($5 == "" || $5 <= 32) { print ($5 == "" ? $0 "/32" : $0) }' | awk '!s[$0]++' > "$tmp.pfx" grep -E '^[0-9]{1,3}(\.[0-9]{1,3}){3}(/[0-9]{1,2})?$' "$tmp.all" | awk -F'[./]' '$1 < 256 && $2 < 256 && $3 < 256 && $4 < 256 && ($5 == "" || $5 <= 32) { print ($5 == "" ? $0 "/32" : $0) }' | awk '!s[$0]++' > "$tmp.pfx"
rm -f "$tmp.all" rm -f "$tmp.all"
@@ -11927,7 +11974,7 @@ steer_list_set() {
} }
steer_list_reset() { steer_list_reset() {
[ "$1" = custom ] && _rb_routable "$1" || { echo '{"error":"редактируется только свой список"}'; return 1; } _st_user_list "$1" || { echo '{"error":"редактируется только свой список или исключения"}'; return 1; }
rm -f "$ST_USER_DIR/$1.lst" "$ST_USER_DIR/$1.pfx" rm -f "$ST_USER_DIR/$1.lst" "$ST_USER_DIR/$1.pfx"
if [ "$1" = custom ]; then if [ "$1" = custom ]; then
if _rb_in custom "$ST_SEL"; then if _rb_in custom "$ST_SEL"; then
@@ -11944,6 +11991,15 @@ steer_list_reset() {
} }
_st_list_changed() { _st_list_changed() {
if [ "$1" = exclude ]; then
# исключения действуют без выбора в списке сервисов: применяем, если Steer уже ведёт правила
if _st_installed && [ ! -f "$ST_OFF" ] && [ -z "$(_st_blocker)" ] && _st_owns "steer-spec" && _st_work; then
job_start steer do_steer_excl
else
printf '{"ok":true,"saved":true,"count":%s}\n' "$2"
fi
return
fi
if _st_installed && [ ! -f "$ST_OFF" ] && [ -z "$(_st_blocker)" ] && _rb_in "$1" "$ST_SEL"; then if _st_installed && [ ! -f "$ST_OFF" ] && [ -z "$(_st_blocker)" ] && _rb_in "$1" "$ST_SEL"; then
job_start steer do_steer_apply job_start steer do_steer_apply
else else
@@ -15959,22 +16015,138 @@ _zm_resolve_any() {
printf '%s' "$a" printf '%s' "$a"
} }
# Где лежит домен или адрес: в каких списках Steer он есть — чтобы «Куда пойдёт запрос» называл не только
# выход, но и список со строкой, которая сработала. Ядро (steer explain) называет только канал, поэтому
# списки проверяем сами, теми же правилами, что и ядро: домен — с поддоменами, «=имя» — только это имя,
# «*.имя» — только поддомены, адрес — по подсетям и диапазонам. Наборы .srs проверяет sing-box, если он есть.
# Порядок: исключения, выбранные сервисы (как их ведёт ядро), затем невыбранные сервисы с уже скачанными
# списками (ничего не качаем — ответ должен быть быстрым). Печатает JSON-массив.
_st_where() {
local host="$1" ip="$2" man="$ST_RUN/where.$$" res="$ST_RUN/where.$$.r" on id name f3 f4 f5 f6 f7 f8 rest x f n=0 st path ent sep="" lab
mkdir -p "$ST_RUN"
{
for f in "$ST_USER_DIR/exclude.lst" "$ST_USER_DIR/exclude.pfx"; do [ -s "$f" ] && printf 'x\texclude\tИсключения\t%s\n' "$f"; done
if [ -s "$ST_DIR/model.svc" ] && _st_owns "steer-spec" && [ ! -f "$ST_OFF" ]; then
awk -F'\t' '$1 == "svc" { id = $2; nm = $3; next } ($1 == "dom" || $1 == "pfx" || $1 == "srs") && id != "" { print "on\t" id "\t" nm "\t" $2 }' "$ST_DIR/model.svc"
fi
} > "$man"
on=" $(awk -F'\t' '$1 == "on" { print $2 }' "$man" | sort -u | tr '\n' ' ') "
_rb_svc_src | while IFS='|' read -r id name f3 f4 f5 f6 f7 f8 rest; do
[ -n "$id" ] || continue
case "$on" in *" $id "*) continue ;; esac
{
if [ "$id" = custom ]; then
printf '%s\n' "$ST_USER_DIR/custom.lst" "$ST_USER_DIR/custom.pfx"
else
for x in $(printf '%s,%s' "$f3" "$f4" | tr ',' ' '); do printf '%s\n' "$ZM_LISTS_DIR/$x"; done
for x in $(printf '%s' "$f8" | tr ',' ' '); do printf '%s\n' "$ST_DIR/lists/$x.dom" "$ST_DIR/lists/$x.pfx" "$ST_DIR/lists/$x.srs"; done
fi
} | while IFS= read -r f; do [ -s "$f" ] && printf 'off\t%s\t%s\t%s\n' "$id" "$name" "$f"; done
done | awk -F'\t' '!s[$2 "\t" $4]++' >> "$man"
# наборы .srs (каталог списков) ядро steer само раскладывает в текст: steer srs-read. Раскладку кладём
# рядом и берём заново, только когда набор обновился. Пятое поле строки — исходный файл, для подписи.
mkdir -p "$ST_RUN/srsdump"
awk -F'\t' '{ print NR "\t" $0 }' "$man" | while IFS="$(printf '\t')" read -r n st id name path; do
case "$path" in
*.srs)
x="$ST_RUN/srsdump/$(printf '%s' "$path" | tr -c 'A-Za-z0-9_.-' '_')"
if { [ ! -s "$x.dom" ] && [ ! -s "$x.pfx" ]; } || [ "$path" -nt "$x.dom" ]; then
rm -f "$x.dom" "$x.pfx"
steer srs-read "$path" --out "$x.dom" --prefixes-out "$x.pfx" >/dev/null 2>&1 || rm -f "$x.dom" "$x.pfx"
fi
if [ -f "$x.dom" ]; then
printf '%s\t%s\t%s\t%s\t%s\n' "$st" "$id" "$name" "$x.dom" "$path"
[ -s "$x.pfx" ] && printf '%s\t%s\t%s\t%s\t%s\n' "$st" "$id" "$name" "$x.pfx" "$path"
else
printf '%s\t%s\t%s\t%s\t%s\n' "$st" "$id" "$name" "$path" "$path"
fi ;;
*) printf '%s\t%s\t%s\t%s\t%s\n' "$st" "$id" "$name" "$path" "$path" ;;
esac
done > "$man.x"
mv -f "$man.x" "$man"
{
awk -F'\t' -v h="$host" -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] }
function under(n, d) { return length(n) > length(d) && substr(n, length(n) - length(d)) == "." d }
BEGIN { iv = ip != "" ? ipn(ip) : -1 }
$4 ~ /\.srs$/ { next }
{
best = ""; bw = -1; path = $4
while ((getline l < path) > 0) {
sub(/[#;].*$/, "", l); gsub(/^[ \t\r]+|[ \t\r]+$/, "", l); l = tolower(l)
if (l == "" || l ~ /^re:/ || l ~ /[ \t]/) continue
if (iv >= 0 && 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) continue
z = 2 ^ (32 - b); a = ipn(c[1])
if (a >= 0 && int(iv / z) == int(a / z) && 100 + b > bw) { best = l; bw = 100 + b }
continue
}
if (iv >= 0 && 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]) && 100 > bw) { best = l; bw = 100 }
continue
}
if (h == "") continue
if (substr(l, 1, 1) == "=") { w = substr(l, 2); if (h == w && length(w) > bw) { best = l; bw = length(w) } continue }
if (substr(l, 1, 2) == "*.") { w = substr(l, 3); if (under(h, w) && length(w) > bw) { best = l; bw = length(w) } continue }
if (l ~ /[*?]/) continue
if ((h == l || under(h, l)) && length(l) > bw) { best = l; bw = length(l) }
}
close(path)
if (best != "") print NR "\t" $1 "\t" $2 "\t" $3 "\t" ($5 != "" ? $5 : $4) "\t" best
}' "$man"
if command -v sing-box >/dev/null 2>&1; then
awk -F'\t' '$4 ~ /\.srs$/ { print NR "\t" $1 "\t" $2 "\t" $3 "\t" $4 }' "$man" | while IFS="$(printf '\t')" read -r n st id name path; do
for x in $host $ip; do
sing-box rule-set match -f binary "$path" "$x" 2>&1 | grep -q 'match rules' && { printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$n" "$st" "$id" "$name" "$path" ""; break; }
done
done
fi
} | sort -n -k1,1 | head -n 40 > "$res"
printf '['
while IFS="$(printf '\t')" read -r n st id name path ent; do
case "$path" in
"$ST_USER_DIR"/exclude.*) lab="Исключения" ;;
"$ST_USER_DIR"/custom.*) lab="Свой список" ;;
"$ST_DIR"/lists/*.base.dom) lab="основные домены сервиса" ;;
"$ST_DIR"/lists/*) lab="${path##*/}"; lab="${lab%.*} (каталог списков)" ;;
*) lab="${path##*/}" ;;
esac
printf '%s{"state":"%s","id":"%s","name":"%s","list":"%s","entry":"%s"}' "$sep" "$st" "$(esc "$id")" "$(esc "$name")" "$(esc "$lab")" "$(esc "$ent")"
sep=","
done < "$res"
printf ']'
rm -f "$man" "$man.x" "$res"
}
steer_explain() { steer_explain() {
local q local q host="" ip="" fd where
q="$(_zm_route_target "$1")" || { echo '{"error":"введите домен (например, youtube.com) или IP-адрес"}'; return 1; } q="$(_zm_route_target "$1")" || { echo '{"error":"введите домен (например, youtube.com) или IP-адрес"}'; return 1; }
stl_present || { echo '{"error":"Steer не установлен"}'; return 1; } stl_present || { echo '{"error":"Steer не установлен"}'; return 1; }
{ _st_owns "steer-spec" && [ ! -f "$ST_OFF" ]; } || { printf '{"target":"%s","verdict":"off","text":""}\n' "$(esc "$q")"; return 0; } if printf '%s' "$q" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then ip="$q"
elif printf '%s' "$q" | grep -q ':'; then :
else host="$q"; fi
if ! { _st_owns "steer-spec" && [ ! -f "$ST_OFF" ]; }; then
printf '{"target":"%s","verdict":"off","text":"","where":%s}\n' "$(esc "$q")" "$(_st_where "$host" "$ip")"
return 0
fi
local node local node
stl_explain "$q" stl_explain "$q"
node="$(cat "$ST_SUB_NODE" 2>/dev/null)" node="$(cat "$ST_SUB_NODE" 2>/dev/null)"
[ "$STL_X_VERDICT" = vpn ] && stl_state vpn && [ "$STL_UP" = true ] && [ -n "$STL_NODE" ] && node="$(_st_active_key)" && node="${node:-$STL_NODE}" [ "$STL_X_VERDICT" = vpn ] && stl_state vpn && [ "$STL_UP" = true ] && [ -n "$STL_NODE" ] && node="$(_st_active_key)" && node="${node:-$STL_NODE}"
printf '{"target":"%s","verdict":"%s","out":"%s","dev":"%s","channel":"%s","addr":"%s","fake":%s,"node":"%s","text":"%s"}\n' \ # поддельный адрес — значит, имя из списков: ищем по имени; настоящий адрес домена — ещё и по подсетям
fd="$(printf '%s\n' "$STL_X_TEXT" | sed -n 's/^[[:space:]]*поддельный адрес имени \([^ ]*\).*/\1/p' | head -n1)"
[ -z "$host" ] && [ -n "$fd" ] && host="$fd" && ip=""
[ -z "$ip" ] && [ "$STL_X_FAKE" != true ] && printf '%s' "$STL_X_ADDR" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' && ip="$STL_X_ADDR"
where="$(_st_where "$host" "$ip")"
# выход «напрямую» из-за своих исключений — отдельный ответ, а не «Steer этот адрес не трогает»
[ "$STL_X_VERDICT" = direct ] && [ "$STL_X_OUT" = "$ST_DIRECT_OUT" ] && case "$where" in *'"state":"x"'*) STL_X_VERDICT=exclude ;; esac
printf '{"target":"%s","verdict":"%s","out":"%s","dev":"%s","channel":"%s","addr":"%s","fake":%s,"node":"%s","where":%s,"text":"%s"}\n' \
"$(esc "$q")" "$STL_X_VERDICT" "$(esc "$STL_X_OUT")" "$(esc "$STL_X_DEV")" "$(esc "$STL_X_SET")" "$(esc "$STL_X_ADDR")" "$STL_X_FAKE" \ "$(esc "$q")" "$STL_X_VERDICT" "$(esc "$STL_X_OUT")" "$(esc "$STL_X_DEV")" "$(esc "$STL_X_SET")" "$(esc "$STL_X_ADDR")" "$STL_X_FAKE" \
"$(esc "$node")" "$(esc_ml "$STL_X_TEXT")" "$(esc "$node")" "$where" "$(esc_ml "$STL_X_TEXT")"
} }
fk_route_check() { fk_route_check() {
local q addr="" how="" verdict=direct tr rule="" ob="" ac="" sec="" s isip=0 caught=0 arg="$1" source="" err local q addr="" how="" verdict=direct tr rule="" ob="" ac="" sec="" s isip=0 caught=0 arg="$1" source="" err wh=
case "$arg" in *'|'*) source="${arg#*|}"; arg="${arg%%|*}" ;; esac case "$arg" in *'|'*) source="${arg#*|}"; arg="${arg%%|*}" ;; esac
q="$(_zm_route_target "$arg")" || { echo '{"error":"введите домен (например, youtube.com) или IP-адрес"}'; return 1; } q="$(_zm_route_target "$arg")" || { echo '{"error":"введите домен (например, youtube.com) или IP-адрес"}'; return 1; }
_fk_installed || { echo '{"error":"Forkozz не установлен"}'; return 1; } _fk_installed || { echo '{"error":"Forkozz не установлен"}'; return 1; }
@@ -15998,8 +16170,11 @@ fk_route_check() {
ob="$(printf '%s' "$tr" | jsonfilter -e '@.outbound' 2>/dev/null)" ob="$(printf '%s' "$tr" | jsonfilter -e '@.outbound' 2>/dev/null)"
ac="$(printf '%s' "$tr" | jsonfilter -e '@.action' 2>/dev/null)" ac="$(printf '%s' "$tr" | jsonfilter -e '@.action' 2>/dev/null)"
sec="$(printf '%s' "$tr" | jsonfilter -e '@.section' 2>/dev/null)" sec="$(printf '%s' "$tr" | jsonfilter -e '@.section' 2>/dev/null)"
# в каких списках секций нашёлся адрес — массив JSON, последний ключ ответа ucode
wh="$(printf '%s' "$tr" | sed -n 's/.*"where": *\(\[.*\]\) *}[[:space:]]*$/\1/p' | head -n1)"
;; ;;
esac esac
case "$wh" in '['*']') ;; *) wh='[]' ;; esac
case "$ac" in case "$ac" in
source_required) verdict=unknown ;; source_required) verdict=unknown ;;
fallback_direct) verdict=direct ;; fallback_direct) verdict=direct ;;
@@ -16009,9 +16184,9 @@ fk_route_check() {
*) if [ -z "$addr" ]; then verdict=unknown; else verdict=direct; fi ;; *) if [ -z "$addr" ]; then verdict=unknown; else verdict=direct; fi ;;
esac esac
[ -z "$addr" ] && [ "$isip" = 0 ] && { [ "$verdict" = proxy ] || [ "$ac" = connection ] || [ "$ac" = outbound ]; } && verdict=unknown [ -z "$addr" ] && [ "$isip" = 0 ] && { [ "$verdict" = proxy ] || [ "$ac" = connection ] || [ "$ac" = outbound ]; } && verdict=unknown
printf '{"target":"%s","verdict":"%s","how":"%s","addr":"%s","ip":%s,"rule":"%s","outbound":"%s","action":"%s","section":"%s","source":"%s"}\n' \ printf '{"target":"%s","verdict":"%s","how":"%s","addr":"%s","ip":%s,"rule":"%s","outbound":"%s","action":"%s","section":"%s","source":"%s","where":%s}\n' \
"$(esc "$q")" "$verdict" "$how" "$(esc "$addr")" "$([ "$isip" = 1 ] && echo true || echo false)" "$(esc "$rule")" "$(esc "$ob")" "$(esc "$ac")" "$(esc "$sec")" \ "$(esc "$q")" "$verdict" "$how" "$(esc "$addr")" "$([ "$isip" = 1 ] && echo true || echo false)" "$(esc "$rule")" "$(esc "$ob")" "$(esc "$ac")" "$(esc "$sec")" \
"$(esc "$source")" "$(esc "$source")" "$wh"
} }
forkop_action() { forkop_action() {
@@ -16410,6 +16585,7 @@ case "$cmd" in
*) echo '{"error":"неизвестная команда"}'; exit 1 ;; *) echo '{"error":"неизвестная команда"}'; exit 1 ;;
esac esac
ZM_INSTALLER_EOF ZM_INSTALLER_EOF
sed -i "s/@ZM_VERSION@/$ZM_NEW_VER/" '/opt/zapret-manager-luci/backend.sh.zm-new'
chmod 0755 '/opt/zapret-manager-luci/backend.sh.zm-new' chmod 0755 '/opt/zapret-manager-luci/backend.sh.zm-new'
mv -f '/opt/zapret-manager-luci/backend.sh.zm-new' '/opt/zapret-manager-luci/backend.sh' mv -f '/opt/zapret-manager-luci/backend.sh.zm-new' '/opt/zapret-manager-luci/backend.sh'
cat > '/opt/zapret-manager-luci/netshift.uc' << 'ZM_INSTALLER_EOF' cat > '/opt/zapret-manager-luci/netshift.uc' << 'ZM_INSTALLER_EOF'
@@ -18767,15 +18943,163 @@ function rule_hit(r, host, ip, sets, source) {
return !has && r.source_ip_cidr != null; return !has && r.source_ip_cidr != null;
} }
/* ---------- where: in which lists of the sections the domain or the IP is ----------
* sing-box names only the rule; netshift glues all plain lists of a section into one rule set, so the
* list itself is found here: each source of each section is checked on its own, the same way the
* engine matches (domain with subdomains, full:/keyword:/regexp:, IPv4 subnets; .srs/.json by
* sing-box itself). Missing files are downloaded for working sections while there is time; the
* catalog items no section uses are checked only from what is already on the router. */
let WHERE_T0 = 0;
/* время считается от начала проверки маршрута: докачка идёт, только пока с начала прошло меньше 15 с */
function where_time() { return time() - WHERE_T0 < 15; }
function dom_under(h, d) { return d != "" && (h == d || (length(h) > length(d) && substr(h, length(h) - length(d) - 1) == "." + d)); }
function ref_base(u) {
let b = replace(replace(s(u), /[?#].*$/, ""), /^.*\//, "");
return b != "" ? b : s(u);
}
function cache_tag(u) {
let n = replace(replace(s(u), /^https?:\/\//, ""), /[^A-Za-z0-9_.-]/g, "_");
return length(n) > 120 ? substr(n, length(n) - 120) : n;
}
function plain_cache(u, fetch) {
if (substr(s(u), 0, 1) == "/") return fs.access(u) ? u : null;
let name = RS_CACHE + "/l_" + cache_tag(u), st = fs.stat(name);
if (fetch && where_time() && (!st || time() - st.mtime > 86400)) {
try { fs.mkdir(RS_CACHE); } catch (e) {}
sh("curl -sSL -m 8 -o " + q(name + ".tmp") + " " + q(u) + " && mv -f " + q(name + ".tmp") + " " + q(name));
st = fs.stat(name);
}
return st ? name : null;
}
function plain_hit(path, host, ip) {
let best = null, bw = -1;
for (let l in split(s(fs.readfile(path)), "\n")) {
l = lc(trim(replace(l, /[#;].*$/, "")));
if (l == "" || match(l, /[ \t]/) || substr(l, 0, 8) == "include:") continue;
if (match(l, /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(\/[0-9]+)?$/)) {
if (ip != "" && cidr_hit(ip, l)) {
let b = index(l, "/") >= 0 ? int(split(l, "/")[1]) : 32;
if (100 + b > bw) { best = l; bw = 100 + b; }
}
continue;
}
if (host == "") continue;
let w = l, hit = false;
if (substr(l, 0, 5) == "full:") { w = substr(l, 5); hit = host == w; }
else if (substr(l, 0, 8) == "keyword:") { w = substr(l, 8); hit = w != "" && index(host, w) >= 0; }
else if (match(l, /^(regexp|regex|re):/)) {
w = replace(l, /^[a-z]+:/, "");
try { hit = !!match(host, regexp(w)); } catch (e) { hit = false; }
w = "";
}
else { w = replace(replace(l, /^domain:/, ""), /^(\*\.|\.)/, ""); hit = dom_under(host, w); }
if (hit && length(w) > bw) { best = l; bw = length(w); }
}
return best;
}
function srs_hit(rs, host, ip, fetch) {
/* тот же кеш, что у rs_file, но качаем коротко (8 с) и только пока есть время: проверка маршрута
* в оболочке ограничена 40 секундами, и долгая докачка оставила бы человека вовсе без ответа */
let name = RS_CACHE + "/" + replace(s(rs.tag), /[^A-Za-z0-9_.-]/g, "_") + (s(rs.format) == "binary" ? ".srs" : ".json");
let st = fs.stat(name);
if (fetch && where_time() && s(rs.url) != "" && (!st || time() - st.mtime > 86400)) {
try { fs.mkdir(RS_CACHE); } catch (e) {}
sh("curl -sSL -m 8 -o " + q(name + ".tmp") + " " + q(rs.url) + " && mv -f " + q(name + ".tmp") + " " + q(name));
st = fs.stat(name);
}
let f = st ? { path: name, fmt: s(rs.format || "binary") } : null;
if (!f) return false;
for (let t in [ host, ip ]) {
if (t == "") continue;
let r = sh("sing-box rule-set match -f " + q(f.fmt) + " " + q(f.path) + " " + q(t) + " 2>&1 | cat");
if (match(r, /(^|\n)[^\n]*match rules/)) return true;
}
return false;
}
function url_rs(u) {
return { type: "remote", tag: "u_" + cache_tag(u), url: u, format: url_ext(u) == "json" ? "source" : "binary" };
}
function where_scan(host, ip, win) {
let out = [];
if (host == "" && ip == "") return out;
if (!WHERE_T0) WHERE_T0 = time();
let c = cursor(), p = pick(c), used = {}, names = {}, items = [];
try { items = catalog_items(services_list()); } catch (e) { items = []; }
for (let it in items) for (let t in it.targets) if (names[t] == null) names[t] = it.name;
let won = false;
for (let x in sections(c, "section")) {
let n = x[".name"], byp = n == BYPASS;
if (!byp && !is_conn(x)) continue;
let on = byp || n == p.sec || s(x.disabled) != "1", rf = sec_refs(x);
let st = byp ? "x" : on ? "on" : "dis", label = byp ? "" : sec_label(x, n == p.sec);
let add = (name, list, entry) => {
let e = { state: st, sec: label, name, list, entry: entry ?? "", win: false };
if (!won && on && n == win) { e.win = true; won = true; }
push(out, e);
};
let own = byp ? "Исключения" : "Свой список";
if (host != "") for (let d in domain_lines(x)) if (dom_under(host, lc(replace(s(d), /^(\*\.|\.)/, "")))) { add(own, "свои домены", d); break; }
if (ip != "") for (let sn in subnet_lines(x)) if (cidr_hit(ip, sn)) { add(own, "свои IP и подсети", sn); break; }
for (let cn in rf.c) {
used["c:" + cn] = true;
if (srs_hit({ type: "remote", tag: n + "-" + cn + "-community-ruleset", url: SRS_MAIN + "/" + cn + ".srs", format: "binary" }, host, ip, on))
add(names["c:" + cn] ?? cn, cn + ".srs", "");
}
for (let k in [ "s", "r" ]) 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), "");
}
for (let u in rf.l) {
used["l:" + u] = true;
let f = plain_cache(u, on), e = f ? plain_hit(f, host, ip) : null;
if (e != null) add(names["l:" + u] ?? ref_base(u), ref_base(u), e);
}
}
/* пункты каталога, которые ни одна секция не выбрала: только то, что уже есть на роутере */
for (let it in items) {
let hit = null;
for (let t in it.targets) {
if (used[t]) continue;
let k = substr(t, 0, 1), v = substr(t, 2);
if (k == "l") {
let f = plain_cache(v, false), e = f ? plain_hit(f, host, ip) : null;
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") {
if (srs_hit(url_rs(v), host, ip, false)) { hit = { list: ref_base(v), entry: "" }; break; }
}
}
if (hit) push(out, { state: "off", sec: "", name: it.name, list: hit.list, entry: hit.entry, win: false });
if (length(out) >= 40) break;
}
return out;
}
function cmd_route(target, addr, source) { function cmd_route(target, addr, source) {
WHERE_T0 = time();
source = trim(s(source)); source = trim(s(source));
if (source != "" && ip4n(source) == null) fail("укажите IPv4-адрес устройства, например 192.168.1.100"); if (source != "" && ip4n(source) == null) fail("укажите IPv4-адрес устройства, например 192.168.1.100");
let cfgj = null; let cfgj = null;
try { cfgj = json(s(fs.readfile(SB_CONF))); } catch (e) { cfgj = null; } try { cfgj = json(s(fs.readfile(SB_CONF))); } catch (e) { cfgj = null; }
if (type(cfgj) != "object" || type(cfgj.route) != "object") { out({ action: "", outbound: "", section: "", rule: "" }); return; } if (type(cfgj) != "object" || type(cfgj.route) != "object") {
let t = lc(s(target)), v4 = !!match(t, /^[0-9.]+$/);
out({ action: "", outbound: "", section: "", rule: "", where: where_scan(v4 || index(t, ":") >= 0 ? "" : t, v4 ? t : (ip4n(addr) != null && !match(s(addr), /^198\.1[89]\./) ? s(addr) : ""), "") });
return;
}
target = lc(s(target)); target = lc(s(target));
let isip = !!match(target, /^[0-9.]+$/) || index(target, ":") >= 0; let isip = !!match(target, /^[0-9.]+$/) || index(target, ":") >= 0;
let host = isip ? "" : target, ip = isip ? target : (ip4n(addr) != null && !match(s(addr), /^198\.1[89]\./) ? s(addr) : ""); let host = isip ? "" : target, ip = isip ? target : (ip4n(addr) != null && !match(s(addr), /^198\.1[89]\./) ? s(addr) : "");
if (isip && match(target, /^198\.1[89]\./)) ip = "";
let fin = (o) => { o.where = where_scan(host, ip, o.action == "bypass" ? BYPASS : s(o.section)); out(o); };
let sets = {}; let sets = {};
for (let rs in as_arr(cfgj.route.rule_set)) sets[s(rs.tag)] = rs; for (let rs in as_arr(cfgj.route.rule_set)) sets[s(rs.tag)] = rs;
let c = cursor(), p = pick(c), labels = {}; let c = cursor(), p = pick(c), labels = {};
@@ -18786,7 +19110,7 @@ function cmd_route(target, addr, source) {
if (r.domain == "ip.podkop.fyi" || r.domain == "fakeip.podkop.fyi") continue; if (r.domain == "ip.podkop.fyi" || r.domain == "fakeip.podkop.fyi") continue;
if (!rule_hit(r, host, ip, sets, source)) continue; if (!rule_hit(r, host, ip, sets, source)) continue;
if (r.source_ip_cidr != null && source == "") { if (r.source_ip_cidr != null && source == "") {
out({ action: "source_required", outbound: "", section: "", rule: "" }); fin({ action: "source_required", outbound: "", section: "", rule: "" });
return; return;
} }
if (s(r.action) == "reject") { if (s(r.action) == "reject") {
@@ -18796,20 +19120,20 @@ function cmd_route(target, addr, source) {
let x = labels[k]; let x = labels[k];
if (!bs && index(s(rt), x.name + "-") == 0) bs = x; if (!bs && index(s(rt), x.name + "-") == 0) bs = x;
} }
out({ action: "block", outbound: "", section: bs ? bs.name : "", rule: bs ? bs.label : "" }); fin({ action: "block", outbound: "", section: bs ? bs.name : "", rule: bs ? bs.label : "" });
return; return;
} }
let ob = s(r.outbound); let ob = s(r.outbound);
if (ob == "direct-out") { out({ action: "bypass", outbound: ob, section: BYPASS, rule: "Исключения" }); return; } if (ob == "direct-out") { fin({ action: "bypass", outbound: ob, section: BYPASS, rule: "Исключения" }); return; }
let x = labels[ob]; let x = labels[ob];
if (x && ob == fallback_tag(x.name) && fallback_info(x.name, px).direct) { if (x && ob == fallback_tag(x.name) && fallback_info(x.name, px).direct) {
out({ action: "fallback_direct", outbound: "direct-out", section: x.name, rule: x.label }); fin({ action: "fallback_direct", outbound: "direct-out", section: x.name, rule: x.label });
return; return;
} }
out({ action: x ? "connection" : "outbound", outbound: ob, section: x ? x.name : "", rule: x ? x.label : ob }); fin({ action: x ? "connection" : "outbound", outbound: ob, section: x ? x.name : "", rule: x ? x.label : ob });
return; return;
} }
out({ action: "direct", outbound: "direct-out", section: "", rule: "" }); fin({ action: "direct", outbound: "direct-out", section: "", rule: "" });
} }
/* ---------- migration from a Forkop configuration ---------- */ /* ---------- migration from a Forkop configuration ---------- */
@@ -20953,6 +21277,27 @@ function routeCheck(o) {
return el; return el;
} }
/* «Куда пойдёт запрос»: в каких списках нашёлся домен или адрес — строки для карточки результата.
* w — [{ state, name, list, entry, win, sec }]: state x — исключения, on — действует, off — не выбран или
* выключен; win — именно это правило и решило, куда пойдёт запрос. Пусто — null. */
function routeWhere(w, o) {
o = o || {};
w = (w || []).filter(function(x) { return x && x.list; });
if (!w.length) return null;
var tags = o.tags || { x: 'исключение', on: 'действует', off: 'сервис не выбран' }, max = o.max || 12;
var out = w.slice(0, max).map(function(x) {
var kids = [ E('b', {}, x.name || x.list) ];
if (x.sec) kids.push(' · ' + x.sec);
if (x.name && x.list && x.list !== x.name) kids.push(' · ' + x.list);
if (x.entry) { kids.push(' · '); kids.push(E('code', { 'class': 'zm-route-entry' }, x.entry)); }
var t = x.win ? (o.winTag || 'сработало') : (tags[x.state] || '');
if (t) kids.push(E('span', { 'class': 'zm-route-tag' + (x.win ? ' zm-route-tag-win' : '') }, t));
return E('div', { 'class': 'zm-route-where' + (x.state !== 'on' && x.state !== 'x' && !x.win ? ' zm-route-where-off' : '') }, kids);
});
if (w.length > max) out.push(E('div', { 'class': 'zm-route-where zm-route-where-off' }, 'и ещё списков: ' + (w.length - max)));
return out;
}
function hostsWarn(name) { function hostsWarn(name) {
var href = document.body.classList.contains('zmw-body') ? '#/hosts' : L.url('admin/services/zapret-manager/hosts'); var href = document.body.classList.contains('zmw-body') ? '#/hosts' : L.url('admin/services/zapret-manager/hosts');
var el = E('div', { 'class': 'zm-refresh-banner zm-show zm-hosts-warn', 'role': 'note', 'style': 'display:none' }, [ var el = E('div', { 'class': 'zm-refresh-banner zm-show zm-hosts-warn', 'role': 'note', 'style': 'display:none' }, [
@@ -21086,6 +21431,7 @@ return baseclass.extend({
linksEditor: linksEditor, linksEditor: linksEditor,
linkInfo: linkInfo, linkInfo: linkInfo,
routeCheck: routeCheck, routeCheck: routeCheck,
routeWhere: routeWhere,
rebootBanner: rebootBanner, rebootBanner: rebootBanner,
rebootRouter: rebootRouter, rebootRouter: rebootRouter,
rebootSlot: rebootSlot, rebootSlot: rebootSlot,
@@ -22601,6 +22947,8 @@ return view.extend({
var routeCard = E('div', { 'class': 'zm-card' }), routeBox = null; var routeCard = E('div', { 'class': 'zm-card' }), routeBox = null;
var customCard = E('div', { 'class': 'zm-card' }); var customCard = E('div', { 'class': 'zm-card' });
var customData = null, customLoading = false, customEditor = null, customDraft = null, customDirty = false; var customData = null, customLoading = false, customEditor = null, customDraft = null, customDirty = false;
var exclCard = E('div', { 'class': 'zm-card' });
var exclData = null, exclLoading = false, exclEditor = null, exclDraft = null, exclDirty = false;
var warpCard = E('div', { 'class': 'zm-card' }); var warpCard = E('div', { 'class': 'zm-card' });
var autoCard = E('div', { 'class': 'zm-card' }); var autoCard = E('div', { 'class': 'zm-card' });
var wfixCard = E('div', { 'class': 'zm-card' }), wfixBusy = false; var wfixCard = E('div', { 'class': 'zm-card' }), wfixBusy = false;
@@ -22629,6 +22977,7 @@ return view.extend({
else if (lastAction === 'remove') msg = 'Steer удалён'; else if (lastAction === 'remove') msg = 'Steer удалён';
else if (lastAction === 'stop') msg = 'Steer выключен — всё идёт напрямую'; else if (lastAction === 'stop') msg = 'Steer выключен — всё идёт напрямую';
else if (lastAction === 'domlist') msg = 'Готово, новый список доменов работает'; else if (lastAction === 'domlist') msg = 'Готово, новый список доменов работает';
else if (lastAction === 'excl') msg = 'Готово, исключения работают';
else if (lastAction === 'lists' || lastAction === 'start') msg = 'Готово, выбор применён'; else if (lastAction === 'lists' || lastAction === 'start') msg = 'Готово, выбор применён';
else if (/^sub_/.test(lastAction)) msg = 'Готово'; else if (/^sub_/.test(lastAction)) msg = 'Готово';
else if (lastAction === 'devs_set') msg = 'Готово, выбор устройств применён'; else if (lastAction === 'devs_set') msg = 'Готово, выбор устройств применён';
@@ -23187,6 +23536,80 @@ return view.extend({
customCard.appendChild(cbar); customCard.appendChild(cbar);
} }
/* Исключения: домены и IP, которые всегда идут напрямую — выше любого сервиса и у устройств «весь трафик». */
function loadExcl() {
exclLoading = true;
renderExcl();
zm.steerAction('list_get', 'exclude').then(function(res) {
exclLoading = false;
if (res.error) { exclData = null; renderExcl(); zm.toast(res.error, 'error'); return; }
exclData = res;
exclDraft = null;
exclDirty = false;
renderExcl();
}).catch(function() { exclLoading = false; renderExcl(); });
}
function saveExcl(action, arg, okText) {
if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; }
zm.steerAction(action, arg).then(function(res) {
if (res.error) { zm.toast(res.error, 'error'); return; }
exclDraft = null;
exclDirty = false;
if (res.saved) {
zm.toast(okText + (res.count ? ' (' + res.count + ')' : ''), 'info');
loadExcl();
refresh();
return;
}
zm.toast(okText + ' — применяем правила', 'info');
lastAction = 'excl';
data.running = true;
data.phase = 'rules';
follow();
loadExcl();
}).catch(function() { zm.toast('Роутер не ответил', 'error'); });
}
function renderExcl() {
if (exclEditor && exclDirty) exclDraft = exclEditor.value;
if (exclEditor && !exclDirty) exclDraft = null;
exclCard.innerHTML = '';
exclCard.style.display = data.blocker ? 'none' : '';
if (data.blocker) return;
exclCard.appendChild(E('h3', {}, 'Исключения'));
exclCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Эти домены и IP всегда идут напрямую, мимо WARP и VPN — даже если входят в выбранный сервис.'));
if (exclLoading && !exclData) { exclCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Загружаем список…')); return; }
var nd = exclData ? (parseInt(exclData.domains, 10) || 0) : 0, np = exclData ? (parseInt(exclData.subnets, 10) || 0) : 0;
exclCard.appendChild(row('Состояние', !(nd + np) ? badge('zm-off', 'исключений нет')
: !data.installed ? badge('zm-warn', 'заработают после установки Steer')
: data.stopped ? badge('zm-off', 'Steer выключен')
: badge('zm-ok', 'действуют — эти адреса идут напрямую')));
if (nd + np) exclCard.appendChild(row('В списке', E('span', {}, 'доменов: ' + nd + ' · IP и подсетей: ' + np)));
exclEditor = E('textarea', {
'class': 'zm-config-editor', 'spellcheck': 'false', 'style': 'min-height:160px', 'placeholder': 'music.youtube.com\nbank.example.ru\n203.0.113.0/24'
});
exclEditor.value = (exclData && exclData.content) || '';
exclCard.appendChild(exclEditor);
var ebar = zm.editorBar(exclEditor, {
saveLabel: data.installed && !data.stopped ? 'Сохранить и применить' : 'Сохранить',
onSave: function(v) {
if (!v.trim()) { zm.toast('Добавьте хотя бы один домен или IP, либо нажмите «Очистить»', 'warning'); return false; }
saveExcl('list_set', 'exclude|' + v, 'Исключения сохранены');
return false;
}
});
exclEditor.addEventListener('input', function() { exclDirty = ebar.dirty(); exclDraft = exclEditor.value; });
if (exclDirty && exclDraft !== null) { exclEditor.value = exclDraft; exclEditor.dispatchEvent(new Event('input')); }
if (nd + np) exclCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() {
if (!confirm('Очистить исключения?\n\nЭти адреса снова пойдут так, как велят сервисы.')) return;
saveExcl('list_reset', 'exclude', 'Исключения очищены');
} }, 'Очистить') ]));
exclCard.appendChild(E('p', { 'class': 'zm-hint' }, 'По одному на строку: домен (поддомены включаются сами), IP-адрес или подсеть. Сайты исключайте доменом: подсеть не остановит сайт, который Steer ловит по имени — устройство тогда ходит на подменный адрес, а не на настоящий.'));
exclCard.appendChild(ebar);
}
function runDiag(quiet) { function runDiag(quiet) {
if (diagBusy) return; if (diagBusy) return;
diagBusy = true; diagBusy = true;
@@ -23954,9 +24377,18 @@ return view.extend({
function routeVerdict(r) { function routeVerdict(r) {
var tl = data.tunnels || []; var tl = data.tunnels || [];
var lines = []; var lines = [];
if (r.addr) lines.push([ 'Адрес', r.fake ? [ r.addr, ' (подменный — домен есть в списках Steer)' ] : [ r.addr, ' (настоящий — домена нет в списках Steer)' ] ]); if (r.addr) lines.push([ 'Адрес', r.fake ? [ r.addr, ' (подменный — домен есть в списках Steer)' ] : r.verdict === 'exclude' ? [ r.addr, ' (настоящий — домен в исключениях)' ] : [ r.addr, ' (настоящий — домена нет в списках Steer)' ] ]);
if (r.channel && !/^zm_(warp|vpn)_/.test(r.channel)) lines.push([ 'Правило', r.channel ]); /* В каких списках нашёлся адрес. Сработало первое: исключения стоят выше сервисов, сервисы — в порядке правил. */
if (r.verdict === 'off') return { tone: 'off', cls: 'zm-off', label: 'Steer выключен', title: 'запрос пойдёт напрямую', note: 'Запустите Steer, чтобы сервисы шли через WARP или VPN.' }; var wh = (r.where || []).map(function(x) { var y = {}; for (var k in x) y[k] = x[k]; return y; });
var winState = r.verdict === 'exclude' ? 'x' : (r.verdict === 'warp' || r.verdict === 'vpn' || r.verdict === 'other') ? 'on' : '';
for (var wi = 0; winState && wi < wh.length; wi++) if (wh[wi].state === winState) { wh[wi].win = true; break; }
var whl = zm.routeWhere(wh);
if (whl) lines.push([ 'Списки', whl ]);
else if (r.channel && !/^zm_/.test(r.channel)) lines.push([ 'Правило', r.channel ]);
var offSvc = wh.filter(function(x) { return x.state === 'off'; }).map(function(x) { return x.name; }).filter(function(n, i, a) { return n && a.indexOf(n) === i; });
var onSvc = wh.filter(function(x) { return x.state === 'on'; }).length > 0;
if (r.verdict === 'off') return { tone: 'off', cls: 'zm-off', label: 'Steer выключен', title: 'запрос пойдёт напрямую', lines: lines, note: 'Запустите Steer, чтобы сервисы шли через WARP или VPN.' };
if (r.verdict === 'exclude') return { tone: 'warn', cls: 'zm-warn', label: 'напрямую · исключение', title: 'адрес в «Исключениях» Steer — идёт мимо WARP и VPN', lines: lines };
if (r.verdict === 'vpn') { if (r.verdict === 'vpn') {
lines.push([ 'Выход', 'VPN' + (data.sub_label ? ' · ' + data.sub_label : '') ]); lines.push([ 'Выход', 'VPN' + (data.sub_label ? ' · ' + data.sub_label : '') ]);
lines.push([ 'Сервер', r.node ? r.node : 'выбирается автоматически (первый рабочий)' ]); lines.push([ 'Сервер', r.node ? r.node : 'выбирается автоматически (первый рабочий)' ]);
@@ -23973,8 +24405,10 @@ return view.extend({
lines.push([ 'Выход', r.out + (r.dev ? ' · ' + r.dev : '') ]); lines.push([ 'Выход', r.out + (r.dev ? ' · ' + r.dev : '') ]);
return { tone: 'ok', cls: 'zm-ok', label: 'через ' + (r.out || 'туннель'), title: 'идёт через выход из своих настроек', lines: lines, raw: r.text }; return { tone: 'ok', cls: 'zm-ok', label: 'через ' + (r.out || 'туннель'), title: 'идёт через выход из своих настроек', lines: lines, raw: r.text };
} }
if (r.verdict === 'direct') return { tone: 'warn', cls: 'zm-warn', label: 'напрямую', title: 'Steer этот адрес не трогает', lines: lines, if (r.verdict === 'direct') return { tone: 'warn', cls: 'zm-warn', label: 'напрямую', title: onSvc ? 'адрес есть в выбранном сервисе, но ядро его сейчас не ведёт' : 'Steer этот адрес не трогает', lines: lines,
note: 'Если сайт должен идти через ' + (data.exit === 'vpn' ? 'VPN' : warpName()) + ' — включите его сервис в списке или добавьте домен в «Свой список».', raw: r.text }; note: onSvc ? 'Устройство могло запомнить настоящий адрес сайта: очистите DNS-кэш или подождите пару минут. Если не поможет — нажмите «Применить».'
: offSvc.length ? 'Адрес есть в списке ' + (offSvc.length > 1 ? 'сервисов ' : 'сервиса ') + offSvc.map(function(n) { return '«' + n + '»'; }).join(', ') + ' — включите его, чтобы сайт шёл через ' + (data.exit === 'vpn' ? 'VPN' : warpName()) + '.'
: 'Если сайт должен идти через ' + (data.exit === 'vpn' ? 'VPN' : warpName()) + ' — включите его сервис в списке или добавьте домен в «Свой список».', raw: r.text };
return { tone: 'bad', cls: 'zm-bad', label: 'не удалось определить', title: 'смотрите ответ роутера', lines: lines, raw: r.text }; return { tone: 'bad', cls: 'zm-bad', label: 'не удалось определить', title: 'смотрите ответ роутера', lines: lines, raw: r.text };
} }
@@ -24019,6 +24453,7 @@ return view.extend({
renderDns(); renderDns();
renderLists(); renderLists();
renderCustom(); renderCustom();
renderExcl();
renderCheck(); renderCheck();
renderRoute(); renderRoute();
renderWarp(); renderWarp();
@@ -24029,12 +24464,12 @@ return view.extend({
renderAll(); renderAll();
if (tab === 'dev' && !data.blocker && data.installed) loadDevs(); if (tab === 'dev' && !data.blocker && data.installed) loadDevs();
[ listCard, customCard, checkCard, routeCard ].forEach(function(n) { panes.svc.appendChild(n); }); [ listCard, customCard, exclCard, checkCard, routeCard ].forEach(function(n) { panes.svc.appendChild(n); });
[ warpCard, wfixCard, autoCard ].forEach(function(n) { panes.warp.appendChild(n); }); [ warpCard, wfixCard, autoCard ].forEach(function(n) { panes.warp.appendChild(n); });
panes.dev.appendChild(devCard); panes.dev.appendChild(devCard);
panes.sub.appendChild(subCard); panes.sub.appendChild(subCard);
[ zm.rebootSlot(), mainCard, hostsWarn, logEl, dnsCard, tabBar, panes.svc, panes.dev, panes.warp, panes.sub ].forEach(function(n) { wrap.appendChild(n); }); [ zm.rebootSlot(), mainCard, hostsWarn, logEl, dnsCard, tabBar, panes.svc, panes.dev, panes.warp, panes.sub ].forEach(function(n) { wrap.appendChild(n); });
if (!data.blocker) { loadCustom(); loadSub(); } if (!data.blocker) { loadCustom(); loadExcl(); loadSub(); }
if (data.running) { lastAction = data.phase === 'remove' ? 'remove' : [ 'install', 'pkgs', 'awg', 'keys', 'tunnel' ].indexOf(data.phase) >= 0 ? 'install' : 'apply'; follow(); } if (data.running) { lastAction = data.phase === 'remove' ? 'remove' : [ 'install', 'pkgs', 'awg', 'keys', 'tunnel' ].indexOf(data.phase) >= 0 ? 'install' : 'apply'; follow(); }
else if (data.installed && !data.stopped && (parseInt(data.channels, 10) || 0) > 0) runDiag(true); else if (data.installed && !data.stopped && (parseInt(data.channels, 10) || 0) > 0) runDiag(true);
@@ -27517,6 +27952,12 @@ html.zm-theme-dark .zm-dock .zm-refresh-banner { background: #2f2a1c; color: #e3
.zm-route-k { flex-shrink: 0; width: 120px; opacity: .65; } .zm-route-k { flex-shrink: 0; width: 120px; opacity: .65; }
.zm-route-v { min-width: 0; overflow-wrap: anywhere; } .zm-route-v { min-width: 0; overflow-wrap: anywhere; }
.zm-route-note { margin: 8px 0 0; } .zm-route-note { margin: 8px 0 0; }
.zm-route-where { line-height: 1.5; overflow-wrap: anywhere; }
.zm-route-where + .zm-route-where { margin-top: 2px; }
.zm-route-where-off { opacity: .6; }
.zm-route-entry { font-size: 12px; padding: 0 4px; border-radius: 4px; background: rgba(127,127,127,.14); }
.zm-route-tag { display: inline-block; margin-left: 6px; padding: 0 6px; border-radius: 999px; font-size: 11px; line-height: 1.6; border: 1px solid rgba(127,127,127,.35); opacity: .85; }
.zm-route-tag-win { border-color: #2ea043; color: #2ea043; opacity: 1; }
.zm-fold { margin-top: 14px; border-top: 1px solid rgba(0,0,0,.08); padding-top: 10px; } .zm-fold { margin-top: 14px; border-top: 1px solid rgba(0,0,0,.08); padding-top: 10px; }
html.zm-theme-dark .zm-fold { border-top-color: rgba(255,255,255,.1); } html.zm-theme-dark .zm-fold { border-top-color: rgba(255,255,255,.1); }
.zm-fold-head { display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600; font-size: 13px; user-select: none; flex-wrap: wrap; } .zm-fold-head { display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600; font-size: 13px; user-select: none; flex-wrap: wrap; }
@@ -30644,7 +31085,12 @@ return view.extend({
var lines = [], via = rs ? (rs.mode === 'iface' ? 'интерфейс ' + (rs.iface || '') : 'VPN') : 'VPN'; var lines = [], via = rs ? (rs.mode === 'iface' ? 'интерфейс ' + (rs.iface || '') : 'VPN') : 'VPN';
if (r.action === 'source_required') return { tone: 'warn', cls: 'zm-warn', label: 'нужен IP устройства', title: 'маршрут зависит от устройства', note: 'Укажите IP устройства над полем сайта и повторите проверку.' }; if (r.action === 'source_required') return { tone: 'warn', cls: 'zm-warn', label: 'нужен IP устройства', title: 'маршрут зависит от устройства', note: 'Укажите IP устройства над полем сайта и повторите проверку.' };
if (r.source) lines.push([ 'Устройство', r.source ]); if (r.source) lines.push([ 'Устройство', r.source ]);
/* в каких списках нашёлся адрес: сработавшее отмечено, рядом — другие секции и невыбранные сервисы */
var wh = (r.where || []).map(function(x) { var y = {}; for (var k in x) y[k] = x[k]; if (sl.length < 2 && y.state === 'on') y.sec = ''; return y; });
var whl = zm.routeWhere(wh, { tags: { x: 'исключение', on: 'действует', dis: 'секция выключена', off: 'сервис не выбран' } });
var offSvc = wh.filter(function(x) { return x.state === 'off'; }).map(function(x) { return x.name; }).filter(function(n, i, a) { return n && a.indexOf(n) === i; });
if (r.verdict === 'bypass') { if (r.verdict === 'bypass') {
if (whl) lines.push([ 'Списки', whl ]);
return { tone: 'warn', cls: 'zm-warn', label: 'напрямую · исключение', title: 'адрес в исключениях — идёт мимо VPN', lines: lines, return { tone: 'warn', cls: 'zm-warn', label: 'напрямую · исключение', title: 'адрес в исключениях — идёт мимо VPN', lines: lines,
note: 'Проверьте вкладки «Исключения» и «Устройства → Мимо Forkozz». Чтобы запрос снова шёл через VPN, уберите соответствующее исключение и сохраните.' }; note: 'Проверьте вкладки «Исключения» и «Устройства → Мимо Forkozz». Чтобы запрос снова шёл через VPN, уберите соответствующее исключение и сохраните.' };
} }
@@ -30653,6 +31099,7 @@ return view.extend({
if (r.how === 'subnet') lines.push([ 'Совпадение', 'адрес входит в подсети Forkozz' ]); if (r.how === 'subnet') lines.push([ 'Совпадение', 'адрес входит в подсети Forkozz' ]);
if (sl.length > 1 && rs) lines.push([ 'Секция', rs.label ]); if (sl.length > 1 && rs) lines.push([ 'Секция', rs.label ]);
else if (r.rule) lines.push([ 'Правило', r.rule ]); else if (r.rule) lines.push([ 'Правило', r.rule ]);
if (whl) lines.push([ 'Списки', whl ]);
if (r.action === 'fallback_direct') return { tone: 'warn', cls: 'zm-warn', label: 'напрямую · VPN недоступны', title: 'сработал резервный прямой выход секции', lines: lines, if (r.action === 'fallback_direct') return { tone: 'warn', cls: 'zm-warn', label: 'напрямую · VPN недоступны', title: 'сработал резервный прямой выход секции', lines: lines,
note: 'Forkozz продолжает проверять VPN раз в 2 минуты и вернёт секцию через рабочий сервер после восстановления.' }; note: 'Forkozz продолжает проверять VPN раз в 2 минуты и вернёт секцию через рабочий сервер после восстановления.' };
if (r.verdict === 'proxy') { if (r.verdict === 'proxy') {
@@ -30672,7 +31119,8 @@ return view.extend({
if (r.verdict === 'direct') { if (r.verdict === 'direct') {
if (r.outbound && r.outbound !== 'direct' && r.outbound !== 'direct-out') lines.push([ 'По настройкам', r.outbound ]); if (r.outbound && r.outbound !== 'direct' && r.outbound !== 'direct-out') lines.push([ 'По настройкам', r.outbound ]);
return { tone: 'warn', cls: 'zm-warn', label: 'напрямую', title: 'Forkozz этот адрес не трогает', lines: lines, return { tone: 'warn', cls: 'zm-warn', label: 'напрямую', title: 'Forkozz этот адрес не трогает', lines: lines,
note: r.ip ? 'Чтобы IP шёл через VPN, добавьте его подсеть на вкладке «Сервисы».' : 'Чтобы сайт шёл через VPN, включите его сервис или добавьте домен на вкладке «Сервисы». Если домен уже в списке — устройство могло запомнить старый адрес: очистите DNS-кэш или подождите пару минут.' }; note: offSvc.length ? 'Адрес есть в списке ' + (offSvc.length > 1 ? 'сервисов ' : 'сервиса ') + offSvc.map(function(n) { return '«' + n + '»'; }).join(', ') + ' — выберите его в секции на вкладке «Сервисы», чтобы он шёл через VPN.'
: r.ip ? 'Чтобы IP шёл через VPN, добавьте его подсеть на вкладке «Сервисы».' : 'Чтобы сайт шёл через VPN, включите его сервис или добавьте домен на вкладке «Сервисы». Если домен уже в списке — устройство могло запомнить старый адрес: очистите DNS-кэш или подождите пару минут.' };
} }
return { tone: 'bad', cls: 'zm-bad', label: 'не удалось определить', title: 'роутер не смог узнать адрес домена', lines: lines, note: 'Проверьте, что DNS роутера работает (кнопка «Проверить» выше).' }; return { tone: 'bad', cls: 'zm-bad', label: 'не удалось определить', title: 'роутер не смог узнать адрес домена', lines: lines, note: 'Проверьте, что DNS роутера работает (кнопка «Проверить» выше).' };
} }