diff --git a/ZapretManager_LuCI.sh b/ZapretManager_LuCI.sh index 31e2b47..9289d2f 100644 --- a/ZapretManager_LuCI.sh +++ b/ZapretManager_LuCI.sh @@ -1,6 +1,6 @@ #!/bin/sh read -r _ _ ZM_NEW_VER <<'ZM_VERSION_EOF' -# Version: 3.46 +# Version: 3.89 ZM_VERSION_EOF set -e @@ -361,6 +361,61 @@ _mon_dev() { printf '%s' "$d" } +# Сети для «Сеть сейчас»: «имя устройство интернет(1/0)» по строке, кэш на минуту. +# Интернет — устройство маршрута по умолчанию с наименьшей метрикой (wan, wwan, модем…). +_mon_ifs() { + local f="$JOBS_DIR/mon.ifs" dr o n d + if [ -s "$f" ] && [ -z "$(find "$f" -mmin +1 2>/dev/null)" ]; then cat "$f"; return 0; fi + dr="$(awk 'NR > 1 && $2 == "00000000" && $8 == "00000000" { print $7 + 0, $1 }' /proc/net/route 2>/dev/null | sort -n | awk 'NR == 1 { print $2 }')" + { + ubus -t 2 call network.interface dump 2>/dev/null | jsonfilter -e '@.interface[@.up=true]' 2>/dev/null | while IFS= read -r o; do + n="$(jsonfilter -s "$o" -e '@.interface' 2>/dev/null)" + d="$(jsonfilter -s "$o" -e '@.l3_device' 2>/dev/null)" + [ -n "$n" ] && [ -n "$d" ] && [ "$n" != loopback ] && [ -d "/sys/class/net/$d" ] || continue + case "$n$d" in *[!A-Za-z0-9_.@-]*) continue ;; esac + echo "$n $d" + done + [ -n "$dr" ] && echo "$dr $dr" + } | awk -v dr="$dr" ' + { l[NR] = $0; nm[NR] = $1; dv[NR] = $2 } + END { + for (p = 0; p < 2; p++) for (i = 1; i <= NR; i++) { + six = (nm[i] ~ /6$/) ? 1 : 0 + if (six != p || seen[dv[i]]++) continue + print nm[i], dv[i], (dv[i] == dr ? 1 : 0) + } + }' > "$f.$$" 2>/dev/null && mv -f "$f.$$" "$f" 2>/dev/null + cat "$f" 2>/dev/null +} + +# Вентилятор: обороты (hwmon fanN_input), мощность (hwmon pwmN, 0–255) или ступень охлаждения +# (thermal cooling_device с «fan» в типе). Нет вентилятора — ничего не выводит. +_mon_fan() { + local h n v rpm=-1 pwm=-1 st=-1 mx=-1 c t y="${ZM_SYSFS:-/sys}" + for h in "$y"/class/hwmon/hwmon*; do + [ -d "$h" ] || continue + n=""; read -r n 2>/dev/null < "$h/name" + for v in "$h"/fan[0-9]_input; do + [ -r "$v" ] || continue + t=""; read -r t 2>/dev/null < "$v"; case "$t" in ''|*[!0-9]*) ;; *) [ "$rpm" -lt 0 ] && rpm="$t" ;; esac + done + case "$n" in *fan*|*FAN*) ;; *) [ -e "$h/fan1_input" ] || continue ;; esac + for v in "$h"/pwm[0-9]; do + [ -r "$v" ] || continue + t=""; read -r t 2>/dev/null < "$v"; case "$t" in ''|*[!0-9]*) ;; *) [ "$pwm" -lt 0 ] && pwm=$(( (t * 100 + 127) / 255 )) ;; esac + done + done + for c in "$y"/class/thermal/cooling_device*; do + [ -r "$c/type" ] || continue + t=""; read -r t 2>/dev/null < "$c/type" + case "$t" in *fan*|*Fan*|*FAN*) ;; *) continue ;; esac + read -r st 2>/dev/null < "$c/cur_state"; read -r mx 2>/dev/null < "$c/max_state" + case "$st$mx" in *[!0-9]*|'') st=-1; mx=-1 ;; *) break ;; esac + done + [ "$rpm" -ge 0 ] || [ "$pwm" -ge 0 ] || [ "$st" -ge 0 ] || return 0 + printf '{"rpm":%s,"pwm":%s,"st":%s,"mx":%s}' "$rpm" "$pwm" "$st" "$mx" +} + _mon_static() { local f="$JOBS_DIR/mon.static" soc="" v c n part cpu="" mx="" line if [ -s "$f" ] && [ -z "$(find "$f" -mmin +10 2>/dev/null)" ]; then cat "$f"; return 0; fi @@ -431,14 +486,17 @@ _mon_temps() { } monitor_snapshot() { - local wan lan fl f t + local wan lan fl f t ifs fan wan="$(_mon_dev wan)"; lan="$(_mon_dev lan)" + ifs="$(_mon_ifs | tr '\n' ';')" + fan="$(_mon_fan)" fl="/proc/uptime /proc/loadavg /proc/stat /proc/meminfo /proc/net/dev" for f in /proc/sys/net/netfilter/nf_conntrack_count /proc/sys/net/netfilter/nf_conntrack_max; do [ -r "$f" ] && fl="$fl $f"; done t=""; read -r t 2>/dev/null < /sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq case "$t" in ''|*[!0-9]*) t=0 ;; esac printf '{' - awk -v wan="$wan" -v lan="$lan" ' + awk -v wan="$wan" -v lan="$lan" -v ifs="$ifs" ' + BEGIN { k = split(ifs, il, ";"); for (i = 1; i <= k; i++) if (split(il[i], q, " ") >= 2) { ifn[q[2]] = q[1]; ifi[q[2]] = q[3] + 0; ifo[++ifc] = q[2] } } FILENAME == "/proc/uptime" { up = $1 + 0 } FILENAME == "/proc/loadavg" { ld = ($1 + 0) "," ($2 + 0) "," ($3 + 0); split($4, pr, "/"); run = pr[1] + 0; tot = pr[2] + 0 } FILENAME == "/proc/stat" && $1 ~ /^cpu/ { @@ -450,6 +508,7 @@ monitor_snapshot() { l = $0; sub(/^[ \t]+/, "", l); n = substr(l, 1, index(l, ":") - 1); split(substr(l, index(l, ":") + 1), v, " ") if (n == wan) w = (v[1] + 0) "," (v[9] + 0) if (n == lan) la = (v[1] + 0) "," (v[9] + 0) + if (n in ifn) ifc_v[n] = (v[1] + 0) "," (v[9] + 0) } FILENAME ~ /_count$/ { cc = $1 + 0 } FILENAME ~ /_max$/ { cm = $1 + 0 } @@ -457,9 +516,12 @@ monitor_snapshot() { av = ("MemAvailable" in m) ? m["MemAvailable"] : m["MemFree"] + m["Cached"] + m["Buffers"] printf "\"t\":%s,\"load\":[%s],\"run\":%d,\"tasks\":%d,\"cpu\":[%s],\"cores\":[%s],", up + 0, (ld != "" ? ld : "0,0,0"), run, tot, all, cores printf "\"mem\":{\"total\":%d,\"free\":%d,\"avail\":%d,\"buffers\":%d,\"cached\":%d,\"shmem\":%d,\"sreclaim\":%d,\"swap\":%d,\"swapfree\":%d},", m["MemTotal"], m["MemFree"], av, m["Buffers"], m["Cached"], m["Shmem"], m["SReclaimable"], m["SwapTotal"], m["SwapFree"] - printf "\"net\":{\"wan\":[%s],\"lan\":[%s],\"wan_dev\":\"%s\",\"lan_dev\":\"%s\"},\"conn\":[%d,%d]", w, la, wan, lan, cc, cm + printf "\"net\":{\"wan\":[%s],\"lan\":[%s],\"wan_dev\":\"%s\",\"lan_dev\":\"%s\",\"ifs\":[", w, la, wan, lan + for (i = 1; i <= ifc; i++) { d = ifo[i]; if (!(d in ifc_v)) continue; printf "%s{\"n\":\"%s\",\"d\":\"%s\",\"i\":%d,\"c\":[%s]}", (o++ ? "," : ""), ifn[d], d, ifi[d], ifc_v[d] } + printf "]},\"conn\":[%d,%d]", cc, cm }' $fl 2>/dev/null df -k / /tmp 2>/dev/null | awk 'NR == 2 { a = $2 + 0; b = $3 + 0 } NR == 3 { c = $2 + 0; d = $3 + 0 } END { printf ",\"flash\":[%d,%d],\"tmp\":[%d,%d]", a, b, c, d }' + printf ',"fan":%s' "${fan:-null}" printf ',"freq":%s,"leases":%s,"temps":[%s],"htop":{"inst":%s,"ttyd":%s,"open":%s},%s}\n' \ "$((t / 1000))" "$(cat /tmp/dhcp.leases 2>/dev/null | grep -c .)" "$(_mon_temps)" \ "$([ -x /usr/bin/htop ] && echo true || echo false)" "$([ -x /usr/bin/ttyd ] && echo true || echo false)" \ @@ -694,6 +756,9 @@ _zm_pkg_fmt() { /^fetch / { next } /ubus call service delete .*Not found/ { next } /(write error|[Ww]rite error): [Bb]roken pipe|^[^ ]*: [Bb]roken pipe$/ { next } + # tailscaled --cleanup при установке: на firewall4 нет iptables, а tailscale0 ещё не создан — это не ошибки + /linuxfw: clear ip6?tables: exec: "ip6?tables": executable file not found/ { next } + /router: enumerating tailscale0 addresses for cleanup failed/ { next } /[Ee]rror|ERROR|Collected errors|^ \* |[Uu]nknown package|not found|No such|unable|cannot|failed|Failed|unsatisfiable|breaks:/ { l = $0; sub(/^[ *]+/, "", l); if (l != "" && !seen[l]++) print " ! " l; next } ' } @@ -1422,6 +1487,24 @@ ui_theme_set() { printf '{"ok":true}\n' } +# Скрытые пункты меню Web UI: id разделов через пробел. Хранится на роутере — одинаково +# на всех устройствах. Пусто — показывать всё. +UI_NAV_FILE="$ZM_STATE_DIR/ui.nav.hidden" +ui_nav_get() { [ -s "$UI_NAV_FILE" ] || return 0; tr '\n' ' ' < "$UI_NAV_FILE" | sed 's/ */ /g; s/^ //; s/ $//'; } +ui_nav_set() { + local id out="" n=0 + for id in $(printf '%s' "$1" | tr ',' ' '); do + printf '%s' "$id" | grep -qE '^[a-z0-9_]{1,32}$' || { echo '{"error":"неверный раздел"}'; return 1; } + [ "$id" = dashboard ] && continue + case " $out " in *" $id "*) continue ;; esac + out="$out $id"; n=$((n + 1)) + [ "$n" -le 64 ] || { echo '{"error":"слишком много разделов"}'; return 1; } + done + mkdir -p "$ZM_STATE_DIR" + if [ -n "$out" ]; then printf '%s\n' $out > "$UI_NAV_FILE"; else rm -f "$UI_NAV_FILE"; fi + printf '{"ok":true,"hidden":"%s"}\n' "$(ui_nav_get)" +} + ui_flags_get() { local f="$ZM_STATE_DIR/ui.flags" out="" sep="" k if [ -s "$f" ]; then @@ -1446,6 +1529,17 @@ zapret_restart() { /etc/init.d/zapret restart >/dev/null 2>&1 } +# Название текущей стратегии по блоку NFQWS_OPT файла $CONF: «v7 Yv08 Dv1 QUIC», «своя»… +_strat_label() { + local s + s=$(_nfq_names | sed 's/^#[[:space:]]*//; s/[[:space:]]*$//; /^udp443$/d' | tr '\n' ' ' | sed 's/ $//') + _udp443_on && s="${s:+$s }QUIC" + { _zc_has || [ -s "$ZC_FILE" ]; } && s="${s:+$s }Custom" + # стратегию правили вручную и убрали строки-названия (#v7 и т.п.) — она есть, просто своя + [ -z "$s" ] && _nfq_opt_body | grep -q -- '^[[:space:]]*--' && s="своя" + printf '%s' "$s" +} + status() { local zr="not_installed" zr_running="false" zr_ver="" _test_recover @@ -1461,15 +1555,20 @@ status() { /etc/init.d/zapret2 status >/dev/null 2>&1 && zr2_running="true" fi - local strat="" fs_marker="" + local strat="" fs_marker="" testing=false test_now="" if [ -f "$CONF" ]; then - strat=$(_nfq_names | sed 's/^#[[:space:]]*//; s/[[:space:]]*$//; /^udp443$/d' | tr '\n' ' ' | sed 's/ $//') - _udp443_on && strat="${strat:+$strat }QUIC" + strat=$(_strat_label) fs_marker=$(grep -m1 '^# ZMFS:' "$CONF" | sed 's/^# ZMFS://') - { _zc_has || [ -s "$ZC_FILE" ]; } && strat="${strat:+$strat }Custom" + # идёт «Тест стратегий»: в конфиге временно стоит проверяемая — показываем сохранённую стратегию пользователя + if [ -s "$TEST_BACKUP" ] && _test_running; then + testing=true; test_now="$strat" + strat=$(CONF="$TEST_BACKUP"; _strat_label) + fs_marker=$(grep -m1 '^# ZMFS:' "$TEST_BACKUP" | sed 's/^# ZMFS://') + [ "$test_now" = "$strat" ] && test_now="" + fi fi - printf '{"pkg":"%s","zapret":"%s","zapret_running":%s,"zapret_version":"%s","zapret2":"%s","zapret2_running":%s,"strategy":"%s","flowseal":"%s","yv_off":%s,"quic_yt":%s,"expert":%s,"rkn":%s,"rkn_on":%s,"rkn_fits":%s,"rkn_count":%s,"wssize":%s,"ulist":%s,"ulist_on":%s,"ulist_count":%s}\n' \ + printf '{"pkg":"%s","zapret":"%s","zapret_running":%s,"zapret_version":"%s","zapret2":"%s","zapret2_running":%s,"strategy":"%s","flowseal":"%s","yv_off":%s,"quic_yt":%s,"expert":%s,"rkn":%s,"rkn_on":%s,"rkn_fits":%s,"rkn_count":%s,"wssize":%s,"ulist":%s,"ulist_on":%s,"ulist_count":%s,"testing":%s,"test_now":"%s"}\n' \ "$PKG" "$zr" "$zr_running" "$(esc "$zr_ver")" "$zr2" "$zr2_running" "$(esc "$strat")" "$(esc "$fs_marker")" \ "$([ -f /opt/zapret-manager-luci/yv_off ] && echo true || echo false)" \ "$([ -f "$CONF" ] && _udp443_on && echo true || echo false)" \ @@ -1481,7 +1580,7 @@ status() { "$([ -f "$CONF" ] && _zo_wss_on && echo true || echo false)" \ "$([ -f "$ZO_USER_FLAG" ] && echo true || echo false)" \ "$([ -f "$CONF" ] && _zo_user_on && echo true || echo false)" \ - "$(_zo_user_count)" + "$(_zo_user_count)" "$testing" "$(esc "$test_now")" } _do_install_zapret_core() { @@ -2756,7 +2855,11 @@ hosts_replace_geohide() { echo '{"error":"скачался не файл hosts — попробуйте ещё раз позже"}' return 1 fi - mv "$tmp" "$HOSTS_FILE" + if ! mv "$tmp" "$HOSTS_FILE" 2>/dev/null; then + rm -f "$tmp" + echo '{"error":"не удалось записать /etc/hosts — проверьте свободное место на роутере"}' + return 1 + fi /etc/init.d/dnsmasq restart >/dev/null 2>&1 printf '{"ok":true,"region":"%s"}\n' "$region" } @@ -2999,8 +3102,17 @@ _quic_blocked() { && echo true || echo false } +# DISABLE_IPV6 может быть записан как '0', "0" или 0 (так его сохраняет и LuCI самого Zapret); нет строки — IPv6 выключен +ZM_V6_OPT_RX="^[[:space:]]*option[[:space:]]+DISABLE_IPV6[[:space:]]+['\"]?" _ipv6_enabled_in_zapret() { - [ -f "$CONF" ] && grep -q "option DISABLE_IPV6 '0'" "$CONF" && echo true || echo false + [ -f "$CONF" ] && grep -Eq "${ZM_V6_OPT_RX}0['\"]?[[:space:]]*\$" "$CONF" && echo true || echo false +} +_zapret_v6_set() { + if grep -Eq "${ZM_V6_OPT_RX}[^[:space:]]*[[:space:]]*\$" "$CONF"; then + sed -i -E "s/${ZM_V6_OPT_RX}[^[:space:]]*[[:space:]]*\$/\toption DISABLE_IPV6 '$1'/" "$CONF" + else + sed -i "/^[[:space:]]*config[[:space:]]\{1,\}main[[:space:]]\{1,\}'\{0,1\}config'\{0,1\}[[:space:]]*\$/a\\ option DISABLE_IPV6 '$1'" "$CONF" + fi } _flow_offloading_fix_applied() { @@ -3015,7 +3127,8 @@ system_status() { "$(_quic_blocked)" "$(_ipv6_enabled_in_zapret)" "$(_flow_offloading_fix_applied)" "$(_expert_mode)" "$PKG" "$(_zt_json)" } -ZM_V6_ADDRS="2001:4860:4860::8888 2606:4700:4700::1111" +# цели проверки IPv6: сначала по имени, потом по адресам DNS Google, Cloudflare и Яндекса +ZM_V6_ADDRS="2001:4860:4860::8888 2606:4700:4700::1111 2a02:6b8::feed:0ff" ZM_V6_STATE="$JOBS_DIR/v6.state" _zm_v6_ms() { local a t @@ -3028,10 +3141,22 @@ _zm_v6_ms() { _zm_v6_dns() { nslookup google.com 127.0.0.1 2>/dev/null | awk '/^Name:/ { f = 1 } f && /^Address/ && /:[0-9A-Fa-f]*:/ { ok = 1 } END { exit !ok }' } +# Единая проверка IPv6 для баннера, «Проверки сети» и включения в Zapret: «мс цель» или ошибка +_zm_v6_reach() { + local a t + for a in google.com $ZM_V6_ADDRS; do + t="$(_zm_v6_ms "$a")" && { echo "$t $a"; return 0; } + done + return 1 +} +# состояние по результату _zm_v6_reach: live — работает, nodns — работает, но DNS не отдаёт IPv6-адреса, dead — не работает +_zm_v6_state_of() { + [ -n "$1" ] || { echo dead; return; } + if [ "${1#* }" = google.com ] || _zm_v6_dns; then echo live; else echo nodns; fi +} +_zm_v6_save() { mkdir -p "$JOBS_DIR"; echo "$1" > "$ZM_V6_STATE"; } _zm_v6_probe() { - ip -6 route show default 2>/dev/null | grep -q . || { echo dead; return; } - _zm_v6_ms $ZM_V6_ADDRS >/dev/null || { echo dead; return; } - if _zm_v6_dns; then echo live; else echo nodns; fi + _zm_v6_state_of "$(_zm_v6_reach)" } _zm_v6_cached() { local f="$ZM_V6_STATE" @@ -3046,15 +3171,12 @@ _zm_v6_cached() { } system_check_connectivity() { - local t4 t6 dns=true st=dead + local t4 t6 r dns=true st t4=$(ping -4 -c1 -W2 google.com 2>/dev/null | grep 'time=' | sed -E 's/.*time=([0-9.]+).*/\1/') - t6="$(_zm_v6_ms google.com)" - if [ -z "$t6" ]; then - t6="$(_zm_v6_ms $ZM_V6_ADDRS)" - [ -n "$t6" ] && ! _zm_v6_dns && dns=false - fi - if [ -n "$t6" ]; then st=live; [ "$dns" = false ] && st=nodns; fi - mkdir -p "$JOBS_DIR"; echo "$st" > "$ZM_V6_STATE" + r="$(_zm_v6_reach)"; t6="${r%% *}" + st="$(_zm_v6_state_of "$r")" + [ "$st" = nodns ] && dns=false + _zm_v6_save "$st" printf '{"ipv4_ok":%s,"ipv4_ms":"%s","ipv6_ok":%s,"ipv6_ms":"%s","ipv6_dns":%s}\n' \ "$([ -n "$t4" ] && echo true || echo false)" "$(esc "$t4")" \ "$([ -n "$t6" ] && echo true || echo false)" "$(esc "$t6")" "$dns" @@ -3097,18 +3219,23 @@ system_toggle_quic() { } system_toggle_ipv6() { + local r [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } - if grep -q "option DISABLE_IPV6 '0'" "$CONF"; then - sed -i "s/option DISABLE_IPV6 '0'/option DISABLE_IPV6 '1'/" "$CONF" + if [ "$(_ipv6_enabled_in_zapret)" = true ]; then + _zapret_v6_set 1 + [ "$(_ipv6_enabled_in_zapret)" = false ] || { echo '{"error":"не удалось изменить настройку DISABLE_IPV6 в конфиге Zapret — ничего не изменено"}'; return 1; } zapret_restart printf '{"ok":true,"ipv6_enabled":false}\n' return fi - if ! _zm_v6_ms $ZM_V6_ADDRS >/dev/null; then - echo '{"error":"IPv6 недоступен на роутере — включение не рекомендуется"}' + if ! r="$(_zm_v6_reach)"; then + _zm_v6_save dead + echo '{"error":"IPv6 на роутере сейчас не работает: не ответили ни google.com, ни DNS Google, Cloudflare и Яндекса по IPv6. Включать его в Zapret не нужно"}' return 1 fi - sed -i "s/option DISABLE_IPV6 '1'/option DISABLE_IPV6 '0'/" "$CONF" + _zm_v6_save "$(_zm_v6_state_of "$r")" + _zapret_v6_set 0 + [ "$(_ipv6_enabled_in_zapret)" = true ] || { echo '{"error":"не удалось изменить настройку DISABLE_IPV6 в конфиге Zapret — ничего не изменено"}'; return 1; } zapret_restart printf '{"ok":true,"ipv6_enabled":true}\n' } @@ -3448,13 +3575,13 @@ ZM_UNINSTALL_EOF printf '{"ok":true,"kept":%s}\n' "$([ "$1" = wipe ] && echo false || echo true)" } -PG_ORDER="steer forkozz mixomo awg bytetube zapret zapret2 tgproxy stgws terminal htop adblock doh dnsnet hosts panel" +PG_ORDER="steer forkozz mixomo awg bytetube zapret zapret2 tgproxy stgws tailscale terminal htop adblock doh dnsnet hosts panel" _pg_title() { case "$1" in zapret) echo "Zapret" ;; zapret2) echo "Zapret2" ;; bytetube) echo "ByeTube (ByeDPI, hev-socks5-tunnel)" ;; steer) echo "Steer (ядро steer, WARP, подписки)" ;; forkozz) echo "Forkozz (netshift, sing-box)" ;; mixomo) echo "Mixomo (Mihomo, MagiTrickle)" ;; awg) echo "AmneziaWG" ;; - tgproxy) echo "TG WS Proxy (MTProto, SOCKS5, Rust)" ;; stgws) echo "sTGWS" ;; terminal) echo "Терминал (ttyd)" ;; htop) echo "htop (Монитор ресурсов)" ;; + tgproxy) echo "TG WS Proxy (MTProto, SOCKS5, Rust)" ;; stgws) echo "sTGWS" ;; tailscale) echo "Tailscale (телефон через роутер)" ;; terminal) echo "Терминал (ttyd)" ;; htop) echo "htop (Монитор ресурсов)" ;; adblock) echo "Блокировка рекламы" ;; doh) echo "DNS over HTTPS (https-dns-proxy)" ;; dnsnet) echo "Браузеры и российские сайты (записи DNS)" ;; hosts) echo "Записи панели в hosts" ;; panel) echo "Zapret Manager (LuCI, Web UI, настройки, zms и zmw)" ;; @@ -3462,7 +3589,7 @@ _pg_title() { } _pg_group() { case "$1" in - zapret|zapret2|bytetube) echo block ;; steer|forkozz|mixomo|awg|tgproxy|stgws) echo vpn ;; + zapret|zapret2|bytetube) echo block ;; steer|forkozz|mixomo|awg|tgproxy|stgws|tailscale) echo vpn ;; adblock|doh|dnsnet|hosts) echo dns ;; terminal|htop) echo tools ;; panel) echo panel ;; esac } @@ -3477,6 +3604,7 @@ _pg_inst() { awg) _awg_installed ;; tgproxy) [ -x /etc/init.d/tg-ws-proxy ] || [ -x "$TG_BIN_GO" ] || [ -x "$TG_BIN_RS" ] ;; stgws) [ -x /etc/init.d/tgws ] || _pg_pkg tgws ;; + tailscale) _tsc_installed || _pg_pkg tailscale ;; terminal) _term_installed ;; htop) [ -x /usr/bin/htop ] || _pg_pkg htop ;; adblock) _ab_on ;; @@ -3507,6 +3635,7 @@ _pg_trace() { awg) echo "pkg:luci-i18n-amneziawg-ru pkg:luci-app-amneziawg pkg:luci-proto-amneziawg pkg:amneziawg-tools pkg:kmod-amneziawg $AWG_DIR /usr/bin/awg /usr/bin/awg-quick /lib/netifd/proto/amneziawg.sh /lib/modules/*/amneziawg.ko /etc/modules.d/*amneziawg* $AWG_KMOD_MANUAL $(_zm_luci_files amneziawg)" ;; tgproxy) echo "pkg:tg-ws-proxy /usr/bin/tg-ws-proxy /etc/init.d/tg-ws-proxy /etc/tg-ws-proxy /etc/tg-ws-proxy.conf /etc/config/tg-ws-proxy $TG_BIN_GO $TG_BIN_GO.new $TG_INIT_GO $TG_BIN_RS $TG_INIT_RS $TG_SECRET_RS_FILE $TG_VER_GO_FILE $TG_VER_RS_FILE /etc/config/tg-ws-proxy-rs $TG_WATCH_FLAG /tmp/tg-ws-proxy.ipk /tmp/tg-ws-proxy.apk /etc/rc.d/*tg-ws-proxy* cron:zm-tg-auto cronrx:tg-ws-proxy" ;; stgws) echo "pkg:tgws /etc/init.d/tgws /usr/sbin/tgws /usr/sbin/stgws /etc/config/tgws /etc/rc.d/*tgws* /var/lib/tgws /var/lib/stgws nft:tgws nft:stgws cronrx:tgws" ;; + tailscale) echo "cron:zm-tailscale $ZM_STATE_DIR/latest.tsc pkg:luci-i18n-tailscale-community-ru pkg:luci-app-tailscale-community /etc/init.d/tailscale-settings /etc/rc.d/*tailscale-settings /www/luci-static/resources/view/tailscale.js /usr/share/luci/menu.d/luci-app-tailscale-community.json /usr/share/rpcd/acl.d/luci-app-tailscale-community.json /usr/share/rpcd/ucode/tailscale.uc pkg:tailscale /usr/sbin/tailscale /usr/sbin/tailscaled /etc/init.d/tailscale /etc/rc.d/*tailscale /etc/config/tailscale /etc/config/tailscale-opkg /etc/config/tailscale.apk-new /etc/tailscale /var/lib/tailscale $TSC_NFT $TSC_HOOK $TSC_SYSCTL $TSC_DIR $TSC_UPLOG uci:network.$TSC_NET uci:firewall.$TSC_FW_RULE" ;; terminal) echo "pkg:luci-i18n-ttyd-ru pkg:luci-app-ttyd pkg:ttyd /usr/bin/ttyd /etc/config/ttyd /etc/config/ttyd-opkg /etc/config/ttyd.apk-new /etc/init.d/ttyd /etc/rc.d/*ttyd $(_zm_luci_files ttyd)" ;; htop) echo "pkg:htop /usr/bin/htop /etc/htoprc /root/.config/htop $MON_TTYD_OWN" ;; adblock) echo "$AB_DIR $AB_STATS_DIR $AB_STATS_LOG dnsconf:zm-adblock.conf dnsconf:zm-adblock-stats.conf nft:zm_adblock_free /var/run/zm-adblock-free.pid /var/etc/zm-adblock-free.conf /var/etc/zm-adblock-free.conf.macs cron:zm-adblock cron:zm-abstats" ;; @@ -3597,6 +3726,7 @@ _pg_remove() { awg) ZM_NO_NETCHECK=1 do_awg_remove all ;; tgproxy) do_tg_remove_mtproto; do_tg_remove_socks5; do_tg_remove_rust ;; stgws) do_tgws_remove ;; + tailscale) do_tsc_remove ;; terminal) do_term_remove ;; htop) do_htop_remove ;; adblock) adblock_action off >/dev/null 2>&1; rm -rf "$AB_DIR"; rm -f "$JOBS_DIR"/adblock.*; echo " ✓ Блокировка рекламы выключена, списки, белый список и статистика удалены" ;; @@ -4468,6 +4598,15 @@ tg_watch_set() { printf '{"ok":true,"auto":%s}\n' "$(_tg_auto_json)" } +# Установлен ли хоть один прокси для Telegram (MTProto, SOCKS5, Rust, sTGWS) +_tg_any_installed() { + local id + for id in mtproto socks5 rust tgws; do + [ -x "$(_tg_auto_init "$id")" ] && return 0 + done + return 1 +} + tg_auto_tick() { local init init="$(_tg_auto_init "$1")" || return 0 @@ -4778,6 +4917,7 @@ do_tgws_install() { local domain domain=$(_tgws_domain) echo "==> Используем домен: ${domain:-не определён}" + _tsc_tg_ensure >/dev/null 2>&1 _ab_dns_bg echo "==> Готово, sTGWS установлен и запущен" } @@ -5840,7 +5980,7 @@ _zm_update_fetch() { _zm_job_label() { case "$1" in - steer) echo "Steer" ;; awg) echo "AmneziaWG" ;; forkop) echo "Forkozz" ;; term) echo "Терминал" ;; + steer) echo "Steer" ;; awg) echo "AmneziaWG" ;; forkop) echo "Forkozz" ;; term) echo "Терминал" ;; tsc) echo "Tailscale" ;; mixomo*) echo "Mixomo" ;; bytetube*) echo "ByeTube" ;; install_zapret2|remove_zapret2) echo "Zapret2" ;; install_zapret|remove_zapret) echo "Zapret" ;; strategy_test) echo "тест стратегий" ;; tg*) echo "TG WS Proxy" ;; doh*) echo "DNS over HTTPS" ;; @@ -7013,11 +7153,11 @@ health() { fi local d_ab=0 if _ab_on; then if _ab_applied; then d_ab=1; else d_ab=2; fi; fi - printf '{"dots":{"zapret":%s,"zapret2":%s,"bytetube":%s,"tg":%s,"mixomo":%s,"doh":%s,"hosts":%s,"adblock":%s,"steer":%s,"forkop":%s,"awg":%s,"term":%s},' \ - "$d_zr" "$d_zr2" "$d_bt" "$d_tg" "$d_mx" "$d_doh" "$hs" "$d_ab" "$d_sr" "${d_fk:-0}" "${d_awg:-0}" "$d_term" - printf '"zapret":%s,"zapret2":%s,"bytetube":%s,"tg":%s,"mixomo":%s,"doh":%s,"hosts":%s,"steer":%s,"steer_off":%s,"steer_exit":"%s","awg":%s,"forkop":%s,"flow_warn":%s,"ipv6_warn":%s,"reboot_hint":"%s"}\n' \ + printf '{"dots":{"zapret":%s,"zapret2":%s,"bytetube":%s,"tg":%s,"mixomo":%s,"doh":%s,"hosts":%s,"adblock":%s,"steer":%s,"forkop":%s,"awg":%s,"term":%s,"ts":%s},' \ + "$d_zr" "$d_zr2" "$d_bt" "$d_tg" "$d_mx" "$d_doh" "$hs" "$d_ab" "$d_sr" "${d_fk:-0}" "${d_awg:-0}" "$d_term" "$(_tsc_dot)" + printf '"zapret":%s,"zapret2":%s,"bytetube":%s,"tg":%s,"mixomo":%s,"doh":%s,"hosts":%s,"steer":%s,"steer_off":%s,"steer_exit":"%s","awg":%s,"forkop":%s,"flow_warn":%s,"ipv6_warn":%s,"reboot_hint":"%s","nav_hidden":"%s"}\n' \ "$zr" "$zr2" "$bt" "$tg" "$mx" "$doh" "$hs" "$sr" \ - "$([ -f /etc/zm-steer/stopped ] && echo true || echo false)" "$sx" "$awgj" "$fkh" "$fw" "$v6" "$(esc "$(_zm_reboot_hint_get)")" + "$([ -f /etc/zm-steer/stopped ] && echo true || echo false)" "$sx" "$awgj" "$fkh" "$fw" "$v6" "$(esc "$(_zm_reboot_hint_get)")" "$(esc "$(ui_nav_get)")" } VERSIONS_CACHE="$ZM_STATE_DIR/versions.json" @@ -7405,7 +7545,10 @@ start_service() { config_get byedpi_opts main byedpi_opts "" byedpi_opts=$(byedpi_opts_clean "$byedpi_opts") config_get ipv6 main ipv6 1 - [ -f /proc/net/if_inet6 ] || ipv6=0 + if [ "$ipv6" = 1 ] && ! v6_kernel_ok; then + log "IPv6 на роутере выключен (disable_ipv6) — работаю только по IPv4" + ipv6=0 + fi case "$byedpi_port" in ''|*[!0-9]*) log "некорректный порт: $byedpi_port"; return 1 ;; @@ -7493,11 +7636,17 @@ status) v_nftset=0; dnsmasq_has_nftset && v_nftset=1 v_fw=0; nft list chain inet fw4 forward 2>/dev/null | grep -q bytetube && v_fw=1 v_bin=0; { [ -x /usr/bin/ciadpi ] || [ -x /usr/bin/byedpi ]; } && [ -x /usr/bin/hev-socks5-tunnel ] && v_bin=1 + v_v6ok=0; v6_kernel_ok && v_v6ok=1 + v_v6on=0; [ "$ipv6" = 1 ] && [ "$v_v6ok" = 1 ] && v_v6on=1 + # hev включён, но не работает — последняя его ошибка из журнала, чтобы панель показала причину + v_hevlog="" + [ "$enabled" = 1 ] && [ "$v_hev" = 0 ] && v_hevlog=$(logread 2>/dev/null | grep 'hev-socks5-tunnel' | grep '\[E\]' | tail -n 1 \ + | sed 's/.*\(\[E\]\)/\1/' | tr -d '"\\\000-\037' | cut -c1-160) - printf '{"enabled":%s,"byedpi":%s,"hev":%s,"tun":%s,"nft":%s,"rule":%s,"route":%s,"dns":%s,"dnsmasq_nftset":%s,"fw":%s,"binaries":%s,"ipv6":%s,"quic":"%s","ips4":%s,"ips6":%s}\n' \ + printf '{"enabled":%s,"byedpi":%s,"hev":%s,"tun":%s,"nft":%s,"rule":%s,"route":%s,"dns":%s,"dnsmasq_nftset":%s,"fw":%s,"binaries":%s,"ipv6":%s,"ipv6_ok":%s,"ipv6_on":%s,"hev_log":"%s","quic":"%s","ips4":%s,"ips6":%s}\n' \ "$(b "$enabled")" "$(b $v_byedpi)" "$(b $v_hev)" "$(b $v_tun)" "$(b $v_nft)" \ "$(b $v_rule)" "$(b $v_route)" "$(b $v_dns)" "$(b $v_nftset)" "$(b $v_fw)" "$(b $v_bin)" \ - "$(b "$ipv6")" "$quic" "$(count_set yt4)" "$(count_set yt6)" + "$(b "$ipv6")" "$(b $v_v6ok)" "$(b $v_v6on)" "$v_hevlog" "$quic" "$(count_set yt4)" "$(count_set yt6)" ;; flush) nft flush set inet "$NFT_TABLE" yt4 2>/dev/null @@ -7725,6 +7874,16 @@ find_byedpi() { byedpi_opts_clean() { printf '%s' "$1" | tr -d "\"'" | tr '\n\r\t' ' ' | sed 's/^ *//; s/ *$//; s/ */ /g' } + +# IPv6 для ytb0 возможен, только если он есть в ядре и не выключен через sysctl: +# all — на всех интерфейсах, default — на новых (ytb0 создаётся новым). Файл /proc/net/if_inet6 есть и при выключенном IPv6. +v6_kernel_ok() { + local p="${YTB_PROC:-/proc}" + [ -f "$p/net/if_inet6" ] || return 1 + [ "$(cat "$p/sys/net/ipv6/conf/all/disable_ipv6" 2>/dev/null)" = 1 ] && return 1 + [ "$(cat "$p/sys/net/ipv6/conf/default/disable_ipv6" 2>/dev/null)" = 1 ] && return 1 + return 0 +} YTB_FILE_END_7f3a9c chmod 755 /usr/libexec/bytetube/common.sh cat > /usr/libexec/bytetube/net.sh <<'YTB_FILE_END_7f3a9c' @@ -7736,7 +7895,7 @@ YTB_FILE_END_7f3a9c config_load bytetube config_get IPV6 main ipv6 1 config_get QUIC main quic block -[ -f /proc/net/if_inet6 ] || IPV6=0 +v6_kernel_ok || IPV6=0 rules_del() { while ip rule del pref "$PREF" 2>/dev/null; do :; done @@ -7821,7 +7980,7 @@ YTB_FILE_END_7f3a9c config_load bytetube config_get IPV6 main ipv6 1 -[ -f /proc/net/if_inet6 ] || IPV6=0 +v6_kernel_ok || IPV6=0 ip link set "$TUN" up 2>/dev/null ip route replace default dev "$TUN" table "$TABLE" @@ -8522,6 +8681,24 @@ YTB_FILE_END_7f3a9c chmod 644 /usr/share/bytetube/test-domains.txt } +# После обновления панели: переписать скрипты уже установленного ByeTube (настройки /etc/config/bytetube не трогаются) +# и перезапустить его, если скрипты изменились и он включён +bytetube_sync() { + local f a b + [ -x /usr/bin/bytetube ] && [ -x /etc/init.d/bytetube ] || { echo '{"installed":false}'; return 0; } + f="/etc/init.d/bytetube /usr/bin/bytetube /usr/libexec/bytetube/common.sh /usr/libexec/bytetube/net.sh /usr/libexec/bytetube/route-up.sh" + a="$(cat $f 2>/dev/null | md5sum)" + _bytetube_install_payload >/dev/null 2>&1 + b="$(cat $f 2>/dev/null | md5sum)" + [ "$a" = "$b" ] && { echo '{"installed":true,"changed":false}'; return 0; } + if [ "$(uci -q get bytetube.main.enabled)" = 1 ]; then + /etc/init.d/bytetube restart >/dev/null 2>&1 + echo '{"installed":true,"changed":true,"restarted":true}' + else + echo '{"installed":true,"changed":true,"restarted":false}' + fi +} + do_bytetube_install() { NEW_BYEDPI=0 NEW_HEV=0 @@ -8795,11 +8972,15 @@ _doh_provider_of() { case "$1" in https://cloudflare-dns.com/*|https://1.1.1.1/*|https://1.0.0.1/*) echo cloudflare ;; https://dns.google/*|https://8.8.8.8/*|https://8.8.4.4/*) echo google ;; + https://security.cloudflare-dns.com/*) echo cloudflare_sec ;; + https://family.cloudflare-dns.com/*) echo cloudflare_fam ;; https://dns.quad9.net/*) echo quad9 ;; + https://dns10.quad9.net/*) echo quad9_open ;; https://xbox-dns.ru/*) echo xbox ;; https://dns.comss.one/*) echo comss ;; https://dns.dns-ai.ru/*) echo dnsai ;; https://dns.bezmezhau.com/*) echo bezmezhau ;; + https://common.dot.dns.yandex.net/*) echo yandex ;; https://family.dot.dns.yandex.net/*) echo yandex_family ;; https://safe.dot.dns.yandex.net/*) echo yandex_safe ;; https://eu.geohide.ru/*) echo geohide_eu ;; @@ -8810,7 +8991,11 @@ _doh_provider_of() { https://d.webshiftvpn.ru/*) echo webshift ;; https://dns.dagami.ru/*) echo dagami ;; https://freedns.controld.com/p0) echo controld ;; + https://freedns.controld.com/p1) echo controld_sec ;; + https://freedns.controld.com/family) echo controld_fam ;; https://unfiltered.joindns4.eu/*) echo dns4eu ;; + https://protective.joindns4.eu/*) echo dns4eu_sec ;; + https://child.joindns4.eu/*) echo dns4eu_fam ;; *) echo "" ;; esac } @@ -8857,11 +9042,19 @@ _doh_boot_of() { case "$1" in cloudflare) echo "1.1.1.1,1.0.0.1,2606:4700:4700::1111,2606:4700:4700::1001" ;; google) echo "8.8.8.8,8.8.4.4,2001:4860:4860::8888,2001:4860:4860::8844" ;; + cloudflare_sec) echo "1.1.1.2,1.0.0.2,2606:4700:4700::1112,2606:4700:4700::1002" ;; + cloudflare_fam) echo "1.1.1.3,1.0.0.3,2606:4700:4700::1113,2606:4700:4700::1003" ;; quad9) echo "9.9.9.9,149.112.112.112,2620:fe::fe,2620:fe::9" ;; + quad9_open) echo "9.9.9.10,149.112.112.10,2620:fe::10,2620:fe::fe:10" ;; + yandex) echo "77.88.8.8,77.88.8.1" ;; yandex_family) echo "77.88.8.7,77.88.8.3" ;; yandex_safe) echo "77.88.8.88,77.88.8.2" ;; controld) echo "76.76.2.0,76.76.10.0" ;; + controld_sec) echo "76.76.2.1,76.76.10.1" ;; + controld_fam) echo "76.76.2.4,76.76.10.4" ;; dns4eu) echo "86.54.11.100,86.54.11.200" ;; + dns4eu_sec) echo "86.54.11.1,86.54.11.201" ;; + dns4eu_fam) echo "86.54.11.12,86.54.11.212" ;; esac } @@ -8907,7 +9100,11 @@ _doh_url_of() { case "$1" in cloudflare) echo "https://cloudflare-dns.com/dns-query" ;; google) echo "https://dns.google/dns-query" ;; + cloudflare_sec) echo "https://security.cloudflare-dns.com/dns-query" ;; + cloudflare_fam) echo "https://family.cloudflare-dns.com/dns-query" ;; quad9) echo "https://dns.quad9.net/dns-query" ;; + quad9_open) echo "https://dns10.quad9.net/dns-query" ;; + yandex) echo "https://common.dot.dns.yandex.net/dns-query" ;; xbox) echo "https://xbox-dns.ru/dns-query" ;; comss) echo "https://dns.comss.one/dns-query" ;; dnsai) echo "https://dns.dns-ai.ru/dns-query" ;; @@ -8922,7 +9119,11 @@ _doh_url_of() { webshift) echo "https://d.webshiftvpn.ru/dns-query" ;; dagami) echo "https://dns.dagami.ru/dns-query" ;; controld) echo "https://freedns.controld.com/p0" ;; + controld_sec) echo "https://freedns.controld.com/p1" ;; + controld_fam) echo "https://freedns.controld.com/family" ;; dns4eu) echo "https://unfiltered.joindns4.eu/dns-query" ;; + dns4eu_sec) echo "https://protective.joindns4.eu/dns-query" ;; + dns4eu_fam) echo "https://child.joindns4.eu/dns-query" ;; https://*) printf '%s' "$1" | grep -qE '^https://[A-Za-z0-9.-]+\.[A-Za-z0-9-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~%/+=&?-]*)?$' || return 1 [ "${#1}" -le 200 ] || return 1 @@ -9146,7 +9347,9 @@ _doh_fo_on() { [ "$(_doh_fo_get fo 0)" = 1 ] || [ "$(_doh_fo_get direct 0)" = 1 _doh_name() { case "$1" in google) echo Google ;; cloudflare) echo Cloudflare ;; quad9) echo Quad9 ;; xbox) echo XBOX ;; comss) echo Comss ;; dnsai) echo DNS-AI ;; bezmezhau) echo Bezmezhau ;; - yandex_family) echo "Яндекс Семейный" ;; yandex_safe) echo "Яндекс Безопасный" ;; geohide_ru) echo "GeoHide RU" ;; geohide_eu) echo "GeoHide EU" ;; + cloudflare_sec) echo "Cloudflare · Без вирусов" ;; cloudflare_fam) echo "Cloudflare · Семейный" ;; quad9_open) echo "Quad9 · Без фильтра" ;; + controld_sec) echo "Control D · Без вирусов" ;; controld_fam) echo "Control D · Семейный" ;; dns4eu_sec) echo "DNS4EU · Защитный" ;; dns4eu_fam) echo "DNS4EU · Детский" ;; + yandex) echo "Яндекс" ;; yandex_family) echo "Яндекс · Семейный" ;; yandex_safe) echo "Яндекс · Безопасный" ;; geohide_ru) echo "GeoHide RU" ;; geohide_eu) echo "GeoHide EU" ;; geohide_us) echo "GeoHide US" ;; malw) echo MALW ;; zapretmoe) echo zapret.moe ;; webshift) echo WebShift ;; dagami) echo Dagami ;; controld) echo "Control D" ;; dns4eu) echo DNS4EU ;; https://*) _doh_own_title "$1" | grep . || printf '%s' "$1" | sed 's#^https://\([^/]*\).*#\1#' ;; *) echo "$1" ;; @@ -9670,6 +9873,16 @@ STL_VPN_DEV="zm_vpn" _stl_say() { echo "==> $*"; } _st_onfail() { [ "$(cat /etc/zm-steer/onfail 2>/dev/null)" = drop ] && echo drop || echo direct; } +_st_dnsmode() { [ "$(cat /etc/zm-steer/dns.mode 2>/dev/null)" = router ] && echo router || echo tun; } +_st_realip() { [ "$(cat /etc/zm-steer/realip.mode 2>/dev/null)" = off ] && echo off || echo on; } +_st_dnsopt_save() { + mkdir -p /etc/zm-steer + rm -f /etc/zm-steer/realip + case "$1" in + dnsmode) if [ "$2" = router ]; then echo router > /etc/zm-steer/dns.mode; else rm -f /etc/zm-steer/dns.mode; fi ;; + realip) if [ "$2" = off ]; then echo off > /etc/zm-steer/realip.mode; else rm -f /etc/zm-steer/realip.mode; fi ;; + esac +} _stl_t() { _zm_tmo "$@"; } _stl_out() { case "$1" in vpn) echo "$STL_VPN_OUT" ;; *) echo "$STL_WARP_OUT" ;; esac; } _stl_js() { printf '"%s"' "$(esc "$1")"; } @@ -9886,7 +10099,10 @@ _stl_rule() { [ -n "$dom" ] && body="$body${body:+,}\"domains_file\":[$dom]" [ -n "$pfx" ] && body="$body${body:+,}\"prefixes_file\":[$pfx]" lists="$lists${lists:+,}$(_stl_js "$lid"):{$body}" - rules="$rules${rules:+,}{\"name\":$(_stl_js "$lab"),\"to\":$(_stl_js "$lid"),\"out\":\"$out\"}" + x="" + [ "$(_st_realip)" = on ] && x=',"resolve":"realip"' + [ -z "$ZM_STL_NODNS" ] && [ "$(_st_dnsmode)" = tun ] && x="$x,\"dns\":\"zm_tun\"" + rules="$rules${rules:+,}{\"name\":$(_stl_js "$lab"),\"to\":$(_stl_js "$lid"),\"out\":\"$out\"$x}" } _stl_tun_json() { @@ -9919,7 +10135,7 @@ _stl_vpn_outs() { _stl_spec() { local tab k a b c lists="" rules="" outs="" lan="" lid="" lab="" srs="" dom="" pfx="" d m - local clients="" drules="" cid dout byp="" xd="" xp="" xc="" xr="" + local clients="" drules="" cid dout byp="" xd="" xp="" xc="" xr="" x="" dnsj="" tab="$(printf '\t')" local out="" body wp=latency while IFS="$tab" read -r k a b c; do @@ -9972,7 +10188,13 @@ _stl_spec() { [ -n "$drules" ] && [ -n "$rules" ] && drules="$drules," rules="$drules$rules" [ -n "$clients" ] && clients="\"clients\":{$clients}," - printf '{"version":2,"lan":{"devices":[%s]},%s"lists":{%s},"outputs":{%s},"rules":[%s]}\n' "${lan:-\"br-lan\"}" "$clients" "$lists" "$outs" "$rules" + case "$rules" in *'"dns":"zm_tun"'*) + dnsj="\"dns\":{\"upstreams\":{\"zm_tun\":{\"servers\":[\"zm_tun_cf\",\"zm_tun_gg\",\"zm_dir_ya\"],\"mode\":\"failover\"}," + dnsj="$dnsj\"zm_tun_cf\":{\"url\":\"https://cloudflare-dns.com/dns-query\",\"ips\":[\"1.1.1.1\",\"1.0.0.1\"],\"out\":\"$out\"}," + dnsj="$dnsj\"zm_tun_gg\":{\"url\":\"https://dns.google/dns-query\",\"ips\":[\"8.8.8.8\",\"8.8.4.4\"],\"out\":\"$out\"}," + dnsj="$dnsj\"zm_dir_ya\":{\"url\":\"https://common.dot.dns.yandex.net/dns-query\",\"ips\":[\"77.88.8.8\",\"77.88.8.1\"]}}}," + ;; esac + printf '{"version":2,%s"lan":{"devices":[%s]},%s"lists":{%s},"outputs":{%s},"rules":[%s]}\n' "$dnsj" "${lan:-\"br-lan\"}" "$clients" "$lists" "$outs" "$rules" } _stl_check() { @@ -9994,10 +10216,14 @@ stl_apply() { _stl_spec "$1" > "$new" || { rm -f "$new"; echo "ОШИБКА: в настройке нет ни одного списка"; return 1; } _stl_say "Проверяем правила: $(grep -o '"out":' "$new" | wc -l | tr -d ' ')" if ! err="$(_stl_check "$new")"; then - rm -f "$new" - echo "ОШИБКА: ядро steer отвергло настройку" - printf '%s\n' "$err" | tail -n 5 - return 1 + if grep -q '"zm_tun"' "$new" && ( ZM_STL_NODNS=1; _stl_spec "$1" ) > "$new" && err="$(_stl_check "$new")"; then + _stl_say "Ядро steer не умеет DNS через туннель — правила применены без него. Обновите ядро steer, чтобы адреса сервисов узнавались через туннель" + else + rm -f "$new" + echo "ОШИБКА: ядро steer отвергло настройку" + printf '%s\n' "$err" | tail -n 5 + return 1 + fi fi [ "$(stl_spec_whose)" = none ] && [ -s "$STL_SPEC" ] && [ ! -f "$STL_KEEP" ] && cp -f "$STL_SPEC" "$STL_KEEP" rm -f "$prev"; [ -s "$STL_SPEC" ] && cp -f "$STL_SPEC" "$prev" @@ -11918,7 +12144,7 @@ _st_model() { local id rm -f "$ST_RUN"/used.* "$ST_RUN"/srs.*.done mkdir -p "$ST_RUN" - printf 'lan'; for id in $(_zm_lan_devs); do printf '\t%s' "$id"; done; echo + printf 'lan'; for id in $(_st_lan_devs); do printf '\t%s' "$id"; done; echo if _st_use_vpn; then printf 'vpn\t%s\n' "$ST_SUB" _st_sub_filter @@ -12650,7 +12876,7 @@ _st_spec_quick() { _st_vpn_zone on mkdir -p "$ST_RUN" { - printf 'lan'; for id in $(_zm_lan_devs); do printf '\t%s' "$id"; done; echo + printf 'lan'; for id in $(_st_lan_devs); do printf '\t%s' "$id"; done; echo printf 'vpn\t%s\n' "$ST_SUB" _st_sub_filter _st_dev_model @@ -12669,7 +12895,7 @@ _st_spec_quick_warp() { _st_engine_ready || return 1 mkdir -p "$ST_RUN" { - printf 'lan'; for id in $(_zm_lan_devs); do printf '\t%s' "$id"; done; echo + printf 'lan'; for id in $(_st_lan_devs); do printf '\t%s' "$id"; done; echo printf 'wpick\t%s\n' "$(_st_warp_pick)" printf 'warp'; for id in $(_st_warp_use); do printf '\t%s' "$id"; done; echo _st_dev_model @@ -12689,6 +12915,20 @@ _st_respec() { _st_kick } +do_steer_dnsopt() { + _st_phase rules + rm -f "$ST_STOP_FLAG" + _st_dnsopt_save "$1" "$2" + case "$1:$2" in + dnsmode:router) _rb_say "Адреса выбранных сервисов узнаёт DNS роутера" ;; + dnsmode:*) _rb_say "Адреса выбранных сервисов узнаются через туннель — как при полном VPN" ;; + realip:on) _rb_say "Точно по IP: роутер отдаёт настоящие адреса выбранных сервисов" ;; + realip:*) _rb_say "Точно по IP выключено: роутер отдаёт подменные адреса" ;; + esac + _st_respec + _rb_say "Готово" +} + do_steer_onfail() { _st_phase rules rm -f "$ST_STOP_FLAG" @@ -13016,10 +13256,10 @@ steer_status() { if [ "$vup" = true ] && [ "$vexit" = vpn ] && [ "$off" = false ]; then _st_vpn_live; case $? in 0) vlive=true ;; 1) vlive=false ;; esac fi - printf '{"running":%s,"phase":"%s","blocker":"%s","installed":%s,"stopped":%s,"version":"%s","steer_running":%s,"channels":%s,"warp_up":%s,"warp_colo":"%s","warp_host":"%s","warp_port":"%s","warp_hs_age":"%s","warp_rx":%s,"warp_tx":%s,"autorestart":"%s","dns_conflict":%s,"doh":"%s","doh_mode":"%s","exit":"%s","vpn_up":%s,"vpn_live":%s,"has_sub":%s,"sub_label":"%s","latest":"%s","ext":%s,"warp_on":%s,"warp_mode":"%s","warp_pick":"%s","onfail":"%s","warp_own_saved":%s,"tunnels":%s,"warp_active":"%s","catalog":%s,"wfix":%s,"lists_via":%s,"failopen":%s,"hosts_extra":%s,"devices":[%s],"services":[%s]}\n' \ + printf '{"running":%s,"phase":"%s","blocker":"%s","installed":%s,"stopped":%s,"version":"%s","steer_running":%s,"channels":%s,"warp_up":%s,"warp_colo":"%s","warp_host":"%s","warp_port":"%s","warp_hs_age":"%s","warp_rx":%s,"warp_tx":%s,"autorestart":"%s","dns_conflict":%s,"doh":"%s","doh_mode":"%s","exit":"%s","vpn_up":%s,"vpn_live":%s,"has_sub":%s,"sub_label":"%s","latest":"%s","ext":%s,"warp_on":%s,"warp_mode":"%s","warp_pick":"%s","onfail":"%s","warp_own_saved":%s,"tunnels":%s,"warp_active":"%s","catalog":%s,"wfix":%s,"lists_via":%s,"failopen":%s,"hosts_extra":%s,"dnsmode":"%s","realip":"%s","devices":[%s],"services":[%s]}\n' \ "$running" "$(esc "$phase")" "$(esc "$blk")" "$installed" "$off" "$(esc "$ver")" "$run" "${chans:-0}" "$warp_up" "$(esc "$colo")" \ "$(esc "$host")" "$(esc "$port")" "$age" "${rx:-0}" "${tx:-0}" "$(_st_cron_get)" "$dns" "$doh" "$(_doh_force_mode)" \ - "$vexit" "$vup" "$vlive" "$vsub" "$(esc "$(_st_sub_label)")" "$(esc "$latest")" "$ext" "$won" "$(_st_warp_own && echo own || echo auto)" "$(_st_warp_pick)" "$(_st_onfail)" "$(_st_own_saved && echo true || echo false)" "$(_st_tunnels_json)" "$active" "$(_st_cat_json)" "$(_st_wfix_json)" "$([ -f "$ST_DIR/lists.via" ] && echo true || echo false)" "$failed" "$(_hosts_extra)" "$(_st_devs_json)" "$svc" + "$vexit" "$vup" "$vlive" "$vsub" "$(esc "$(_st_sub_label)")" "$(esc "$latest")" "$ext" "$won" "$(_st_warp_own && echo own || echo auto)" "$(_st_warp_pick)" "$(_st_onfail)" "$(_st_own_saved && echo true || echo false)" "$(_st_tunnels_json)" "$active" "$(_st_cat_json)" "$(_st_wfix_json)" "$([ -f "$ST_DIR/lists.via" ] && echo true || echo false)" "$failed" "$(_hosts_extra)" "$(_st_dnsmode)" "$(_st_realip)" "$(_st_devs_json)" "$svc" } _st_active_if() { @@ -14509,7 +14749,7 @@ steer_action() { local action="$1" mode="$2" w case "$action" in diag|wfix_tick|explain) ;; *) rm -f "$ST_VPN_PROBE" ;; esac case "$action" in - install|apply|start|stop|remove|warp_restart|warp_endpoint|warp_recreate|lists|engine|warp_setup|warp_fix|warp_fixkeys|warp_own|warp_mode|warp_pick|onfail) + install|apply|start|stop|remove|warp_restart|warp_endpoint|warp_recreate|lists|engine|warp_setup|warp_fix|warp_fixkeys|warp_own|warp_mode|warp_pick|onfail|dnsmode|realip) _st_running && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } [ "$action" = remove ] || [ -z "$(_st_blocker)" ] || { echo '{"error":"ядро steer сейчас настраивает не Zapret Manager — панель его не трогает"}'; return 1; } case "$action" in @@ -14537,6 +14777,15 @@ steer_action() { warp_mode) [ "$mode" = auto ] || { echo '{"error":"неизвестный режим"}'; return 1; } job_start steer do_steer_warp_auto ;; + dnsmode|realip) + if [ "$action" = dnsmode ]; then + case "$mode" in tun|router) ;; *) echo '{"error":"неизвестный режим"}'; return 1 ;; esac + else + case "$mode" in on|off) ;; *) echo '{"error":"неизвестный режим"}'; return 1 ;; esac + fi + if _st_installed && [ ! -f "$ST_OFF" ] && _st_owns "steer-spec"; then job_start steer do_steer_dnsopt "$action" "$mode" + else _st_dnsopt_save "$action" "$mode"; printf '{"ok":true,"saved":true}\n' + fi ;; onfail) case "$mode" in drop|direct) ;; *) echo '{"error":"неизвестный режим"}'; return 1 ;; esac if _st_installed && [ ! -f "$ST_OFF" ] && _st_owns "steer-spec"; then job_start steer do_steer_onfail "$mode" @@ -15812,10 +16061,61 @@ _term_port() { echo "$p" } +# Где слушает терминал. ttyd с «-i br-lan» (интерфейс @lan) через libwebsockets привязывает +# сокет к интерфейсу (SO_BINDTODEVICE): подключения, пришедшие через tailscale0, он отбрасывает, +# поэтому из Tailscale терминал не открывается. Выбрать tailscale0, как советуют, — значит +# закрыть его дома. Правильно — слушать все интерфейсы (без -i), а от интернета закрывает +# firewall: вход из зоны интернета закрыт, порт 7681 там не открыт. Это режим «all». +TERM_ALL="$ZM_STATE_DIR/ttyd.all" +_term_iface() { uci -q get 'ttyd.@ttyd[0].interface'; } +# home — только домашняя сеть; all — дом и Tailscale; ts — только Tailscale; other — своя настройка +_term_mode() { + local i + i="$(_term_iface)" + case "$i" in + '') [ -f "$TERM_ALL" ] && echo all || echo home ;; + @lan|br-lan) echo home ;; + @tailscale|tailscale0) echo ts ;; + *) echo other ;; + esac +} +_term_ts_ok() { case "$(_term_mode)" in all|ts) return 0 ;; esac; return 1; } + +# Режим «all» допустим, только пока вход из интернета закрыт firewall'ом. Открыли — возвращаем +# терминал в домашнюю сеть (вызывается при каждом запуске терминала и раз в минуту сторожем Tailscale). +_term_all_guard() { + [ -f "$TERM_ALL" ] || return 1 + [ -z "$(_term_iface)" ] || { rm -f "$TERM_ALL"; return 1; } + _tsc_wan_closed && return 1 + uci -q set 'ttyd.@ttyd[0].interface=@lan'; uci -q commit ttyd + rm -f "$TERM_ALL" + logger -t zapret-manager "Терминал: вход из интернета в firewall открыт — терминал снова только для домашней сети" + return 0 +} + +# $1: all — дом и Tailscale; home — только дом +_term_set_mode() { + _term_config + case "$1" in + all) + _tsc_wan_closed || { echo "вход из интернета в firewall не закрыт — открыть терминал на всех интерфейсах небезопасно"; return 1; } + uci -q delete 'ttyd.@ttyd[0].interface' + mkdir -p "$ZM_STATE_DIR"; : > "$TERM_ALL" ;; + home) + uci -q set 'ttyd.@ttyd[0].interface=@lan' + rm -f "$TERM_ALL" ;; + *) echo "неизвестный режим"; return 1 ;; + esac + uci -q commit ttyd + _term_running && /etc/init.d/ttyd restart >/dev/null 2>&1 + return 0 +} + _term_config() { [ -f /etc/config/ttyd ] || : > /etc/config/ttyd uci -q get 'ttyd.@ttyd[0]' >/dev/null 2>&1 || uci -q add ttyd ttyd >/dev/null 2>&1 - [ -n "$(uci -q get 'ttyd.@ttyd[0].interface')" ] || uci -q set 'ttyd.@ttyd[0].interface=@lan' + _term_all_guard + [ -n "$(_term_iface)" ] || [ -f "$TERM_ALL" ] || uci -q set 'ttyd.@ttyd[0].interface=@lan' [ -n "$(uci -q get 'ttyd.@ttyd[0].port')" ] || uci -q set 'ttyd.@ttyd[0].port=7681' [ -n "$(uci -q get 'ttyd.@ttyd[0].command')" ] || uci -q set 'ttyd.@ttyd[0].command=/bin/login' uci -q commit ttyd @@ -15847,8 +16147,11 @@ term_status() { _term_installed && inst=true _term_running && run=true _term_enabled && en=true - printf '{"installed":%s,"running":%s,"enabled":%s,"version":"%s","port":%s,"lan_ip":"%s","job":%s}\n' \ + printf '{"installed":%s,"running":%s,"enabled":%s,"version":"%s","port":%s,"lan_ip":"%s","mode":"%s","iface":"%s","ts":%s,"ts_ip":"%s","wan_closed":%s,"job":%s}\n' \ "$inst" "$run" "$en" "$(esc "$(_term_version)")" "$(_term_port)" "$(esc "$(_zm_lan_ip)")" \ + "$(_term_mode)" "$(esc "$(_term_iface)")" "$(_tsc_installed && echo true || echo false)" \ + "$(esc "$(_tsc_running && _zm_tmo 5 tailscale ip -4 2>/dev/null | head -n1)")" \ + "$(_tsc_wan_closed && echo true || echo false)" \ "$(_job_alive term && echo true || echo false)" } @@ -15872,6 +16175,7 @@ do_term_remove() { local rc=0 _zm_pkg_wipe luci-i18n-ttyd-ru luci-app-ttyd ttyd || rc=1 _zm_wipe /etc/config/ttyd /etc/config/ttyd-opkg /etc/config/ttyd.apk-new /etc/init.d/ttyd /etc/rc.d/*ttyd || rc=1 + rm -f "$TERM_ALL" _zm_rm_listed terminal || rc=1 rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* 2>/dev/null _term_installed && rc=1 @@ -15893,10 +16197,1040 @@ term_action() { _job_alive term && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } _term_start >/dev/null 2>&1 || { echo '{"error":"ttyd не запустился — смотрите журнал роутера"}'; return 1; } printf '{"ok":true}\n' ;; + ts_on|ts_off) + _term_installed || { echo '{"error":"терминал не установлен"}'; return 1; } + _job_alive term && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } + if j="$(_term_set_mode "$([ "$a" = ts_on ] && echo all || echo home)")"; then printf '{"ok":true,"mode":"%s"}\n' "$(_term_mode)" + else printf '{"error":"%s"}\n' "$(esc "$j")"; return 1; fi ;; *) echo '{"error":"неизвестное действие"}'; return 1 ;; esac } +TSC_DIR="$ZM_STATE_DIR/tailscale" +TSC_ON="$TSC_DIR/on" +TSC_NOEXIT="$TSC_DIR/noexit" +TSC_LAN="$TSC_DIR/lan" +TSC_LS_PREV="$TSC_DIR/localservice" +TSC_ZR_OWN="$TSC_DIR/zapret.own" +TSC_STATE="$TSC_DIR/state" +TSC_SRV="$TSC_DIR/server" +TSC_KEY="$TSC_DIR/authkey" +TSC_DEV="tailscale0" +TSC_NET="tailscale" +TSC_FW_RULE="zm_tailscale" +TSC_NFT="/usr/share/nftables.d/chain-pre/forward/30-zm-tailscale.nft" +TSC_HOOK="/usr/libexec/zm-tailscale-tgws" +TSC_SYSCTL="/etc/sysctl.d/90-zm-tailscale.conf" +TSC_UPLOG="$JOBS_DIR/tsc.up.log" +TSC_NEED_KB=10240 + +_tsc_say() { echo "==> $*"; } +_tsc_installed() { [ -x /usr/sbin/tailscaled ] && [ -e /usr/sbin/tailscale ]; } +_tsc_running() { pidof tailscaled >/dev/null 2>&1; } +_tsc_enabled() { ls /etc/rc.d/S*tailscale >/dev/null 2>&1; } +# Состояние и настройки — через LocalAPI на сокете tailscaled: ответ за миллисекунды. +# Команда tailscale в сборке для OpenWrt сжата UPX и при каждом запуске распаковывается +# в память целиком — на слабом роутере это секунды. Она остаётся запасным путём. +TSC_SOCK="/var/run/tailscale/tailscaled.sock" +_tsc_api() { + local o + [ -S "$TSC_SOCK" ] && command -v curl >/dev/null 2>&1 || return 1 + o="$(curl -sf --max-time 6 --unix-socket "$TSC_SOCK" "http://local-tailscaled.sock/localapi/v0/$1" 2>/dev/null)" || return 1 + case "$o" in '{'*) printf '%s\n' "$o" ;; *) return 1 ;; esac +} +# Версия меняется только с файлом программы — запоминаем её по времени изменения файла +_tsc_ver() { + local f="$TSC_DIR/version" k v + k="$(date -r /usr/sbin/tailscaled +%s 2>/dev/null)" + if [ -n "$k" ] && [ -s "$f" ]; then + read -r v 2>/dev/null < "$f" + [ "${v%% *}" = "$k" ] && { printf '%s\n' "${v#* }"; return 0; } + fi + v="$(tailscale version 2>/dev/null | head -n1)" + [ -n "$v" ] && [ -n "$k" ] && { mkdir -p "$TSC_DIR"; printf '%s %s\n' "$k" "$v" > "$f" 2>/dev/null; } + printf '%s\n' "$v" +} +_tsc_json() { _tsc_api status || _zm_tmo 8 tailscale status --json 2>/dev/null; } +_tsc_free_kb() { df -k /overlay 2>/dev/null | awk 'NR == 2 { print $4; exit }'; } +_tsc_port() { local p; p="$(uci -q get tailscale.settings.port)"; case "$p" in ''|*[!0-9]*) echo 41641 ;; *) echo "$p" ;; esac; } + +_tsc_lan_cidr() { + local d a="" + for d in $(_zm_lan_devs); do + [ "$d" = "$TSC_DEV" ] && continue + a="$(ip -4 -o addr show dev "$d" 2>/dev/null | awk '{ print $4; exit }')" + [ -n "$a" ] && break + done + [ -n "$a" ] || return 1 + awk -v a="$a" 'BEGIN { + split(a, p, "/"); split(p[1], o, "."); m = (p[2] == "" ? 32 : p[2] + 0) + ip = ((o[1] * 256 + o[2]) * 256 + o[3]) * 256 + o[4]; s = 2 ^ (32 - m); n = int(ip / s) * s + printf "%d.%d.%d.%d/%d\n", int(n / 16777216) % 256, int(n / 65536) % 256, int(n / 256) % 256, n % 256, m + }' +} + +# Имя роутера для Tailscale: «_» и точка ломают вход на Routerich (ссылка входа неактивна), +# поэтому, если в имени системы они есть, передаём исправленное (только буквы, цифры, «-»). +_tsc_hostname() { + local h c + h="$(uci -q get system.@system[0].hostname)" + [ -n "$h" ] || h="$(cat /proc/sys/kernel/hostname 2>/dev/null)" + [ -n "$h" ] || return 1 + c="$(printf '%s' "$h" | tr -c 'A-Za-z0-9-' '-' | sed 's/--*/-/g; s/^-//; s/-$//' | cut -c1-63)" + [ -n "$c" ] || c=openwrt + [ "$c" != "$h" ] && echo "$c" +} + +_tsc_flags() { + local e=true r="" h + [ -f "$TSC_NOEXIT" ] && e=false + [ -f "$TSC_LAN" ] && r="$(_tsc_lan_cidr)" + printf -- '--accept-dns=false --advertise-exit-node=%s --advertise-routes=%s' "$e" "$r" + h="$(_tsc_hostname)" && printf -- ' --hostname=%s' "$h" + return 0 +} + +_tsc_srv() { cat "$TSC_SRV" 2>/dev/null; } +_tsc_kind() { + case "$(_tsc_srv)" in + '') echo official ;; + *://rc.routerich.ru|*://rc.routerich.ru/*) echo routerich ;; + *) echo custom ;; + esac +} +_tsc_admin() { + case "$(_tsc_kind)" in + official) echo "https://login.tailscale.com/admin" ;; + routerich) echo "https://remote.routerich.ru" ;; + esac +} +_tsc_upflags() { + local s + s="$(_tsc_srv)" + printf '%s --netfilter-mode=off' "$(_tsc_flags)" + [ -n "$s" ] && printf ' --login-server=%s' "$s" + return 0 +} + +TSC_GH_BASES="https://raw.githubusercontent.com/gunanovo/openwrt-tailscale/refs/heads/feed https://cdn.jsdelivr.net/gh/gunanovo/openwrt-tailscale@feed" +TSC_LUCI_BASES="https://raw.githubusercontent.com/Tokisaki-Galaxy/luci-app-tailscale-community/gh-pages/all https://tokisaki-galaxy.github.io/luci-app-tailscale-community/all https://cdn.jsdelivr.net/gh/Tokisaki-Galaxy/luci-app-tailscale-community@gh-pages/all" +TSC_SRC="$TSC_DIR/source" + +_tsc_arch() { + local a + a="$(awk -F\' '/DISTRIB_ARCH/ { print $2 }' /etc/openwrt_release 2>/dev/null)" + [ -n "$a" ] || a="$(cat /etc/apk/arch 2>/dev/null)" + [ -n "$a" ] || a="$(opkg print-architecture 2>/dev/null | awk '$2 != "all" && $2 != "noarch" { a = $2 } END { print a }')" + printf '%s' "$a" | tr -d '\r\n\t ' +} + +# $1 — путь от корня зеркала, $2 — куда сохранить, $3 — список зеркал, $4 — сек. +_tsc_fetch() { + local b + for b in $3; do + rm -f "$2" + _zm_gh_get "$b/$1" "$2" "" "${4:-120}" && _zm_dl_sane "$2" && return 0 + done + rm -f "$2" + return 1 +} + +_tsc_gh_latest() { + local f="$JOBS_DIR/tsc.ver.$$" v + mkdir -p "$JOBS_DIR" + _tsc_fetch "$(_tsc_arch)/version" "$f" "$TSC_GH_BASES" 15 || return 1 + v="$(head -c 64 "$f" | tr -d ' \r\n')" + rm -f "$f" + case "$v" in ''|*[!0-9A-Za-z.-]*) return 1 ;; esac + echo "$v" +} + +_tsc_vnum() { printf '%s' "$1" | sed 's/-r[0-9]*$//; s/[^0-9.].*$//'; } + +# Уменьшенная сборка Tailscale для OpenWrt (GuNanOvO/openwrt-tailscale) с GitHub +_tsc_install_gh() { + local arch ver f sum want got + arch="$(_tsc_arch)" + [ -n "$arch" ] || { echo " · Не удалось узнать архитектуру роутера"; return 1; } + echo " → Архитектура: $arch, ищем сборку на GitHub" + ver="$(_tsc_gh_latest)" || { echo " · GitHub не ответил или сборки для $arch нет"; return 1; } + f="$JOBS_DIR/tailscale-$ver.$RAZ" + sum="$JOBS_DIR/tailscale.$RAZ.sha256" + echo " → Скачиваем Tailscale $ver ($RAZ, около 7 МБ)" + _tsc_fetch "$arch/tailscale-$ver.$RAZ" "$f" "$TSC_GH_BASES" 300 || { echo " · Пакет не скачался ни с GitHub, ни с зеркала"; return 1; } + _tsc_fetch "$arch/$RAZ.sha256" "$sum" "$TSC_GH_BASES" 20 || { rm -f "$f"; echo " · Не скачалась контрольная сумма"; return 1; } + want="$(head -c 200 "$sum" | tr -d ' \r\n' | cut -c1-64)" + got="$(sha256sum "$f" 2>/dev/null | cut -d' ' -f1)" + rm -f "$sum" + if [ -z "$want" ] || [ "$want" != "$got" ]; then + rm -f "$f"; echo " · Контрольная сумма не сошлась — файл повреждён, ставить не будем"; return 1 + fi + echo " ✓ Контрольная сумма сошлась" + $INSTALL "$f" || { rm -f "$f"; return 1; } + rm -f "$f" + _tsc_installed || return 1 + mkdir -p "$TSC_DIR"; echo github > "$TSC_SRC" + return 0 +} + +# Страница Tailscale для LuCI (Tokisaki-Galaxy/luci-app-tailscale-community) +_tsc_install_luci() { + local idx="$JOBS_DIR/tsc.luci.idx" ver f + if _tsc_fetch Packages "$idx" "$TSC_LUCI_BASES" 20; then + ver="$(awk '/^Package: luci-app-tailscale-community$/ { p = 1; next } /^Package:/ { p = 0 } p && /^Version:/ { print $2; exit }' "$idx")" + rm -f "$idx" + case "$ver" in ''|*[!0-9A-Za-z.~+-]*) ver="" ;; esac + if [ -n "$ver" ]; then + if [ "$RAZ" = apk ]; then f="luci-app-tailscale-community-$ver.apk"; else f="luci-app-tailscale-community_${ver}_all.ipk"; fi + if _tsc_fetch "$f" "$JOBS_DIR/$f" "$TSC_LUCI_BASES" 120 && $INSTALL "$JOBS_DIR/$f" >/dev/null 2>&1; then + rm -f "$JOBS_DIR/$f" + return 0 + fi + rm -f "$JOBS_DIR/$f" + fi + fi + $INSTALL luci-app-tailscale-community >/dev/null 2>&1 +} + +_tsc_luci_path() { grep -ho '"admin/[A-Za-z0-9_-]*/tailscale"' /usr/share/luci/menu.d/*.json 2>/dev/null | head -n1 | tr -d '"'; } +_tsc_luci_app() { [ -n "$(_tsc_luci_path)" ]; } + +# Настройки Tailscale в /etc/config/tailscale: их читает LuCI-приложение luci-app-tailscale-community +# и его служба tailscale-settings применяет их при каждой загрузке. Без этой записи она +# выключила бы выход в интернет (advertise_exit_node по умолчанию 0). +_tsc_uci_sync() { + local r e l + uci -q show tailscale >/dev/null 2>&1 || return 0 + uci -q get tailscale.settings >/dev/null || uci set tailscale.settings=settings + e=1; [ -f "$TSC_NOEXIT" ] && e=0 + uci set tailscale.settings.advertise_exit_node="$e" + uci set tailscale.settings.dns_mode=disabled + uci set tailscale.settings.service_enabled=1 + uci set tailscale.settings.disable_fw_config=1 + r="$(_tsc_lan_cidr)" + if [ -n "$r" ]; then + l="$(uci -q get tailscale.settings.advertise_routes | tr ' ,' '\n\n' | grep . | grep -vxF "$r")" + [ -f "$TSC_LAN" ] && l="$(printf '%s\n%s' "$l" "$r" | grep .)" + uci -q delete tailscale.settings.advertise_routes + for e in $l; do uci add_list tailscale.settings.advertise_routes="$e"; done + fi + uci commit tailscale +} + +_tsc_start() { + local t=0 + /etc/init.d/tailscale enable >/dev/null 2>&1 + /etc/init.d/tailscale restart >/dev/null 2>&1 + while [ "$t" -lt 20 ]; do + [ -n "$(_tsc_json | jsonfilter -e '@.BackendState' 2>/dev/null)" ] && { _tsc_prefs >/dev/null 2>&1; return 0; } + sleep 1; t=$((t + 1)) + done + echo "ОШИБКА: служба Tailscale не ответила за 20 секунд — смотрите журнал роутера (logread | grep tailscale)" + return 1 +} + +_tsc_wan_closed() { + local s i + s="$(_zm_zone_sec "$(_zm_wan_zone)")" || return 1 + i="$(uci -q get "firewall.$s.input")" + [ "$i" = REJECT ] || [ "$i" = DROP ] +} + +_tsc_nft_text() { + printf '%s\n' \ + '# Zapret Manager: Tailscale. Файл создаёт и убирает панель.' \ + 'iifname "tailscale0" meta nfproto ipv6 counter reject with icmpv6 type admin-prohibited comment "zm-tailscale: no IPv6"' \ + 'iifname "tailscale0" tcp flags syn tcp option maxseg size set 1240 comment "zm-tailscale: MSS"' \ + 'oifname "tailscale0" tcp flags syn tcp option maxseg size set 1240 comment "zm-tailscale: MSS"' +} + +_tsc_hook_text() { + cat <<'EOF' +#!/bin/sh +# Zapret Manager: Telegram через sTGWS для телефонов из Tailscale. +# Файл создаёт и убирает панель; запускается из /etc/init.d/tgws. +( + # адрес роутера в домашней сети — так же, как его находит панель (любое имя сети, мост или нет) + ip="$(/opt/zapret-manager-luci/backend.sh tsc_lan_ip 2>/dev/null | head -n1)" + [ -n "$ip" ] || ip="$(ifstatus lan 2>/dev/null | jsonfilter -e "@['ipv4-address'][0].address" 2>/dev/null)" + [ -n "$ip" ] || ip="$(uci -q get network.lan.ipaddr | awk '{ print $1 }' | cut -d/ -f1)" + [ -n "$ip" ] || ip="$(ip -4 -o addr show dev br-lan 2>/dev/null | awk '{ print $4; exit }' | cut -d/ -f1)" + case "$ip" in *.*.*.*) ;; *) exit 0 ;; esac + i=0 + while ! nft list chain inet stgws tgws_redirect >/dev/null 2>&1 || ! nft list chain inet stgws prerouting_mark >/dev/null 2>&1; do + i=$((i + 1)); [ "$i" -gt 40 ] && exit 0; sleep 1 + done + for c in prerouting_mark tgws_redirect; do + nft -a list chain inet stgws "$c" 2>/dev/null | sed -n '/comment "zm-tailscale"/s/.*# handle \([0-9]*\)$/\1/p' | + while read -r h; do nft delete rule inet stgws "$c" handle "$h"; done + done + nft add rule inet stgws prerouting_mark iifname "tailscale0" ip daddr @tg_ip meta mark set meta mark and 0xefffffff or 0x50000000 counter return comment '"zm-tailscale"' + nft insert rule inet stgws tgws_redirect iifname "tailscale0" meta mark and 0x10000000 == 0x10000000 tcp dport '{ 80, 443, 5222 }' counter dnat ip to "$ip:8490" comment '"zm-tailscale"' +) /dev/null 2>&1 & +EOF +} + +_tsc_tg_rules() { nft list table inet stgws 2>/dev/null | grep -c 'comment "zm-tailscale"'; } +# правило перенаправления Telegram ведёт не на текущий адрес роутера — пересоздаём +_tsc_tg_ip_fix() { + local ip r + [ -x "$TSC_HOOK" ] || return 0 + r="$(nft list chain inet stgws tgws_redirect 2>/dev/null | grep 'zm-tailscale')" || return 0 + ip="$(_zm_lan_ip)" || return 0 + printf '%s' "$r" | grep -qF "to $ip:8490" && return 0 + "$TSC_HOOK" + return 0 +} + +_tsc_tg_ensure() { + [ -f "$TSC_ON" ] && [ -f /etc/init.d/tgws ] || return 0 + local t="$TSC_HOOK.zmtmp" + mkdir -p "${TSC_HOOK%/*}" + _tsc_hook_text > "$t" + if cmp -s "$t" "$TSC_HOOK"; then rm -f "$t"; else mv -f "$t" "$TSC_HOOK"; fi + chmod 0755 "$TSC_HOOK" + if ! grep -qF "$TSC_HOOK" /etc/init.d/tgws; then + grep -q 'procd_close_instance' /etc/init.d/tgws || return 1 + awk -v l=" [ -x $TSC_HOOK ] && $TSC_HOOK" '{ print } !d && /procd_close_instance/ { print l; d = 1 }' /etc/init.d/tgws > /etc/init.d/tgws.zmtmp && + cat /etc/init.d/tgws.zmtmp > /etc/init.d/tgws + rm -f /etc/init.d/tgws.zmtmp + fi + [ "$(_tsc_tg_rules)" -ge 2 ] 2>/dev/null || "$TSC_HOOK" + return 0 +} + +_tsc_tg_drop() { + local c h + [ -f /etc/init.d/tgws ] && grep -qF "$TSC_HOOK" /etc/init.d/tgws && sed -i "\#$TSC_HOOK#d" /etc/init.d/tgws + rm -f "$TSC_HOOK" + for c in prerouting_mark tgws_redirect; do + nft -a list chain inet stgws "$c" 2>/dev/null | sed -n '/comment "zm-tailscale"/s/.*# handle \([0-9]*\)$/\1/p' | + while read -r h; do nft delete rule inet stgws "$c" handle "$h" 2>/dev/null; done + done + return 0 +} + +_tsc_zapret() { + local f + for f in /opt/zapret/config /opt/zapret2/config; do + [ -f "$f" ] || continue + case "$1" in + add) + grep -qE '^IFACE_LAN="?[^" ]' "$f" || continue + grep '^IFACE_LAN=' "$f" | tr '"' ' ' | grep -qw "$TSC_DEV" && continue + sed -i "s/^IFACE_LAN=\"\{0,1\}\([^\"]*\)\"\{0,1\}\$/IFACE_LAN=\"\1 $TSC_DEV\"/" "$f" + mkdir -p "$TSC_DIR"; grep -qxF "$f" "$TSC_ZR_OWN" 2>/dev/null || echo "$f" >> "$TSC_ZR_OWN" + echo " ✓ ${f%/config}: интерфейс $TSC_DEV добавлен в IFACE_LAN" ;; + del) + grep -qxF "$f" "$TSC_ZR_OWN" 2>/dev/null || continue + sed -i "/^IFACE_LAN=/s/ $TSC_DEV//" "$f" ;; + esac + case "$f" in + /opt/zapret/*) [ -x /etc/init.d/zapret ] && /etc/init.d/zapret restart >/dev/null 2>&1 ;; + *) [ -x /etc/init.d/zapret2 ] && /etc/init.d/zapret2 restart >/dev/null 2>&1 ;; + esac + done + [ "$1" = del ] && rm -f "$TSC_ZR_OWN" + return 0 +} + +# Forkozz (netshift): трафик берётся только с интерфейсов из source_network_interfaces +_tsc_fk_has() { uci -q get netshift.settings.source_network_interfaces | tr ' ' '\n' | grep -qx "$TSC_DEV"; } +_tsc_fk() { + local cur ch=0 d + uci -q get netshift.settings >/dev/null || return 0 + cur="$(uci -q get netshift.settings.source_network_interfaces)" + case "$1" in + add) + _tsc_fk_has && return 0 + if [ -z "$cur" ]; then + for d in $(_zm_lan_devs); do + [ "$d" = "$TSC_DEV" ] || uci add_list netshift.settings.source_network_interfaces="$d" + done + fi + uci add_list netshift.settings.source_network_interfaces="$TSC_DEV" + mkdir -p "$TSC_DIR"; : > "$TSC_DIR/forkozz.own"; ch=1 + echo " ✓ Forkozz: устройства Tailscale добавлены к домашней сети" ;; + del) + [ -f "$TSC_DIR/forkozz.own" ] || return 0 + _tsc_fk_has && { uci -q del_list netshift.settings.source_network_interfaces="$TSC_DEV"; ch=1; } + rm -f "$TSC_DIR/forkozz.own" ;; + esac + [ "$ch" = 1 ] || return 0 + uci commit netshift + return 0 +} + +# Forkozz и Steer держат своё состояние (правила, метки соединений, DNS). После перезагрузки +# сети и firewall, перезапуска dnsmasq и особенно после того, как Tailscale побывал в режиме +# со своими правилами firewall, их нужно перезапустить — иначе они пишут «работает», а сервисы +# через них не открываются (проверено на роутере: помог перезапуск Forkozz). +_tsc_kick_bypass() { + local l="$TSC_DIR/kick.lock" + mkdir -p "$TSC_DIR" + # перезапуск уже идёт (сторож и страница могли сработать одновременно) — второй не нужен; + # зависшую больше 10 минут блокировку снимаем + find "$TSC_DIR" -maxdepth 1 -name kick.lock -mmin +10 -exec rmdir {} \; 2>/dev/null + mkdir "$l" 2>/dev/null || { echo " · Forkozz и Steer уже перезапускаются"; return 0; } + pidof tailscaled > "$TSC_DIR/kick.pid" 2>/dev/null + if [ -x /etc/init.d/netshift ] && [ -f /etc/config/netshift ] && /etc/init.d/netshift enabled 2>/dev/null; then + echo " → Перезапускаем Forkozz, чтобы он подхватил изменения сети" + _zm_run 180 /etc/init.d/netshift restart >/dev/null 2>&1 + fi + _tsc_steer_kick + rmdir "$l" 2>/dev/null + return 0 +} +_tsc_kick_bg() { ( _tsc_kick_bypass ) /dev/null 2>&1 & } + +_tsc_steer_on() { _st_installed && [ ! -f "$ST_OFF" ] && _st_owns "steer-spec" && [ -z "$(_st_blocker)" ]; } + +_tsc_steer_kick() { + _tsc_steer_on || return 0 + _job_alive steer && { echo " · Steer сейчас занят — устройства Tailscale попадут в его правила при следующем применении"; return 0; } + echo "==> Steer: обновляем правила для устройств Tailscale" + _st_respec + return 0 +} + +_st_lan_devs() { + local out + out="$(_zm_lan_devs)" + if [ -f "$TSC_ON" ]; then case " $out " in *" $TSC_DEV "*) ;; *) out="$out $TSC_DEV" ;; esac; fi + echo $out +} + +# Файл входа Tailscale должен лежать на флеш-памяти: в /tmp он пропадает при перезагрузке, +# и роутер становится новым устройством — с новым адресом 100.x, новым входом и одобрением. +_tsc_state_keep() { + local f n="/etc/tailscale/tailscaled.state" + f="$(uci -q get tailscale.settings.state_file)" + case "$f" in /tmp/*|/var/*|/dev/shm/*) ;; *) return 1 ;; esac + mkdir -p "${n%/*}" + [ -s "$f" ] && cp -f "$f" "$n" + uci set tailscale.settings.state_file="$n" + uci commit tailscale + echo " ✓ Вход Tailscale теперь хранится на флеш-памяти ($n) — после перезагрузки не придётся входить заново" + return 0 +} + +_tsc_setup() { + local z lz ch=0 p t + mkdir -p "$TSC_DIR"; : > "$TSC_ON" + _tsc_state_keep && _tsc_running && _tsc_start >/dev/null 2>&1 + _tsc_say "Настраиваем сеть и firewall" + if [ "$(uci -q get "network.$TSC_NET")" != interface ]; then + uci set "network.$TSC_NET=interface" + uci set "network.$TSC_NET.proto=none" + uci set "network.$TSC_NET.device=$TSC_DEV" + uci commit network + ubus call network reload >/dev/null 2>&1 + echo " ✓ Интерфейс «$TSC_NET» ($TSC_DEV) создан" + fi + lz="$(_zm_lan_zone)" + if z="$(_zm_zone_sec "$lz")"; then + case " $(uci -q get "firewall.$z.network") " in + *" $TSC_NET "*) ;; + *) uci add_list "firewall.$z.network=$TSC_NET"; ch=1; echo " ✓ Tailscale добавлен в зону «$lz» — телефон для роутера как домашнее устройство" ;; + esac + else + echo "!! Не нашли зону домашней сети в firewall — добавьте интерфейс «$TSC_NET» в неё вручную" + fi + p="$(_tsc_port)" + if [ "$(uci -q get "firewall.$TSC_FW_RULE")" != rule ] || [ "$(uci -q get "firewall.$TSC_FW_RULE.dest_port")" != "$p" ]; then + uci set "firewall.$TSC_FW_RULE=rule" + uci set "firewall.$TSC_FW_RULE.name=Allow-Tailscale" + uci set "firewall.$TSC_FW_RULE.src=$(_zm_wan_zone)" + uci set "firewall.$TSC_FW_RULE.proto=udp" + uci set "firewall.$TSC_FW_RULE.dest_port=$p" + uci set "firewall.$TSC_FW_RULE.target=ACCEPT" + ch=1 + echo " ✓ Открыт порт UDP $p — для прямого соединения с телефоном" + fi + [ "$ch" = 1 ] && uci commit firewall + t="$TSC_NFT.zmtmp" + mkdir -p "${TSC_NFT%/*}" + _tsc_nft_text > "$t" + if cmp -s "$t" "$TSC_NFT"; then rm -f "$t"; else mv -f "$t" "$TSC_NFT"; ch=1; echo " ✓ IPv6 из Tailscale отклоняется сразу, размер пакетов подогнан под туннель"; fi + if [ "$ch" = 1 ]; then + if fw4 check >/dev/null 2>&1 || ! command -v fw4 >/dev/null 2>&1; then + /etc/init.d/firewall reload >/dev/null 2>&1 + else + rm -f "$TSC_NFT" + echo "!! firewall не принял правила IPv6 и MSS — они отключены, остальное работает" + /etc/init.d/firewall reload >/dev/null 2>&1 + fi + fi + if [ "$(cat /proc/sys/net/ipv4/ip_forward 2>/dev/null)" != 1 ]; then + echo 'net.ipv4.ip_forward=1' > "$TSC_SYSCTL" + sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 + echo " ✓ Включена пересылка пакетов" + fi + if [ ! -f "$TSC_LS_PREV" ] && [ "$(uci -q get dhcp.@dnsmasq[0].localservice)" != 0 ]; then + if _tsc_wan_closed; then + uci -q get dhcp.@dnsmasq[0].localservice > "$TSC_LS_PREV" || : > "$TSC_LS_PREV" + uci set dhcp.@dnsmasq[0].localservice=0 + uci commit dhcp + /etc/init.d/dnsmasq restart >/dev/null 2>&1 + echo " ✓ DNS роутера отвечает и устройствам Tailscale (из интернета он по-прежнему закрыт)" + else + echo "!! Вход из интернета в firewall не закрыт — DNS роутера для Tailscale не открываем" + fi + fi + _tsc_uci_sync + _tsc_cron on + _tsc_fk add + _tsc_zapret add + if [ -f /etc/init.d/tgws ]; then + _tsc_tg_ensure && echo " ✓ sTGWS: Telegram с телефона пойдёт через мост" + fi + _tsc_kick_bypass + return 0 +} + +_tsc_unsetup() { + local z lz ch=0 v + _tsc_say "Убираем настройки сети и firewall" + _tsc_tg_drop + _tsc_nft_clean + lz="$(_zm_lan_zone)" + if z="$(_zm_zone_sec "$lz")"; then + case " $(uci -q get "firewall.$z.network") " in *" $TSC_NET "*) uci del_list "firewall.$z.network=$TSC_NET"; ch=1 ;; esac + fi + uci -q get "firewall.$TSC_FW_RULE" >/dev/null && { uci delete "firewall.$TSC_FW_RULE"; ch=1; } + [ "$ch" = 1 ] && uci commit firewall + [ -f "$TSC_NFT" ] && { rm -f "$TSC_NFT"; ch=1; } + [ "$ch" = 1 ] && /etc/init.d/firewall reload >/dev/null 2>&1 + if uci -q get "network.$TSC_NET" >/dev/null; then + uci delete "network.$TSC_NET"; uci commit network + ubus call network reload >/dev/null 2>&1 + fi + rm -f "$TSC_SYSCTL" + if [ -f "$TSC_LS_PREV" ]; then + v="$(cat "$TSC_LS_PREV")" + if [ -n "$v" ]; then uci set "dhcp.@dnsmasq[0].localservice=$v"; else uci -q delete dhcp.@dnsmasq[0].localservice; fi + uci commit dhcp + /etc/init.d/dnsmasq restart >/dev/null 2>&1 + rm -f "$TSC_LS_PREV" + fi + _tsc_zapret del + _tsc_fk del + _tsc_cron off + rm -f "$TSC_ON" + if [ -f "$TERM_ALL" ] && _term_installed; then + _term_set_mode home >/dev/null 2>&1 + echo " ✓ Терминал снова только для домашней сети" + fi + _tsc_kick_bypass + echo " ✓ Сеть и firewall вернулись к прежнему виду" + return 0 +} + +_tsc_dbg() { _tsc_api prefs || _zm_tmo 8 tailscale debug prefs 2>/dev/null; } +# $1 — уже полученный вывод `tailscale debug prefs` (необязательно) +_tsc_nf_on() { + local m + m="$(printf '%s' "${1:-$(_tsc_dbg)}" | jsonfilter -e '@.NetfilterMode' 2>/dev/null)" + [ -n "$m" ] && [ "$m" != 0 ] +} + +# После вызова: TSC_KICK=1, если Tailscale был в режиме со своими правилами firewall или +# остались его правила — тогда Forkozz и Steer надо перезапустить (_tsc_kick_bypass). +_tsc_prefs() { + local st + TSC_KICK="" + _tsc_nf_on "$1" && TSC_KICK=1 + _tsc_uci_sync + st="$(_tsc_json | jsonfilter -e '@.BackendState' 2>/dev/null)" + case "$st" in Running|NeedsMachineAuth|Starting) ;; + NeedsLogin|Stopped) _zm_tmo 20 tailscale set --netfilter-mode=off --accept-dns=false >/dev/null 2>&1; _tsc_nft_clean && TSC_KICK=1; return 0 ;; + *) return 0 ;; + esac + # shellcheck disable=SC2046 + if _zm_tmo 20 tailscale set $(_tsc_flags) --netfilter-mode=off >/dev/null 2>&1 || + _zm_tmo 25 tailscale up --reset $(_tsc_upflags) >/dev/null 2>&1 || + _zm_tmo 20 tailscale set $(_tsc_flags) >/dev/null 2>&1; then + _tsc_nft_clean && TSC_KICK=1 + return 0 + fi + return 1 +} + +# Применились ли наши настройки в самом tailscaled. Вход через LuCI (Account Settings) +# делает `tailscale login` без флагов: роутер в сети, но выходом в интернет себя не +# предлагает, пока не выполнить `tailscale set --advertise-exit-node ...`. +_tsc_prefs_ok() { + local p="$1" r c + [ -n "$p" ] || p="$(_tsc_dbg)" + [ -n "$p" ] || return 0 + r="$(printf '%s' "$p" | jsonfilter -e '@.AdvertiseRoutes[*]' 2>/dev/null)" + if [ -f "$TSC_NOEXIT" ]; then printf '%s\n' "$r" | grep -qx '0.0.0.0/0' && return 1 + else printf '%s\n' "$r" | grep -qx '0.0.0.0/0' || return 1; fi + c="$(_tsc_lan_cidr)" + if [ -n "$c" ]; then + if [ -f "$TSC_LAN" ]; then printf '%s\n' "$r" | grep -qxF "$c" || return 1 + else printf '%s\n' "$r" | grep -qxF "$c" && return 1; fi + fi + [ "$(printf '%s' "$p" | jsonfilter -e '@.CorpDNS' 2>/dev/null)" = true ] && return 1 + _tsc_nf_on "$p" && return 1 + _tsc_nft_left && return 1 + return 0 +} + +# Остатки правил Tailscale (режим netfilter on): таблицы ip/ip6 filter, nat, mangle. +# Удаляем только своё Tailscale: переходы в ts-*, цепочки ts-* и перенос метки соединения +# маской 0x00ff0000; таблицу целиком — только если в ней больше нет ни одного правила. +_tsc_nft_clean() { + local fam t out ch h n=0 + for fam in ip ip6; do + for t in filter nat mangle; do + out="$(nft -a list table "$fam" "$t" 2>/dev/null)" || continue + printf '%s\n' "$out" | grep -qE 'jump ts-|chain ts-|0x00ff0000' || continue + printf '%s\n' "$out" | awk '/^\tchain /{ c = $2 } /^\t\t/ && /# handle / && (/jump ts-/ || /0x00ff0000/) { h = $NF; print c, h }' | + while read -r ch h; do nft delete rule "$fam" "$t" "$ch" handle "$h" 2>/dev/null; done + for ch in $(printf '%s\n' "$out" | awk '/^\tchain ts-/ { print $2 }'); do + nft flush chain "$fam" "$t" "$ch" 2>/dev/null; nft delete chain "$fam" "$t" "$ch" 2>/dev/null + done + if ! nft -a list table "$fam" "$t" 2>/dev/null | grep -qE '^ .*# handle [0-9]+$'; then + nft delete table "$fam" "$t" 2>/dev/null + fi + n=$((n + 1)) + done + done + [ "$n" -gt 0 ] || return 1 + # Tailscale включал это для своих правил и сам не выключает; в OpenWrt по умолчанию 0 + sysctl -w net.ipv4.conf.all.src_valid_mark=0 >/dev/null 2>&1 + return 0 +} +_tsc_nft_left() { + local t + for t in $(nft list tables 2>/dev/null | awk '$1 == "table" && ($2 == "ip" || $2 == "ip6") && ($3 == "filter" || $3 == "nat" || $3 == "mangle") { print $2 "/" $3 }'); do + nft list table "${t%/*}" "${t#*/}" 2>/dev/null | grep -qE 'jump ts-|chain ts-|0x00ff0000' && return 0 + done + return 1 +} + +TSC_CRON_TAG="# zm-tailscale" +_tsc_cron() { + local l="* * * * * /opt/zapret-manager-luci/backend.sh tsc_watch >/dev/null 2>&1 $TSC_CRON_TAG" + if [ "$1" = on ]; then + [ "$(grep -cF "$TSC_CRON_TAG" "$CRON_FILE" 2>/dev/null)" = 1 ] && grep -qxF "$l" "$CRON_FILE" && return 0 + else + grep -qF "$TSC_CRON_TAG" "$CRON_FILE" 2>/dev/null || return 0 + fi + sed -i "\\|$TSC_CRON_TAG|d" "$CRON_FILE" 2>/dev/null + [ "$1" = on ] && echo "$l" >> "$CRON_FILE" + /etc/init.d/cron restart >/dev/null 2>&1 + return 0 +} + +# Раз в минуту из cron: после входа через LuCI, перезагрузки или перезапуска службы +# возвращает наши настройки (выход в интернет, подсеть, без своих правил firewall и без +# MagicDNS на роутере). Дешёво: tailscale запускается, только если сменились состояние +# службы или файл входа. +# Что из нашей настройки пропало: сеть и firewall сбросили, Forkozz или Zapret обновились +# и переписали свои настройки, sTGWS переустановили. Только дешёвые проверки uci и файлов. +_tsc_heal_need() { + local z f + [ "$(uci -q get "network.$TSC_NET")" = interface ] || { echo "интерфейс сети"; return 0; } + [ "$(uci -q get "firewall.$TSC_FW_RULE")" = rule ] || { echo "порт в firewall"; return 0; } + z="$(_zm_zone_sec "$(_zm_lan_zone)")" && case " $(uci -q get "firewall.$z.network") " in + *" $TSC_NET "*) ;; + *) echo "зона домашней сети"; return 0 ;; + esac + if uci -q get netshift.settings >/dev/null && ! _tsc_fk_has; then echo "Forkozz"; return 0; fi + for f in $(cat "$TSC_ZR_OWN" 2>/dev/null); do + [ -f "$f" ] && grep -qE '^IFACE_LAN="?[^" ]' "$f" && ! grep '^IFACE_LAN=' "$f" | tr '"' ' ' | grep -qw "$TSC_DEV" && { echo "Zapret"; return 0; } + done + [ -f /etc/init.d/tgws ] && grep -q procd_close_instance /etc/init.d/tgws && ! grep -qF "$TSC_HOOK" /etc/init.d/tgws && { echo "sTGWS"; return 0; } + return 1 +} + +# Раз в минуту из cron: после входа через LuCI, перезагрузки или перезапуска службы +# возвращает наши настройки (выход в интернет, подсеть, без своих правил firewall и без +# MagicDNS на роутере), после перезапуска Tailscale один раз перезапускает Forkozz и Steer, +# а если обновление другого компонента затёрло нашу настройку — восстанавливает её. +# Дешёво: tailscale запускается, только если сменились служба, файл входа, подсеть или режимы. +tsc_watch() { + local pid l="$TSC_DIR/watch.lock" + [ -f "$TSC_ON" ] && _tsc_installed || return 0 + pid="$(pidof tailscaled 2>/dev/null)" + [ -n "$pid" ] || return 0 + _job_alive tsc && return 0 + # прошлый запуск ещё идёт (перезапуск Forkozz бывает дольше минуты) + find "$TSC_DIR" -maxdepth 1 -name watch.lock -mmin +10 -exec rmdir {} \; 2>/dev/null + mkdir "$l" 2>/dev/null || return 0 + _tsc_watch_run "$pid" + rmdir "$l" 2>/dev/null + return 0 +} + +_tsc_watch_run() { + local pid="$1" key st dp why + _term_all_guard && _term_running && /etc/init.d/ttyd restart >/dev/null 2>&1 + if why="$(_tsc_heal_need)"; then + # не чаще раза в полчаса: если починить не выходит, не дёргаем сеть каждую минуту + if ! find "$TSC_DIR" -maxdepth 1 -name heal.ts -mmin -30 2>/dev/null | grep -q .; then + : > "$TSC_DIR/heal.ts" + logger -t zapret-manager "Tailscale: восстанавливаем настройку ($why)" + _tsc_setup >/dev/null 2>&1 + rm -f "$TSC_DIR/watch.key" + fi + fi + if _tsc_nft_left; then + _tsc_nft_clean && { logger -t zapret-manager "Tailscale: убраны его правила firewall, мешавшие Steer, Forkozz и Zapret"; _tsc_kick_bypass >/dev/null 2>&1; } + rm -f "$TSC_DIR/watch.key" + fi + # вход начат в панели и ещё не завершён — дожимаем его, даже если страница закрыта + [ -s "$TSC_DIR/login.req" ] && _tsc_login_nudge "$(_tsc_json | jsonfilter -e '@.BackendState' 2>/dev/null)" + key="$pid $(date -r "$(uci -q get tailscale.settings.state_file || echo /etc/tailscale/tailscaled.state)" +%s 2>/dev/null) $(_tsc_lan_cidr) $(_zm_lan_ip) $([ -f "$TSC_NOEXIT" ] && echo e)$([ -f "$TSC_LAN" ] && echo l)" + [ "$key" = "$(cat "$TSC_DIR/watch.key" 2>/dev/null)" ] && return 0 + _tsc_tg_ip_fix + st="$(_tsc_json | jsonfilter -e '@.BackendState' 2>/dev/null)" + printf '%s\n' "$st" > "$TSC_STATE" 2>/dev/null + _tsc_login_nudge "$st" + case "$st" in + Running) + dp="$(_tsc_dbg)" + _tsc_prefs_ok "$dp" || { + _tsc_prefs "$dp" >/dev/null 2>&1 + logger -t zapret-manager "Tailscale: применены настройки выхода в интернет и подсети" + [ -n "$TSC_KICK" ] && { logger -t zapret-manager "Tailscale: перезапускаем Forkozz и Steer"; _tsc_kick_bypass >/dev/null 2>&1; } + } + _tsc_prefs_ok && printf '%s\n' "$key" > "$TSC_DIR/watch.key" + # служба Tailscale перезапустилась (кнопка, LuCI, перезагрузка роутера) — один раз + # перезапускаем Forkozz и Steer под новое состояние сети + if [ "$pid" != "$(cat "$TSC_DIR/kick.pid" 2>/dev/null)" ]; then + logger -t zapret-manager "Tailscale перезапущен: перезапускаем Forkozz и Steer" + _tsc_kick_bypass >/dev/null 2>&1 + fi ;; + NeedsLogin|Stopped) + # не вошли: режим без своих правил firewall ставим один раз, а не каждую минуту; + # вход перепишет файл состояния — ключ сменится, и сторож всё проверит снова + _tsc_prefs >/dev/null 2>&1 + [ -n "$TSC_KICK" ] && _tsc_kick_bypass >/dev/null 2>&1 + printf '%s\n' "$key" > "$TSC_DIR/watch.key" ;; + *) + # Starting, NeedsMachineAuth и т. п. — ждём следующей минуты + _tsc_prefs >/dev/null 2>&1 + [ -n "$TSC_KICK" ] && _tsc_kick_bypass >/dev/null 2>&1 ;; + esac + return 0 +} + +# Подсказка к ошибке входа: сбитые часы роутера ломают TLS — сервер «не отвечает» +_tsc_clock_hint() { + [ "$(date +%Y)" -lt 2025 ] 2>/dev/null && printf ' Часы роутера показывают %s — включите синхронизацию времени (LuCI → Система → Система → NTP), без неё вход не пройдёт.' "$(date +%d.%m.%Y)" + return 0 +} + +# «tailscale login» в фоне, как кнопка Login в LuCI. plain — только адрес сервера, без имени устройства +_tsc_login_bg() { + local s h a="" + s="$(_tsc_srv)" + [ "$1" != plain ] && h="$(_tsc_hostname)" && a="--hostname=$h" + [ -n "$s" ] && a="$a --login-server=$s" + pkill -f 'tailscale (up|login)' >/dev/null 2>&1 + : > "$TSC_UPLOG" + # shellcheck disable=SC2086 + ( _zm_tmo 900 tailscale login $a >"$TSC_UPLOG" 2>&1 & ) >/dev/null 2>&1 +} +# Сервер уже подтвердил вход, а роутер всё ждёт — повторяем «tailscale login» (то же, что кнопка в LuCI): +# не чаще раза в 30 с и только 15 минут после того, как вход начали в панели +_tsc_login_nudge() { + local r + [ "$1" = NeedsLogin ] || { [ "$1" = Running ] && rm -f "$TSC_DIR/login.req" "$TSC_DIR/login.nudge"; return 0; } + [ -s "$TSC_DIR/login.req" ] || return 0 + r="$(cat "$TSC_DIR/login.req" 2>/dev/null)" + [ $(( $(date +%s) - ${r:-0} )) -lt 900 ] 2>/dev/null || { rm -f "$TSC_DIR/login.req" "$TSC_DIR/login.nudge"; return 0; } + [ -f "$TSC_DIR/login.nudge" ] && [ $(( $(date +%s) - $(cat "$TSC_DIR/login.nudge" 2>/dev/null || echo 0) )) -lt 30 ] 2>/dev/null && return 0 + date +%s > "$TSC_DIR/login.nudge" + _tsc_login_bg +} + +_tsc_login() { + local i=0 st url key + _tsc_running || _tsc_start >/dev/null 2>&1 || { echo '{"error":"служба Tailscale не запускается — нажмите «Перезапустить» или смотрите журнал роутера"}'; return 1; } + st="$(_tsc_json | jsonfilter -e '@.BackendState' 2>/dev/null)" + if [ "$st" = Running ]; then printf '{"ok":true,"state":"Running"}\n'; return 0; fi + pkill -f 'tailscale (up|login)' >/dev/null 2>&1 + _tsc_uci_sync + : > "$TSC_UPLOG" + key="$(cat "$TSC_KEY" 2>/dev/null)" + if [ -n "$key" ]; then + # shellcheck disable=SC2046 + _zm_tmo 45 tailscale up --reset $(_tsc_upflags) --auth-key="$key" >"$TSC_UPLOG" 2>&1 + st="$(_tsc_json | jsonfilter -e '@.BackendState' 2>/dev/null)" + case "$st" in + Running|NeedsMachineAuth) printf '{"ok":true,"state":"%s"}\n' "$st"; return 0 ;; + esac + printf '{"error":"%s"}\n' "$(esc "Сервер не принял ключ. Проверьте, что это Device Auth Key, а не Management Key, и адрес сервера. Ключ Routerich действует год с создания сети.$(_tsc_clock_hint) $(tail -n 2 "$TSC_UPLOG" 2>/dev/null | sed "s#$key#***#g" | tr '\n' ' ')")" + return 1 + fi + # Вход через аккаунт — как кнопка Login в LuCI: «tailscale login» в фоне и ждём ссылку до 30 с. + # Остальные настройки (выход в интернет, firewall) панель применит сама сразу после входа. + mkdir -p "$TSC_DIR"; date +%s > "$TSC_DIR/login.req"; rm -f "$TSC_DIR/login.nudge" + _tsc_login_bg + while [ "$i" -lt 30 ]; do + sleep 1; i=$((i + 1)) + # имя устройства сервер не принял — повторяем как LuCI, без своих флагов + if [ "$i" = 4 ] && grep -qiE 'requires mentioning|invalid|error' "$TSC_UPLOG" 2>/dev/null && ! grep -q 'https://' "$TSC_UPLOG" 2>/dev/null; then + _tsc_login_bg plain + fi + url="$(_tsc_json | jsonfilter -e '@.AuthURL' 2>/dev/null)" + [ -n "$url" ] || url="$(grep -o 'https\{0,1\}://[^[:space:]]*' "$TSC_UPLOG" 2>/dev/null | head -n1)" + [ -n "$url" ] && { printf '{"ok":true,"url":"%s"}\n' "$(esc "$url")"; return 0; } + st="$(_tsc_json | jsonfilter -e '@.BackendState' 2>/dev/null)" + [ "$st" = Running ] && { rm -f "$TSC_DIR/login.req"; printf '{"ok":true,"state":"Running"}\n'; return 0; } + done + printf '{"error":"%s"}\n' "$(esc "Tailscale не выдал ссылку для входа. Проверьте интернет на роутере и попробуйте ещё раз.$(_tsc_clock_hint) $(tail -n 2 "$TSC_UPLOG" 2>/dev/null | tr '\n' ' ')")" + return 1 +} + +tsc_status() { + local inst=false run=false en=false on=false j="" st="" url="" ip="" host="" tn="" exo=false lano=false + local pok=true cidr="" ver="" kexp="" lip lprev="" net=false zone=false nft=false tg=false tgr=0 zr=false st_on=false z lz free + _tsc_installed && { inst=true; ver="$(_tsc_ver)"; } + [ -f "$TSC_ON" ] && on=true + _tsc_enabled && en=true + if $inst && _tsc_running; then + run=true + j="$(_tsc_json)" + fi + if [ -n "$j" ]; then + st="$(printf '%s' "$j" | jsonfilter -e '@.BackendState' 2>/dev/null)" + _tsc_login_nudge "$st" + url="$(printf '%s' "$j" | jsonfilter -e '@.AuthURL' 2>/dev/null)" + ip="$(printf '%s' "$j" | jsonfilter -e '@.Self.TailscaleIPs[*]' 2>/dev/null | grep -m1 '\.')" + host="$(printf '%s' "$j" | jsonfilter -e '@.Self.HostName' 2>/dev/null)" + tn="$(printf '%s' "$j" | jsonfilter -e '@.CurrentTailnet.Name' 2>/dev/null)" + kexp="$(printf '%s' "$j" | jsonfilter -e '@.Self.KeyExpiry' 2>/dev/null)" + [ "$(printf '%s' "$j" | jsonfilter -e '@.Self.ExitNodeOption' 2>/dev/null)" = true ] && exo=true + printf '%s' "$j" | jsonfilter -e '@.Self.AllowedIPs[*]' 2>/dev/null | grep -qx '0.0.0.0/0' && exo=true + cidr="$(_tsc_lan_cidr)" + [ -n "$cidr" ] && printf '%s' "$j" | jsonfilter -e '@.Self.PrimaryRoutes[*]' 2>/dev/null | grep -qxF "$cidr" && lano=true + if [ "$st" = Running ] && [ -f "$TSC_ON" ] && ! _job_alive tsc; then + local dp + dp="$(_tsc_dbg)" + _tsc_prefs_ok "$dp" || { _tsc_prefs "$dp" >/dev/null 2>&1; [ -n "$TSC_KICK" ] && _tsc_kick_bg; _tsc_prefs_ok || pok=false; } + fi + fi + [ -z "$cidr" ] && cidr="$(_tsc_lan_cidr)" + # адрес роутера в домашней сети, под который настраивали телефон (DNS): запоминаем при + # первом подключении, при смене — подсказываем поправить DNS в панели сервера + lip="$(_zm_lan_ip)" + if [ "$st" = Running ] && [ -n "$lip" ]; then + [ -s "$TSC_DIR/lan.ip" ] || printf '%s\n' "$lip" > "$TSC_DIR/lan.ip" 2>/dev/null + lprev="$(cat "$TSC_DIR/lan.ip" 2>/dev/null)" + [ "$lprev" = "$lip" ] && lprev="" + $on && $run && _tsc_tg_ip_fix >/dev/null 2>&1 + fi + [ -f "$TSC_ON" ] && $inst && ! grep -qF "$TSC_CRON_TAG" "$CRON_FILE" 2>/dev/null && _tsc_cron on + mkdir -p "$TSC_DIR" 2>/dev/null; printf '%s\n' "${st:-}" > "$TSC_STATE" 2>/dev/null + [ "$(uci -q get "network.$TSC_NET")" = interface ] && net=true + lz="$(_zm_lan_zone)" + z="$(_zm_zone_sec "$lz")" && case " $(uci -q get "firewall.$z.network") " in *" $TSC_NET "*) zone=true ;; esac + [ -f "$TSC_NFT" ] && nft=true + if [ -f /etc/init.d/tgws ]; then + tg=true + $on && $run && [ "$(_tsc_tg_rules)" -lt 2 ] 2>/dev/null && [ -n "$(tgws status 2>/dev/null)" ] && _tsc_tg_ensure >/dev/null 2>&1 + tgr="$(_tsc_tg_rules)" + fi + [ -f /etc/init.d/zapret ] && pgrep -f "/opt/zapre[t]/" >/dev/null 2>&1 && zr=true + _tsc_steer_on && st_on=true + free="$(_tsc_free_kb)" + printf '{"installed":%s,"version":"%s","running":%s,"enabled":%s,"on":%s,"state":"%s","auth_url":"%s","ip":"%s","host":"%s","tailnet":"%s",' \ + "$inst" "$(esc "$ver")" "$run" "$en" "$on" "$(esc "$st")" "$(esc "$url")" "$(esc "$ip")" "$(esc "$host")" "$(esc "$tn")" + printf '"exit_want":%s,"exit_ok":%s,"lan_want":%s,"lan_ok":%s,"lan_cidr":"%s","lan_ip":"%s","net":%s,"zone":%s,"nft":%s,' \ + "$([ -f "$TSC_NOEXIT" ] && echo false || echo true)" "$exo" "$([ -f "$TSC_LAN" ] && echo true || echo false)" "$lano" \ + "$(esc "$cidr")" "$(esc "$lip")" "$net" "$zone" "$nft" + local lat="" vcur + $inst && lat="$(_zm_cached tsc _tsc_gh_latest)" + vcur="$(_tsc_vnum "$ver")" + printf '"prefs_ok":%s,"luci_path":"%s","key_expiry":"%s","lan_prev":"%s","term_mode":"%s",' "$pok" "$(esc "$(_tsc_luci_path)")" "$(esc "$kexp")" "$(esc "$lprev")" "$(_term_installed && _term_mode)" + printf '"fk":%s,"fk_ok":%s,"mx":%s,"adblock":%s,' "$(_fk_installed && echo true || echo false)" "$(_tsc_fk_has && echo true || echo false)" \ + "$([ -x /etc/init.d/magitrickle ] && echo true || echo false)" "$(_ab_on && echo true || echo false)" + printf '"luci_app":%s,"latest":"%s","newer":%s,"source":"%s",' "$(_tsc_luci_app && echo true || echo false)" "$(esc "$(_tsc_vnum "$lat")")" \ + "$([ -n "$lat" ] && [ -n "$vcur" ] && _st_ver_lt "$vcur" "$(_tsc_vnum "$lat")" && echo true || echo false)" "$(esc "$(cat "$TSC_SRC" 2>/dev/null)")" + printf '"server":"%s","server_kind":"%s","admin":"%s","has_key":%s,' "$(esc "$(_tsc_srv)")" "$(_tsc_kind)" "$(esc "$(_tsc_admin)")" "$([ -s "$TSC_KEY" ] && echo true || echo false)" + printf '"stgws":%s,"tg_rules":%s,"zapret":%s,"zapret_inst":%s,"steer":%s,"steer_inst":%s,"steer_dns":"%s","free_kb":%s,"need_kb":%s,"job":%s}\n' \ + "$tg" "${tgr:-0}" "$zr" "$([ -f /etc/init.d/zapret ] && echo true || echo false)" "$st_on" "$(_st_installed && echo true || echo false)" "$(esc "$(_st_dnsmode 2>/dev/null)")" "${free:-0}" "$TSC_NEED_KB" \ + "$(_job_alive tsc && echo true || echo false)" +} + +do_tsc_install() { + local free + _ensure_deps + _tsc_say "Проверяем место на флеш-памяти" + free="$(_tsc_free_kb)" + if [ -n "$free" ] && [ "$free" -lt "$TSC_NEED_KB" ]; then + echo "ОШИБКА: свободно $((free / 1024)) МБ, а Tailscale нужно около $((TSC_NEED_KB / 1024)) МБ. Освободите место — например, удалите ненужные компоненты в «Система» → «Удаление компонентов»" + return 1 + fi + [ -n "$free" ] && echo " ✓ Свободно $((free / 1024)) МБ" + _tsc_say "Обновляем список пакетов OpenWrt" + $UPDATE || echo "!! Список пакетов не обновился — пробуем с тем, что есть" + if _tsc_installed; then + echo " ✓ Tailscale уже установлен: $(_tsc_ver)" + else + _tsc_say "Устанавливаем Tailscale с GitHub — уменьшенная сборка для OpenWrt" + if ! _tsc_install_gh; then + _tsc_say "Пробуем репозиторий OpenWrt (пакет больше, около 25 МБ)" + $INSTALL tailscale || { echo "ОШИБКА: Tailscale не установился ни с GitHub, ни из репозитория OpenWrt — проверьте интернет и место на флеш-памяти"; return 1; } + mkdir -p "$TSC_DIR"; echo openwrt > "$TSC_SRC" + fi + _tsc_installed || { echo "ОШИБКА: файла /usr/sbin/tailscaled нет — пакет встал не полностью"; return 1; } + echo " ✓ Tailscale $(_tsc_ver)" + fi + _tsc_say "Ставим страницу Tailscale для LuCI (luci-app-tailscale-community)" + if _tsc_luci_app || _tsc_install_luci; then + rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* 2>/dev/null + case "$(_tsc_luci_path)" in + admin/vpn/*) echo " ✓ Готово: LuCI → VPN → Tailscale" ;; + *) echo " ✓ Готово: LuCI → Службы → Tailscale" ;; + esac + else + echo " · Не установилась — не страшно, всё настраивается на этой странице" + fi + mkdir -p "$TSC_DIR" + [ -f "$TSC_DIR/lan.init" ] || { : > "$TSC_LAN"; : > "$TSC_DIR/lan.init"; } + _tsc_say "Запускаем службу Tailscale" + _tsc_start || return 1 + echo " ✓ Служба работает" + _tsc_setup + _tsc_say "Готово. Осталось войти в аккаунт Tailscale — кнопка «Войти» на этой странице" +} + +do_tsc_update() { + local old + _tsc_installed || { echo "ОШИБКА: Tailscale не установлен"; return 1; } + old="$(_tsc_ver)" + _tsc_say "Обновляем Tailscale (сейчас $old)" + $UPDATE >/dev/null 2>&1 + _tsc_install_gh || { echo "ОШИБКА: свежая версия не скачалась — Tailscale $old продолжает работать"; return 1; } + _tsc_say "Перезапускаем службу" + _tsc_start || return 1 + _tsc_prefs + _tsc_kick_bypass + ZM_VER_FORCE=1 _zm_cached tsc _tsc_gh_latest >/dev/null 2>&1 + _tsc_say "Готово: Tailscale $(_tsc_ver)" +} + +do_tsc_repair() { + _tsc_installed || { echo "ОШИБКА: Tailscale не установлен"; return 1; } + _tsc_running || { _tsc_say "Запускаем службу Tailscale"; _tsc_start || return 1; } + _tsc_setup + _tsc_prefs + _tsc_say "Готово: настройки для телефона на месте" +} + +do_tsc_remove() { + local rc=0 + _tsc_say "Выходим из аккаунта и останавливаем Tailscale" + _tsc_running && _zm_tmo 10 tailscale logout >/dev/null 2>&1 + pkill -f 'tailscale (up|login)' >/dev/null 2>&1 + if [ -x /etc/init.d/tailscale ]; then /etc/init.d/tailscale stop >/dev/null 2>&1; /etc/init.d/tailscale disable >/dev/null 2>&1; fi + killall tailscaled >/dev/null 2>&1 + echo " ✓ Служба остановлена" + _tsc_unsetup + _tsc_say "Удаляем пакет tailscale" + _zm_pkg_wipe luci-i18n-tailscale-community-ru luci-app-tailscale-community >/dev/null 2>&1 + _zm_pkg_wipe tailscale || rc=1 + _zm_rm_listed tailscale || rc=1 + rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* 2>/dev/null + _tsc_installed && rc=1 + _zm_rm_done "$rc" "Tailscale" +} + +_tsc_server_set() { + local srv key old st + srv="$(printf '%s' "$1" | head -n1 | cut -f1 | tr -d '\r ')" + key="$(printf '%s' "$1" | head -n1 | cut -s -f2 | tr -d '\r ')" + srv="${srv%/}" + case "$srv" in + '') ;; + http://*|https://*) printf '%s' "$srv" | grep -qE '^https?://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._~/-]*)?$' || { echo '{"error":"адрес сервера записан с ошибкой — пример: https://rc.routerich.ru"}'; return 1; } ;; + *) echo '{"error":"адрес сервера должен начинаться с https://"}'; return 1 ;; + esac + if [ -n "$key" ]; then + [ "${#key}" -le 300 ] && printf '%s' "$key" | grep -qE '^[A-Za-z0-9_:.+/=-]+$' || { echo '{"error":"в ключе есть лишние символы — скопируйте его целиком, без пробелов"}'; return 1; } + printf '%s' "$key" | grep -qE '^[0-9a-fA-F]{64}$' && { echo '{"error":"это похоже на Management Key (ключ входа в панель). Для роутера и телефона нужен Device Auth Key"}'; return 1; } + fi + [ -z "$srv" ] && key="" + old="$(_tsc_srv)" + mkdir -p "$TSC_DIR" + if [ -n "$srv" ]; then printf '%s\n' "$srv" > "$TSC_SRV"; else rm -f "$TSC_SRV"; fi + if [ -n "$key" ]; then ( umask 077; printf '%s\n' "$key" > "$TSC_KEY" ); else rm -f "$TSC_KEY"; fi + # другой сервер — другая сеть: DNS телефона там настраивается заново под текущий адрес + [ "$old" != "$srv" ] && rm -f "$TSC_DIR/lan.ip" + if [ "$old" != "$srv" ] && _tsc_running; then + st="$(_tsc_json | jsonfilter -e '@.BackendState' 2>/dev/null)" + case "$st" in Running|NeedsMachineAuth|Starting|Stopped) + pkill -f 'tailscale (up|login)' >/dev/null 2>&1 + _zm_tmo 15 tailscale logout >/dev/null 2>&1 ;; + esac + fi + printf '{"ok":true,"changed":%s}\n' "$([ "$old" != "$srv" ] && echo true || echo false)" +} + +tsc_action() { + local a="$1" m="$2" j + case "$a" in + install|remove|repair|update) + _job_alive tsc && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } + if j="$(_zm_busy_job)"; then printf '{"error":"%s"}\n' "$(esc "Сейчас идёт операция: $j — дождитесь её окончания")"; return 1; fi + case "$a" in + install) job_start tsc do_tsc_install ;; + remove) job_start tsc do_tsc_remove ;; + repair) job_start tsc do_tsc_repair ;; + update) job_start tsc do_tsc_update ;; + esac ;; + login|logout|restart|exit|lan|server|apply|kick|lanack) + _tsc_installed || { echo '{"error":"Tailscale не установлен"}'; return 1; } + _job_alive tsc && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } + case "$a" in + login) _tsc_login ;; + server) _tsc_server_set "$m" ;; + kick) _tsc_kick_bg; printf '{"ok":true}\n' ;; + lanack) mkdir -p "$TSC_DIR"; _zm_lan_ip > "$TSC_DIR/lan.ip"; printf '{"ok":true}\n' ;; + apply) + _tsc_prefs + [ -n "$TSC_KICK" ] && _tsc_kick_bg + if _tsc_prefs_ok; then printf '{"ok":true}\n' + else echo '{"error":"Tailscale не принял настройки — нажмите «Перезапустить» и попробуйте ещё раз"}'; return 1; fi ;; + logout) + pkill -f 'tailscale (up|login)' >/dev/null 2>&1 + _zm_tmo 15 tailscale logout >/dev/null 2>&1 || { echo '{"error":"не получилось выйти из аккаунта — смотрите журнал роутера"}'; return 1; } + printf '{"ok":true}\n' ;; + restart) + _tsc_start >/dev/null 2>&1 || { echo '{"error":"служба Tailscale не запустилась — смотрите журнал роутера"}'; return 1; } + _tsc_kick_bg + printf '{"ok":true}\n' ;; + exit|lan) + case "$m" in on|off) ;; *) echo '{"error":"неизвестный режим"}'; return 1 ;; esac + mkdir -p "$TSC_DIR" + if [ "$a" = exit ]; then + if [ "$m" = on ]; then rm -f "$TSC_NOEXIT"; else : > "$TSC_NOEXIT"; fi + else + if [ "$m" = on ]; then + [ -n "$(_tsc_lan_cidr)" ] || { echo '{"error":"не удалось узнать подсеть домашней сети"}'; return 1; } + : > "$TSC_LAN" + else rm -f "$TSC_LAN"; fi + fi + _tsc_prefs || { echo '{"error":"Tailscale не принял настройку — смотрите журнал роутера"}'; return 1; } + printf '{"ok":true}\n' ;; + esac ;; + *) echo '{"error":"неизвестное действие"}'; return 1 ;; + esac +} + +_tsc_dot() { + _tsc_installed || { echo 0; return; } + if _tsc_running; then + [ "$(cat "$TSC_STATE" 2>/dev/null)" = Running ] && echo 1 || echo 4 + elif _tsc_enabled; then echo 2; else echo 4; fi +} + MON_HTOP_PID="$JOBS_DIR/htop.pid" MON_TTYD_OWN="$ZM_STATE_DIR/ttyd.htop" @@ -15949,6 +17283,10 @@ _mon_htop_open() { [ "$port" -gt 7699 ] && { echo '{"error":"нет свободного порта 7682–7699"}'; return 1; } done dev="$(_mon_dev lan)" + # с телефона через Tailscale подключение приходит не через br-lan, а ttyd с -i принимает + # только свой интерфейс (SO_BINDTODEVICE). Одноразовый ttyd с токеном в пути при закрытом + # из интернета firewall можно слушать на всех интерфейсах. + [ -f "$TSC_ON" ] && _tsc_wan_closed && dev="" ttyd --help 2>&1 | grep -q -- '--writable' && w="-W" ( TERM=xterm-256color ttyd -p "$port" ${dev:+-i "$dev"} -o -m 1 $w -b "/$tok" htop /dev/null 2>&1 & echo $! > "$MON_HTOP_PID" ) sleep 1 @@ -16049,7 +17387,7 @@ watch_status() { _wi forkozz "Forkozz" "Поднимает sing-box, если он упал, переключает на запасной сервер и возвращает правки панели после обновления движка" "$b" b=false; _st_installed && [ ! -f "$ST_OFF" ] && b=true _wi steer "Steer" "Следит за туннелями WARP и VPN по подписке, поднимает их после сбоя" "$b" - b=false; [ -f "$TG_WATCH_FLAG" ] && b=true + b=false; [ -f "$TG_WATCH_FLAG" ] && _tg_any_installed && b=true _wi tg "TG WS Proxy" "Перезапускает прокси для Telegram, если он перестал работать" "$b" b=false; [ "$(_dn_get ru off)" = isp ] && b=true _wi dnsnet "DNS провайдера для российских сайтов" "Провайдер сменил адреса DNS — обновляет их в настройках" "$b" @@ -18430,10 +19768,308 @@ SI_OUT="$JOBS_DIR/sysinfo.json" _si_clean() { printf '%s' "$1" | tr '\t\r' ' ' | tr -d '\000-\010\013\014\016-\037'; } +# Отчёт уходит в чаты поддержки: адреса, почта и ссылки с ключами в него не попадают. +# IPv4 заменяется на x.x.x.x, кроме общеизвестных публичных DNS — они ничего не говорят о человеке. +_si_mask() { + printf '%s' "$1" | awk ' + BEGIN { + n = split("1.1.1.1 1.0.0.1 8.8.8.8 8.8.4.4 9.9.9.9 149.112.112.112 77.88.8.8 77.88.8.1 77.88.8.88 77.88.8.2 77.88.8.7 77.88.8.3 76.76.2.0 76.76.10.0 86.54.11.100 86.54.11.200 94.140.14.14 94.140.15.15 208.67.222.222 208.67.220.220 127.0.0.1 0.0.0.0", w, " ") + for (i = 1; i <= n; i++) ok[w[i]] = 1 + } + { + o = ""; t = $0 + gsub("(vless|vmess|trojan|ss|ssr|hysteria2?|tuic|wireguard|socks5?|https?)://[^ ]*(@|token=|key=|sub/|[?][^ ]*=)[^ ]*", "(ссылка скрыта)", t) + while (match(t, /[0-9]+[.][0-9]+[.][0-9]+[.][0-9]+/)) { + ip = substr(t, RSTART, RLENGTH) + o = o substr(t, 1, RSTART - 1) ((ip in ok) ? ip : "x.x.x.x") + t = substr(t, RSTART + RLENGTH) + } + o = o t + gsub(/[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+[.][A-Za-z0-9.-]*[A-Za-z]/, "(почта скрыта)", o) + printf "%s%s", (NR > 1 ? "\n" : ""), o + }' +} + _si_row() { - printf '%s["%s","%s","%s"]' "$SI_SEP" "$(esc "$(_si_clean "$1")")" "$(esc "$(_si_clean "$2")")" "$3" + printf '%s["%s","%s","%s"]' "$SI_SEP" "$(esc "$(_si_mask "$(_si_clean "$1")")")" "$(esc "$(_si_mask "$(_si_clean "$2")")")" "$3" SI_SEP="," } +_si_sec() { printf ',{"title":"%s","rows":[' "$(esc "$1")"; SI_SEP=""; } +_si_end() { printf ']}'; } +_si_j() { jsonfilter -s "$1" -e "$2" 2>/dev/null; } +_si_jl() { jsonfilter -s "$1" -e "$2" 2>/dev/null | grep . | tr '\n' ',' | sed 's/,$//; s/,/, /g'; } +_si_yes() { case "$1" in true|1|on|yes) echo да ;; *) echo нет ;; esac; } +SI_DOH_INIT="/etc/init.d/https-dns-proxy" +# m5 / h6 / d4 → понятный интервал +_si_every() { + case "$1" in + ''|off|0) echo нет ;; + m[0-9]*) echo "каждые ${1#m} мин" ;; + h1) echo "каждый час" ;; + h[0-9]*) echo "каждые ${1#h} ч" ;; + d[0-9]*) echo "раз в сутки в ${1#d}:00" ;; + *) echo "$1" ;; + esac +} +_si_run() { if [ "$1" = true ]; then _si_row "${2:-Состояние}" работает ok; else _si_row "${2:-Состояние}" остановлен bad; fi; } +_si_pkgver() { + if [ "$PKG" = apk ]; then apk list -I "$1" 2>/dev/null | awk -v p="$1" 'index($1, p "-") == 1 { v = substr($1, length(p) + 2); sub(/-r[0-9]+$/, "", v); print v; exit }' + else opkg list-installed 2>/dev/null | awk -v p="$1" '$1 == p { v = $3; sub(/-r?[0-9]+$/, "", v); print v; exit }'; fi +} + +# Память: всё, что поможет понять, хватает ли её роутеру, и кто её занимает +_si_memory() { + local mt ma mf mc mb sw sf z top + set -- $(awk '/^MemTotal:/ {t=$2} /^MemAvailable:/ {a=$2} /^MemFree:/ {f=$2} /^Cached:/ {c=$2} /^Buffers:/ {b=$2} /^SwapTotal:/ {s=$2} /^SwapFree:/ {sf=$2} + END {print int(t/1024), int(a/1024), int(f/1024), int(c/1024), int(b/1024), int(s/1024), int(sf/1024)}' /proc/meminfo) + mt="$1"; ma="$2"; mf="$3"; mc="$4"; mb="$5"; sw="$6"; sf="$7" + _si_sec "Память" + _si_row "Всего" "$mt МБ" "" + _si_row "Занято" "$((mt - ma)) МБ ($(( (mt - ma) * 100 / (mt ? mt : 1) ))%)" "$([ $(( (mt - ma) * 100 / (mt ? mt : 1) )) -ge 85 ] && echo bad || { [ $(( (mt - ma) * 100 / (mt ? mt : 1) )) -ge 70 ] && echo warn; })" + _si_row "Доступно" "$ma МБ" "" + _si_row "Свободно совсем" "$mf МБ" "" + _si_row "Кеш и буферы" "$((mc + mb)) МБ" "" + if [ "${sw:-0}" -gt 0 ]; then _si_row "Подкачка" "занято $((sw - sf)) МБ из $sw МБ" ""; else _si_row "Подкачка" "нет" ""; fi + z="$(cat /sys/block/zram0/disksize 2>/dev/null)"; [ -n "$z" ] && [ "$z" -gt 0 ] 2>/dev/null && _si_row "zram" "$((z / 1048576)) МБ" "" + top="$(for f in /proc/[0-9]*/status; do awk '/^Name:/ {n=$2} /^VmRSS:/ {r=$2} END {if (r > 0) print r, n}' "$f" 2>/dev/null; done | + awk '{s[$2] += $1} END {for (n in s) print s[n], n}' | sort -rn | head -n 6 | awk '{printf "%s%s %d МБ", (NR > 1 ? ", " : ""), $2, ($1 + 512) / 1024}')" + [ -n "$top" ] && _si_row "Больше всех занимают" "$top" "" + _si_end +} + +# Система: процессор, нагрузка, температура, время, менеджер пакетов +_si_system() { + local cpu n la t tz ntp + cpu="$(sed -En 's/^(model name|cpu model|Hardware)[[:space:]]*:[[:space:]]*//p' /proc/cpuinfo 2>/dev/null | head -n1)" + n="$(grep -c '^processor' /proc/cpuinfo 2>/dev/null)" + la="$(cut -d' ' -f1-3 /proc/loadavg 2>/dev/null)" + t="$(_cpu_temp 2>/dev/null)" + tz="$(uci -q get system.@system[0].zonename)"; [ -n "$tz" ] || tz="$(uci -q get system.@system[0].timezone)" + ntp="$(uci -q get system.ntp.enabled)" + _si_sec "Система" + [ -n "$cpu" ] && _si_row "Процессор" "$cpu" "" + _si_row "Ядер" "${n:-1}" "" + _si_row "Нагрузка (1/5/15 мин)" "${la:-—}" "" + [ -n "$t" ] && _si_row "Температура" "$t °C" "$([ "${t%.*}" -ge 85 ] 2>/dev/null && echo bad || { [ "${t%.*}" -ge 70 ] 2>/dev/null && echo warn; })" + _si_row "Время на роутере" "$(date '+%d.%m.%Y %H:%M')" "" + _si_row "Часовой пояс" "${tz:-не задан}" "" + _si_row "Синхронизация времени" "$([ "$ntp" = 0 ] && echo выключена || echo включена)" "$([ "$ntp" = 0 ] && echo warn)" + _si_row "Пакеты" "$PKG" "" + _si_row "Соединений" "$(cat /proc/sys/net/netfilter/nf_conntrack_count 2>/dev/null || echo —) из $(cat /proc/sys/net/netfilter/nf_conntrack_max 2>/dev/null || echo —)" "" + _si_end +} + +# Сеть роутера без адресов: как подключён интернет, Wi-Fi, интерфейсы, firewall и DNS +_si_net_more() { + local proto v6 r band ch dis wl ifs ip fw dns + proto="$(uci -q get network.wan.proto)" + _si_row "Подключение к интернету" "${proto:-—}$(uci -q get network.wwan.proto >/dev/null 2>&1 && echo ' · есть wwan (Wi-Fi-клиент)')" "" + v6="$(uci -q get network.wan6.proto)" + _si_row "IPv6 у провайдера" "$([ -n "$v6" ] && [ "$(uci -q get network.wan6.disabled)" != 1 ] && echo "включён ($v6)" || echo выключен)" "" + ifs="$(ip -o link show up 2>/dev/null | awk -F': ' '{print $2}' | sed 's/@.*//' | grep -vE '^(lo|ifb|gre|erspan|teql|ip6|sit|tun.?l|dummy)' | tr '\n' ' ')" + [ -n "$ifs" ] && _si_row "Интерфейсы (включены)" "$ifs" "" + for r in $(uci -q show wireless | sed -n "s/^wireless\.\([^.=]*\)=wifi-device$/\1/p"); do + band="$(uci -q get "wireless.$r.band")"; ch="$(uci -q get "wireless.$r.channel")"; dis="$(uci -q get "wireless.$r.disabled")" + wl="$(uci -q show wireless | grep -c "\.device='$r'")" + _si_row "Wi-Fi $r" "$([ "$dis" = 1 ] && echo выключен || echo "${band:-?}, канал ${ch:-auto}, $(uci -q get "wireless.$r.htmode"), сетей: $wl")" "" + done + fw="$(uci -q show firewall | sed -n "s/^firewall\.\([^.=]*\)\.name='wan'$/\1/p" | head -n1)" + [ -n "$fw" ] && _si_row "Вход из интернета" "$(uci -q get "firewall.$fw.input")" "$(case "$(uci -q get "firewall.$fw.input")" in REJECT|DROP) echo ok ;; *) echo bad ;; esac)" + dns="$(uci -q get dhcp.@dnsmasq[0].server | wc -w)" + _si_row "dnsmasq" "своих серверов: $dns, noresolv: $(_si_yes "$(uci -q get dhcp.@dnsmasq[0].noresolv)")" "" +} + +# Компоненты ZM: сводка и подробные настройки каждого установленного +_si_components() { + local j v x s sec n + _si_sec "Компоненты ZM" + _si_row "Zapret Manager" "$ZM_VERSION" "" + [ -f /etc/init.d/zapret ] && _si_row "Zapret" "$ZAPRET_VER · $(pgrep -f '/opt/zapre[t]/' >/dev/null && echo работает || echo остановлен)" "$(pgrep -f '/opt/zapre[t]/' >/dev/null && echo ok || echo bad)" + [ -f /etc/init.d/zapret2 ] && _si_row "Zapret2" "$(_si_pkgver zapret2) · $(/etc/init.d/zapret2 status >/dev/null 2>&1 && echo работает || echo остановлен)" "" + _st_installed 2>/dev/null && _si_row "Steer" "установлен" "" + _fk_installed && _si_row "$(_fk_name)" "$(_fk_up && echo работает || echo выключен)" "$(_fk_up && echo ok)" + [ -x /usr/bin/bytetube ] && _si_row "ByeTube" "установлен" "" + { [ -f /etc/init.d/tg-ws-proxy ] || [ -f "$TG_INIT_GO" ] || [ -f "$TG_INIT_RS" ]; } && _si_row "TG WS Proxy" "установлен" "" + [ -x /etc/init.d/tgws ] && _si_row "sTGWS" "установлен" "" + [ -x "$MIHOMO_BIN" ] && _si_row "Mixomo" "установлен" "" + _ab_on && _si_row "Блокировка рекламы" "включена" ok + [ -x "$SI_DOH_INIT" ] && _si_row "DNS over HTTPS" "установлен" "" + _awg_installed 2>/dev/null && _si_row "AmneziaWG" "установлен" "" + _tsc_installed && _si_row "Tailscale" "$(_tsc_vnum "$(_tsc_ver)")" "" + _term_installed && _si_row "Терминал (ttyd)" "$(_term_version)" "" + [ -x /usr/bin/htop ] && _si_row "htop" "установлен" "" + _si_end + + echo "==> Смотрим компоненты ZM" >&2 + + if [ -f /etc/init.d/zapret2 ]; then + _si_sec "Zapret2" + _si_row "Версия" "$(_si_pkgver zapret2)" "" + _si_run "$(/etc/init.d/zapret2 status >/dev/null 2>&1 && echo true || echo false)" + _si_row "Автозапуск" "$(ls /etc/rc.d/S*zapret2 >/dev/null 2>&1 && echo да || echo нет)" "" + _si_end + fi + + if _st_installed 2>/dev/null; then + j="$(steer_status 2>/dev/null)" + _si_sec "Steer" + _si_row "Версия" "$(_si_j "$j" '@.version')" "" + _si_run "$(_si_j "$j" '@.steer_running')" + [ "$(_si_j "$j" '@.stopped')" = true ] && _si_row "Остановлен вручную" "да" warn + _si_row "Выход" "$(case "$(_si_j "$j" '@.exit')" in warp) echo WARP ;; vpn) echo VPN по подписке ;; none|'') echo не подключён ;; *) _si_j "$j" '@.exit' ;; esac)" "" + _si_row "WARP" "$([ "$(_si_j "$j" '@.warp_on')" = true ] && echo "включён, $([ "$(_si_j "$j" '@.warp_up')" = true ] && echo поднят || echo не поднят), режим $(_si_j "$j" '@.warp_mode'), точка $(_si_j "$j" '@.warp_colo')" || echo выключен)" "$([ "$(_si_j "$j" '@.warp_on')" = true ] && { [ "$(_si_j "$j" '@.warp_up')" = true ] && echo ok || echo bad; })" + _si_row "VPN по подписке" "$([ "$(_si_j "$j" '@.has_sub')" = true ] && echo "есть, $([ "$(_si_j "$j" '@.vpn_up')" = true ] && echo поднят || echo не поднят)" || echo нет)" "" + _si_row "Туннелей" "$(_si_j "$j" '@.tunnels[*]' | grep -c .)" "" + _si_row "Каналов" "$(_si_j "$j" '@.channels')" "" + _si_row "Сервисы" "$(_si_jl "$j" '@.services[@.on=true].name')" "" + _si_row "Устройств с отдельными правилами" "$(_si_j "$j" '@.devices[*]' | grep -c .)" "" + _si_row "DNS" "$([ "$(_si_j "$j" '@.dnsmode')" = router ] && echo через роутер || echo через туннель) · DoH $(_si_j "$j" '@.doh_mode')" "" + _si_row "Если туннель упал" "$([ "$(_si_j "$j" '@.onfail')" = drop ] && echo без интернета || echo напрямую)" "" + _si_row "Списки через туннель" "$(_si_yes "$(_si_j "$j" '@.lists_via')")" "" + _si_row "Автоперезапуск" "$(_si_every "$(_si_j "$j" '@.autorestart')")" "" + _si_end + fi + + if _fk_installed; then + j="$(forkop_status 2>/dev/null)" + _si_sec "$(_fk_name)" + _si_row "Версия" "$(_si_j "$j" '@.version')" "" + _si_row "sing-box" "$(_si_j "$j" '@.singbox')" "" + _si_run "$(_fk_up && echo true || echo false)" + _si_row "Автозапуск" "$(_si_yes "$(_si_j "$j" '@.enabled')")" "" + [ "$(_si_j "$j" '@.foreign')" = true ] && _si_row "Поставлен не панелью" "да" warn + v="$(_si_j "$j" '@.blocker_text')"; [ -n "$v" ] && _si_row "Мешает" "$v" bad + _si_row "DNS" "$(uci -q get netshift.settings.dns_type) · $(uci -q get netshift.settings.dns_server)" "" + _si_row "Откуда берёт трафик" "$(uci -q get netshift.settings.source_network_interfaces)" "" + _si_row "QUIC" "$(case "$(uci -q get netshift.settings.disable_quic)" in 1) echo отклоняется в туннеле ;; all) echo отклоняется везде ;; *) echo не трогаем ;; esac)" "" + _si_row "IPv6" "$(_si_yes "$(uci -q get netshift.settings.enable_ipv6)")" "" + _si_row "Блокировать DoH" "$(_si_yes "$(uci -q get netshift.settings.block_doh)")" "" + _si_row "Обновление списков" "$(uci -q get netshift.settings.update_interval)" "" + for sec in $(uci -q show netshift | sed -n "s/^netshift\.\([^.=]*\)=section$/\1/p"); do + x="$(uci -q get "netshift.$sec.connection_type")" + v="$(uci -q get "netshift.$sec.proxy_config_type")" + n="$(uci -q get "netshift.$sec.community_lists")" + _si_row "Секция $sec" "${x:-?}$([ -n "$v" ] && echo " · $v")$([ "$(uci -q get "netshift.$sec.global_proxy")" = 1 ] && echo ' · весь трафик')$([ -n "$n" ] && echo " · списки: $n")" "" + done + _si_end + fi + + if [ -x /usr/bin/bytetube ]; then + j="$(/usr/bin/bytetube status 2>/dev/null)" + _si_sec "ByeTube" + _si_row "Включён" "$(_si_yes "$(_si_j "$j" '@.enabled')")" "" + _si_row "ByeDPI" "$(_si_yes "$(_si_j "$j" '@.byedpi')")" "$([ "$(_si_j "$j" '@.byedpi')" = true ] && echo ok || echo bad)" + _si_row "hev-socks5-tunnel" "$(_si_yes "$(_si_j "$j" '@.hev')")" "$([ "$(_si_j "$j" '@.hev')" = true ] && echo ok || echo bad)" + _si_end + fi + + if [ -f /etc/init.d/tg-ws-proxy ] || [ -f "$TG_INIT_GO" ] || [ -f "$TG_INIT_RS" ] || [ -x /etc/init.d/tgws ]; then + j="$(tg_status 2>/dev/null)" + _si_sec "Telegram" + for x in mtproto:MTProto socks5:SOCKS5 rust:Rust; do + v="${x%%:*}" + [ "$(_si_j "$j" "@.$v")" = installed ] || continue + _si_row "${x#*:}" "$(_si_j "$j" "@.${v}_version") · $([ "$(_si_j "$j" "@.${v}_running")" = true ] && echo работает || echo остановлен) · перезапуск: $(_si_every "$(_si_j "$j" "@.auto.$v")")" "$([ "$(_si_j "$j" "@.${v}_running")" = true ] && echo ok || echo bad)" + done + [ "$(_si_j "$j" '@.rust')" = installed ] && _si_row "Rust: порт" "$(_si_j "$j" '@.rust_port')" "" + if [ -x /etc/init.d/tgws ]; then + x="$(tgws_status 2>/dev/null)" + _si_row "sTGWS" "$(_si_j "$x" '@.version') · $([ "$(_si_j "$x" '@.running')" = true ] && echo работает || echo остановлен) · перезапуск: $(_si_every "$(_si_j "$j" '@.auto.tgws')")" "$([ "$(_si_j "$x" '@.running')" = true ] && echo ok || echo bad)" + fi + _si_row "Присмотр за прокси" "$(_si_yes "$(_si_j "$j" '@.auto.watch')")" "" + _si_end + fi + + if [ -x "$MIHOMO_BIN" ]; then + j="$(mixomo_status 2>/dev/null)" + _si_sec "Mixomo" + _si_row "Mihomo" "$(_si_j "$j" '@.mihomo_version') · $([ "$(_si_j "$j" '@.mihomo_running')" = true ] && echo работает || echo остановлен)" "$([ "$(_si_j "$j" '@.mihomo_running')" = true ] && echo ok || echo bad)" + [ "$(_si_j "$j" '@.magitrickle')" = installed ] && _si_row "MagiTrickle" "$(_si_j "$j" '@.magitrickle_version') · $([ "$(_si_j "$j" '@.magitrickle_running')" = true ] && echo работает || echo остановлен) · список: $(_si_j "$j" '@.magitrickle_list')" "" + [ "$(_si_j "$j" '@.hev')" = installed ] && _si_row "hev-socks5-tunnel" "$(_si_j "$j" '@.hev_version') · $([ "$(_si_j "$j" '@.hev_running')" = true ] && echo работает || echo остановлен)" "" + _si_row "Подписка" "$([ -n "$(_si_j "$j" '@.subscription')" ] && [ "$(_si_j "$j" '@.subscription')" != false ] && echo есть || echo нет)" "" + _si_row "Автоперезапуск" "$(_si_every "$(_si_j "$j" '@.autorestart')")" "" + _si_end + fi + + j="$(hosts_status 2>/dev/null)" + n="$(_si_j "$j" '@.items[@.enabled=true].id' | grep -c .)" + if [ "$n" -gt 0 ] || [ -n "$(_si_j "$j" '@.geohide')" ]; then + _si_sec "Hosts" + _si_row "Включено наборов" "$n" "" + [ "$n" -gt 0 ] && _si_row "Наборы" "$(_si_jl "$j" '@.items[@.enabled=true].id')" "" + _si_row "Список GeoHide" "$(_si_j "$j" '@.geohide' | sed 's/^$/нет/')" "" + _si_end + fi + + if _ab_on; then + j="$(adblock_status 2>/dev/null)" + _si_sec "Блокировка рекламы" + _si_row "Применена" "$(_si_yes "$(_si_j "$j" '@.applied')")" "$([ "$(_si_j "$j" '@.applied')" = true ] && echo ok || echo bad)" + _si_row "Список" "$(_si_j "$j" '@.list')" "" + _si_row "Доменов" "$(_si_j "$j" '@.count')" "" + _si_row "Автообновление" "$(_si_yes "$(_si_j "$j" '@.auto')")" "" + _si_row "Блокировать DoH браузеров" "$(_si_yes "$(_si_j "$j" '@.doh')")" "" + _si_row "Занимает в памяти" "$(( $(_si_j "$j" '@.ram_kb' | grep -o '^[0-9]*' | sed 's/^$/0/') / 1024 )) МБ" "" + v="$(_si_j "$j" '@.err')"; [ -n "$v" ] && _si_row "Последняя ошибка" "$v" bad + _si_end + fi + + if [ -x "$SI_DOH_INIT" ]; then + j="$(doh_status 2>/dev/null)" + _si_sec "DNS over HTTPS" + _si_run "$(_si_j "$j" '@.running')" + _si_row "Провайдеры" "$(_si_jl "$j" '@.resolvers[*].provider')" "" + _si_row "Серверы" "$(_si_jl "$j" '@.resolvers[*].url')" "" + _si_row "Перехват DNS устройств" "$(_si_yes "$(_si_j "$j" '@.force_dns')") ($(_si_j "$j" '@.force_mode'))" "" + _si_row "Запасной DNS при сбое" "$(_si_yes "$(_si_j "$j" '@.failover.on')")" "" + _si_end + fi + + if _awg_installed 2>/dev/null; then + _si_sec "AmneziaWG" + _si_row "Модуль ядра" "$(_awg_kmod_ver)" "" + _si_row "awg-tools" "$(_si_pkgver amneziawg-tools)" "" + n=0; v="" + for x in $(_awg_ifaces); do n=$((n + 1)); v="$v $x($([ "$(cat "/sys/class/net/$x/operstate" 2>/dev/null)" = down ] || [ ! -d "/sys/class/net/$x" ] && echo выкл || echo вкл))"; done + _si_row "Туннели" "$([ "$n" -gt 0 ] && echo "$n:$v" || echo нет)" "" + _si_end + fi + + if _tsc_installed; then + j="$(tsc_status 2>/dev/null)" + _si_sec "Tailscale" + _si_row "Версия" "$(_si_j "$j" '@.version') · $(_si_j "$j" '@.source' | sed 's/^$/неизвестно откуда/')" "" + _si_run "$(_si_j "$j" '@.running')" "Служба" + _si_row "Вход" "$(_si_j "$j" '@.state')" "$([ "$(_si_j "$j" '@.state')" = Running ] && echo ok || echo warn)" + _si_row "Сервер" "$(case "$(_si_j "$j" '@.server_kind')" in routerich) echo Routerich ;; custom) echo свой сервер ;; *) echo Tailscale ;; esac)" "" + _si_row "Выход в интернет" "$([ "$(_si_j "$j" '@.exit_want')" = true ] && { [ "$(_si_j "$j" '@.exit_ok')" = true ] && echo одобрен || echo ждёт одобрения; } || echo выключен)" "" + _si_row "Доступ к домашней сети" "$([ "$(_si_j "$j" '@.lan_want')" = true ] && { [ "$(_si_j "$j" '@.lan_ok')" = true ] && echo одобрен || echo ждёт одобрения; } || echo выключен)" "" + _si_row "Настройки Tailscale" "$([ "$(_si_j "$j" '@.prefs_ok')" = false ] && echo сбились || echo в порядке)" "$([ "$(_si_j "$j" '@.prefs_ok')" = false ] && echo bad || echo ok)" + v="$(_si_j "$j" '@.key_expiry')"; _si_row "Вход роутера" "$([ -n "$v" ] && echo "до ${v%%T*}" || echo бессрочный)" "" + _si_row "Forkozz для телефона" "$(_si_yes "$(_si_j "$j" '@.fk_ok')")" "" + v="$(_si_j "$j" '@.term_mode')"; [ -n "$v" ] && _si_row "Терминал через Tailscale" "$([ "$v" = all ] || [ "$v" = ts ] && echo открывается || echo только дома)" "" + _si_row "Страница в LuCI" "$(_si_yes "$(_si_j "$j" '@.luci_app')")" "" + _si_end + fi + + if _term_installed; then + _si_sec "Терминал" + _si_run "$(_term_running && echo true || echo false)" + _si_row "Где открывается" "$(case "$(_term_mode)" in all) echo дома и через Tailscale ;; ts) echo только Tailscale ;; other) echo "интерфейс $(_term_iface)" ;; *) echo только дома ;; esac)" "" + _si_row "Порт" "$(_term_port)" "" + _si_end + fi + + _si_sec "Панель Zapret Manager" + _si_row "Версия" "$ZM_VERSION" "" + _si_row "Фоновые проверки" "$([ -f "$WATCH_OFF" ] && echo выключены || echo включены)" "$([ -f "$WATCH_OFF" ] && echo warn)" + _si_row "Поиск обновлений" "$(case "$(_ver_mode)" in day) echo ночью ;; open) echo при открытии ;; *) echo по кнопке ;; esac)" "" + v="$(cat "$ZM_STATE_DIR/ui.theme" 2>/dev/null)"; _si_row "Тема Web UI" "${v:-авто}" "" + v="$(ui_nav_get)"; [ -n "$v" ] && _si_row "Скрыто в меню" "$v" "" + _si_row "Задания cron панели" "$(grep -c 'zapret-manager\|zm-' "$CRON_FILE" 2>/dev/null)" "" + _si_end +} + _si_ping() { local t @@ -18496,6 +20132,8 @@ do_sysinfo() { [ -n "$ft" ] && _si_row "Флеш" "занято $((fu / 1024)) МБ из $((ft / 1024)) МБ" "" [ -n "$tt" ] && _si_row "/tmp" "занято $((tu / 1024)) МБ из $((tt / 1024)) МБ" "" printf ']}' + _si_memory + _si_system } > "$out" echo "==> Проверяем сеть" @@ -18519,7 +20157,7 @@ do_sysinfo() { if /etc/init.d/https-dns-proxy running >/dev/null 2>&1 && [ -f /etc/config/https-dns-proxy ]; then sed -n "s/^[[:space:]]*option resolver_url '\([^']*\)'.*/\1/p" /etc/config/https-dns-proxy | while IFS= read -r v; do _si_row "DNS over HTTPS" "$v" ""; SI_SEP=","; done fi - _fk_installed && _si_row "$(_fk_name)" "$(_fk_up && echo работает || echo выключен)" "$(_fk_up && echo ok)" + _si_net_more printf ']}' } >> "$out" @@ -18540,10 +20178,19 @@ do_sysinfo() { _si_row "Порты TCP" "${tcp:-—}" "" _si_row "Порты UDP" "${udp:-—}" "" _si_row "IPv6 в Zapret" "$([ "$(_ipv6_enabled_in_zapret)" = true ] && echo включён || echo выключен)" "" + _si_row "Автозапуск" "$(ls /etc/rc.d/S*zapret >/dev/null 2>&1 && echo да || echo нет)" "$(ls /etc/rc.d/S*zapret >/dev/null 2>&1 || echo bad)" + _si_row "Режим списков" "$(if [ "$(jsonfilter -s "$st" -e '@.rkn_on' 2>/dev/null)" = true ]; then echo "только списки РКН ($(jsonfilter -s "$st" -e '@.rkn_count' 2>/dev/null) доменов)"; elif [ "$(jsonfilter -s "$st" -e '@.ulist_on' 2>/dev/null)" = true ]; then echo "только свой список ($(jsonfilter -s "$st" -e '@.ulist_count' 2>/dev/null))"; else echo "все сайты, кроме исключений"; fi)" "" + _si_row "QUIC для YouTube" "$(_si_yes "$(jsonfilter -s "$st" -e '@.quic_yt' 2>/dev/null)")" "" + _si_row "wssize" "$(_si_yes "$(jsonfilter -s "$st" -e '@.wssize' 2>/dev/null)")" "" + _si_row "Режим эксперта" "$(_si_yes "$(jsonfilter -s "$st" -e '@.expert' 2>/dev/null)")" "" + v="$(jsonfilter -s "$st" -e '@.flowseal' 2>/dev/null)"; [ -n "$v" ] && _si_row "Стратегия Flowseal" "$v" "" + _si_row "Исключений" "$(grep -c . /opt/zapret/ipset/zapret-hosts-user-exclude.txt 2>/dev/null || echo 0)" "" else _si_row "Состояние" "не установлен" "" fi - printf ']}]' + printf ']}' + _si_components + printf ']' opt="" [ -f "$CONF" ] && opt="$(uci -q get zapret.config.NFQWS_OPT 2>/dev/null)" printf ',"strategy":"%s"' "$(esc_ml "$(_si_clean "$opt")")" @@ -18784,7 +20431,10 @@ _ab_dns_hosts() { _ab_dns_builtin() { cat << 'ZM_AB_DNS_EOF' dns.quad9.net +dns10.quad9.net cloudflare-dns.com +security.cloudflare-dns.com +family.cloudflare-dns.com one.one.one.one 1dot1dot1dot1.cloudflare-dns.com dns.google @@ -18804,6 +20454,8 @@ d.webshiftvpn.ru dns.dagami.ru freedns.controld.com unfiltered.joindns4.eu +protective.joindns4.eu +child.joindns4.eu ZM_AB_DNS_EOF } _ab_jl() { awk 'NF && !/^[0-9.]*$/ { printf "%s\"%s\"", (n++ ? "," : ""), $0 }' "$@"; } @@ -19689,6 +21341,10 @@ case "$cmd" in awg_action) awg_action "$1" "$2" ;; term_status) term_status ;; term_action) term_action "$1" "$2" ;; + tsc_status) tsc_status ;; + tsc_action) tsc_action "$1" "$2" ;; + tsc_watch) tsc_watch ;; + tsc_lan_ip) _zm_lan_ip ;; monitor) monitor_snapshot ;; monitor_procs) monitor_procs ;; monitor_action) monitor_action "$1" "$2" ;; @@ -19702,6 +21358,7 @@ case "$cmd" in steer_status) steer_status ;; steer_action) steer_action "$1" "$2" ;; bytetube_action) bytetube_action "$1" ;; + bytetube_sync) bytetube_sync ;; forkop_status) forkop_status ;; forkop_config_get) forkop_config_get ;; forkop_config_set) forkop_config_set "$1" ;; @@ -19715,6 +21372,7 @@ case "$cmd" in reboot_hint_clear) reboot_hint_clear ;; zm_watch) zm_watch ;; ui_theme_set) ui_theme_set "$1" ;; + ui_nav_set) ui_nav_set "$1" ;; ui_flags_get) ui_flags_get ;; ui_flag_set) ui_flag_set "$1" ;; adblock_dns_sync) _ab_dns_sync ;; @@ -22576,6 +24234,8 @@ list_methods() { json_add_object "awg_action"; json_add_string "action" "string"; json_add_string "mode" "string"; json_close_object json_add_object "term_status"; json_close_object json_add_object "term_action"; json_add_string "action" "string"; json_add_string "mode" "string"; json_close_object + json_add_object "tsc_status"; json_close_object + json_add_object "tsc_action"; json_add_string "action" "string"; json_add_string "mode" "string"; json_close_object json_add_object "adblock_status"; json_close_object json_add_object "adblock_action"; json_add_string "action" "string"; json_add_string "mode" "string"; json_close_object json_add_object "steer_status"; json_close_object @@ -22587,6 +24247,7 @@ list_methods() { json_add_object "forkop_action"; json_add_string "action" "string"; json_add_string "mode" "string"; json_close_object json_add_object "jobs_cancel"; json_add_string "job" "string"; json_close_object json_add_object "ui_theme_set"; json_add_string "theme" "string"; json_close_object + json_add_object "ui_nav_set"; json_add_string "ids" "string"; json_close_object json_add_object "ui_flags_get"; json_close_object json_add_object "ui_flag_set"; json_add_string "name" "string"; json_close_object json_add_object "monitor"; json_close_object @@ -22703,6 +24364,8 @@ call_method() { awg_action) json_get_var action action; json_get_var mode mode; printf '%s' "$mode" | "$BACKEND" awg_action "$action" @stdin ;; term_status) "$BACKEND" term_status ;; term_action) json_get_var action action; json_get_var mode mode; printf '%s' "$mode" | "$BACKEND" term_action "$action" @stdin ;; + tsc_status) "$BACKEND" tsc_status ;; + tsc_action) json_get_var action action; json_get_var mode mode; printf '%s' "$mode" | "$BACKEND" tsc_action "$action" @stdin ;; monitor) "$BACKEND" monitor ;; monitor_procs) "$BACKEND" monitor_procs ;; monitor_action) json_get_var action action; json_get_var mode mode; printf '%s' "$mode" | "$BACKEND" monitor_action "$action" @stdin ;; @@ -22719,6 +24382,7 @@ call_method() { forkop_action) json_get_var action action; json_get_var mode mode; printf '%s' "$mode" | "$BACKEND" forkop_action "$action" @stdin ;; jobs_cancel) json_get_var job job; "$BACKEND" jobs_cancel "$job" ;; ui_theme_set) json_get_var theme theme; "$BACKEND" ui_theme_set "$theme" ;; + ui_nav_set) json_get_var ids ids; "$BACKEND" ui_nav_set "$ids" ;; ui_flags_get) "$BACKEND" ui_flags_get ;; ui_flag_set) json_get_var name name; "$BACKEND" ui_flag_set "$name" ;; *) echo '{"error":"unknown method"}'; return 1 ;; @@ -22793,6 +24457,7 @@ cat > '/usr/share/rpcd/acl.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF' "monitor_procs", "watch_status", "term_status", + "tsc_status", "adblock_status", "steer_status", "forkop_status", @@ -22879,6 +24544,7 @@ cat > '/usr/share/rpcd/acl.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF' "monitor_action", "watch_set", "term_action", + "tsc_action", "adblock_action", "steer_action", "forkop_config_set", @@ -22886,6 +24552,7 @@ cat > '/usr/share/rpcd/acl.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF' "sysinfo_run", "jobs_cancel", "ui_theme_set", + "ui_nav_set", "ui_flag_set", "versions_mode", "system_reboot", @@ -22959,6 +24626,11 @@ cat > '/usr/share/luci/menu.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF "order": 52, "action": { "type": "view", "path": "zapret-manager/awg" } }, + "admin/services/zapret-manager/tailscale": { + "title": "Tailscale", + "order": 54, + "action": { "type": "view", "path": "zapret-manager/tailscale" } + }, "admin/services/zapret-manager/terminal": { "title": "Терминал", "order": 53, @@ -23073,6 +24745,8 @@ var callAwgStatus = zmDeclare({ object: 'zapret-manager', method: 'awg_status', var callAwgAction = zmDeclare({ object: 'zapret-manager', method: 'awg_action', params: ['action', 'mode'], expect: {} }); var callTermStatus = zmDeclare({ object: 'zapret-manager', method: 'term_status', expect: {} }); var callTermAction = zmDeclare({ object: 'zapret-manager', method: 'term_action', params: ['action', 'mode'], expect: {} }); +var callTscStatus = zmDeclare({ object: 'zapret-manager', method: 'tsc_status', expect: {} }); +var callTscAction = zmDeclare({ object: 'zapret-manager', method: 'tsc_action', params: ['action', 'mode'], expect: {} }); var callMonitor = zmDeclare({ object: 'zapret-manager', method: 'monitor', expect: {} }); var callMonitorProcs = zmDeclare({ object: 'zapret-manager', method: 'monitor_procs', expect: {} }); var callMonitorAction = zmDeclare({ object: 'zapret-manager', method: 'monitor_action', params: ['action', 'mode'], expect: {} }); @@ -24352,7 +26026,7 @@ function alertBanners(h, done) { if (s && s[key] === true) return { ok: true }; return call(); }).then(function(r) { - if (r && r.error) { toast(r.error, 'error'); b.disabled = false; return; } + if (r && r.error) { toast(r.error, 'error'); b.disabled = false; if (done) done(); return; } if (r && r[key] === false) return call().then(function() { toast('Не получилось — попробуйте ещё раз', 'error'); b.disabled = false; }); toast(okText, 'info'); if (done) done(); @@ -25074,6 +26748,8 @@ return baseclass.extend({ awgAction: bigArg2(callAwgAction), termStatus: callTermStatus, termAction: bigArg2(callTermAction), + tscStatus: callTscStatus, + tscAction: bigArg2(callTscAction), monitor: callMonitor, monitorProcs: callMonitorProcs, monitorAction: callMonitorAction, @@ -25197,7 +26873,8 @@ return view.extend({ var items = []; items.push(row('Zapret', zm.stateBadge(zrSt))); if (zrSt !== 0 && d.strategy) items.push(row('Стратегия', E('span', { 'style': 'display:inline-flex; gap:8px; flex-wrap:wrap; align-items:center' }, [ E('span', {}, [ String(d.strategy) ]), - d.rkn_on ? E('span', { 'class': 'zm-badge zm-ok' }, [ E('span', { 'class': 'zm-dot' }), 'по спискам РКН' ]) : d.ulist_on ? E('span', { 'class': 'zm-badge zm-ok' }, [ E('span', { 'class': 'zm-dot' }), 'по вашему списку' ]) : '' ]))); + d.rkn_on ? E('span', { 'class': 'zm-badge zm-ok' }, [ E('span', { 'class': 'zm-dot' }), 'по спискам РКН' ]) : d.ulist_on ? E('span', { 'class': 'zm-badge zm-ok' }, [ E('span', { 'class': 'zm-dot' }), 'по вашему списку' ]) : '', + d.testing ? E('span', { 'class': 'zm-badge zm-warn', 'title': 'Пока идёт тест, роутер по очереди пробует стратегии. Потом вернётся ваша' }, [ E('span', { 'class': 'zm-dot' }), 'идёт тест' + (d.test_now ? ': сейчас ' + d.test_now : '') ]) : '' ]))); items.push(row('Zapret2', zm.stateBadge(zr2St))); var warnBadge = function(text) { return E('span', { 'class': 'zm-badge zm-warn' }, [ E('span', { 'class': 'zm-dot' }), text ]); }; var offBadge = function(text) { return E('span', { 'class': 'zm-badge zm-off' }, [ E('span', { 'class': 'zm-dot' }), text ]); }; @@ -25492,25 +27169,35 @@ cat > '/www/luci-static/resources/view/zapret-manager/doh.js' << 'ZM_INSTALLER_E 'require zapret-manager.common as zm'; var E = (function(raw) { return function() { var a = Array.prototype.slice.call(arguments), i = a.length - 1; if (i >= 1 && (typeof a[i] === 'string' || typeof a[i] === 'number')) a[i] = [ String(a[i]) ]; return raw.apply(null, a); }; })(window.E); +// k — тип сервера: plain — только шифрование, safe — защита от вирусов, family — ещё и взрослые сайты, geo — обход геоблокировок. +// v — название варианта внутри «папки» провайдера (FAMS). Адреса сверены с документацией провайдеров. var PROVIDERS = [ - { id: 'google', label: 'Google', url: 'https://dns.google/dns-query', boot: '8.8.8.8, 8.8.4.4' }, - { id: 'cloudflare', label: 'Cloudflare', url: 'https://cloudflare-dns.com/dns-query', boot: '1.1.1.1, 1.0.0.1' }, - { id: 'quad9', label: 'Quad9', url: 'https://dns.quad9.net/dns-query', boot: '9.9.9.9, 149.112.112.112' }, - { id: 'xbox', label: 'XBOX', url: 'https://xbox-dns.ru/dns-query' }, - { id: 'comss', label: 'Comss', url: 'https://dns.comss.one/dns-query' }, - { id: 'dnsai', label: 'DNS-AI', url: 'https://dns.dns-ai.ru/dns-query' }, - { id: 'bezmezhau', label: 'Bezmezhau', url: 'https://dns.bezmezhau.com/dns-query' }, - { id: 'yandex_family', label: 'Яндекс Семейный', url: 'https://family.dot.dns.yandex.net/dns-query', boot: '77.88.8.7, 77.88.8.3' }, - { id: 'yandex_safe', label: 'Яндекс Безопасный', url: 'https://safe.dot.dns.yandex.net/dns-query', boot: '77.88.8.88, 77.88.8.2' }, - { id: 'geohide_ru', label: 'GeoHide RU', url: 'https://geohide.ru/dns-query' }, - { id: 'geohide_eu', label: 'GeoHide EU', url: 'https://eu.geohide.ru/dns-query' }, - { id: 'geohide_us', label: 'GeoHide US', url: 'https://us.geohide.ru/dns-query' }, - { id: 'malw', label: 'MALW', url: 'https://dns.malw.link/dns-query' }, - { id: 'zapretmoe', label: 'zapret.moe', url: 'https://dns.zapret.moe/dns-query' }, - { id: 'webshift', label: 'WebShift', url: 'https://d.webshiftvpn.ru/dns-query' }, - { id: 'dagami', label: 'Dagami', url: 'https://dns.dagami.ru/dns-query' }, - { id: 'controld', label: 'Control D', url: 'https://freedns.controld.com/p0', boot: '76.76.2.0, 76.76.10.0' }, - { id: 'dns4eu', label: 'DNS4EU', url: 'https://unfiltered.joindns4.eu/dns-query', boot: '86.54.11.100, 86.54.11.200' } + { id: 'google', label: 'Google', url: 'https://dns.google/dns-query', boot: '8.8.8.8, 8.8.4.4', k: 'plain' }, + { id: 'cloudflare', label: 'Cloudflare', v: 'Без фильтра', url: 'https://cloudflare-dns.com/dns-query', boot: '1.1.1.1, 1.0.0.1', k: 'plain' }, + { id: 'cloudflare_sec', label: 'Cloudflare · Без вирусов', v: 'Без вирусов', url: 'https://security.cloudflare-dns.com/dns-query', boot: '1.1.1.2, 1.0.0.2', k: 'safe' }, + { id: 'cloudflare_fam', label: 'Cloudflare · Семейный', v: 'Семейный', url: 'https://family.cloudflare-dns.com/dns-query', boot: '1.1.1.3, 1.0.0.3', k: 'family' }, + { id: 'quad9_open', label: 'Quad9 · Без фильтра', v: 'Без фильтра', url: 'https://dns10.quad9.net/dns-query', boot: '9.9.9.10, 149.112.112.10', k: 'plain' }, + { id: 'quad9', label: 'Quad9', v: 'Без вирусов', url: 'https://dns.quad9.net/dns-query', boot: '9.9.9.9, 149.112.112.112', k: 'safe' }, + { id: 'controld', label: 'Control D', v: 'Без фильтра', url: 'https://freedns.controld.com/p0', boot: '76.76.2.0, 76.76.10.0', k: 'plain' }, + { id: 'controld_sec', label: 'Control D · Без вирусов', v: 'Без вирусов', url: 'https://freedns.controld.com/p1', boot: '76.76.2.1, 76.76.10.1', k: 'safe' }, + { id: 'controld_fam', label: 'Control D · Семейный', v: 'Семейный', url: 'https://freedns.controld.com/family', boot: '76.76.2.4, 76.76.10.4', k: 'family' }, + { id: 'dns4eu', label: 'DNS4EU', v: 'Без фильтра', url: 'https://unfiltered.joindns4.eu/dns-query', boot: '86.54.11.100, 86.54.11.200', k: 'plain' }, + { id: 'dns4eu_sec', label: 'DNS4EU · Защитный', v: 'Защитный', url: 'https://protective.joindns4.eu/dns-query', boot: '86.54.11.1, 86.54.11.201', k: 'safe' }, + { id: 'dns4eu_fam', label: 'DNS4EU · Детский', v: 'Детский', url: 'https://child.joindns4.eu/dns-query', boot: '86.54.11.12, 86.54.11.212', k: 'family' }, + { id: 'yandex', label: 'Яндекс', v: 'Базовый', url: 'https://common.dot.dns.yandex.net/dns-query', boot: '77.88.8.8, 77.88.8.1', k: 'plain' }, + { id: 'yandex_safe', label: 'Яндекс · Безопасный', v: 'Безопасный', url: 'https://safe.dot.dns.yandex.net/dns-query', boot: '77.88.8.88, 77.88.8.2', k: 'safe' }, + { id: 'yandex_family', label: 'Яндекс · Семейный', v: 'Семейный', url: 'https://family.dot.dns.yandex.net/dns-query', boot: '77.88.8.7, 77.88.8.3', k: 'family' }, + { id: 'xbox', label: 'XBOX', url: 'https://xbox-dns.ru/dns-query', k: 'geo' }, + { id: 'comss', label: 'Comss', url: 'https://dns.comss.one/dns-query', k: 'geo' }, + { id: 'dnsai', label: 'DNS-AI', url: 'https://dns.dns-ai.ru/dns-query', k: 'geo' }, + { id: 'bezmezhau', label: 'Bezmezhau', url: 'https://dns.bezmezhau.com/dns-query', k: 'geo' }, + { id: 'geohide_ru', label: 'GeoHide RU', v: 'RU', d: 'российский сервер', url: 'https://geohide.ru/dns-query', k: 'geo' }, + { id: 'geohide_eu', label: 'GeoHide EU', v: 'EU', d: 'европейский сервер', url: 'https://eu.geohide.ru/dns-query', k: 'geo' }, + { id: 'geohide_us', label: 'GeoHide US', v: 'US', d: 'американский сервер', url: 'https://us.geohide.ru/dns-query', k: 'geo' }, + { id: 'malw', label: 'MALW', url: 'https://dns.malw.link/dns-query', k: 'geo' }, + { id: 'zapretmoe', label: 'zapret.moe', url: 'https://dns.zapret.moe/dns-query', k: 'geo' }, + { id: 'webshift', label: 'WebShift', url: 'https://d.webshiftvpn.ru/dns-query', k: 'geo' }, + { id: 'dagami', label: 'Dagami', url: 'https://dns.dagami.ru/dns-query', k: 'geo' } ]; var BOOT_STD = '1.1.1.1, 8.8.8.8'; var OWN_RE = /^https:\/\/[A-Za-z0-9.-]+\.[A-Za-z0-9-]+(:\d{1,5})?(\/[A-Za-z0-9._~%\/+=&?-]*)?$/; @@ -25519,11 +27206,25 @@ var DNSM_URL = '/cgi-bin/luci/admin/services/dns-manager'; var OWN_MAX = 12; var OWN_NAMES = {}; +// «Папки»: одна плитка на провайдера, по нажатию раскрываются его варианты +var FAMS = { + cloudflare: { label: 'Cloudflare', ids: [ 'cloudflare', 'cloudflare_sec', 'cloudflare_fam' ] }, + quad9: { label: 'Quad9', ids: [ 'quad9_open', 'quad9' ] }, + controld: { label: 'Control D', ids: [ 'controld', 'controld_sec', 'controld_fam' ] }, + dns4eu: { label: 'DNS4EU', ids: [ 'dns4eu', 'dns4eu_sec', 'dns4eu_fam' ] }, + yandex: { label: 'Яндекс', ids: [ 'yandex', 'yandex_safe', 'yandex_family' ] }, + geohide: { label: 'GeoHide', ids: [ 'geohide_ru', 'geohide_eu', 'geohide_us' ] } +}; +var VAR_TEXT = { plain: 'только шифрование', safe: 'блокирует вредоносные и фишинговые сайты', family: 'ещё и сайты для взрослых' }; + +// ids — плитки: провайдер-«папка» (ключ FAMS) или отдельный сервер var GROUPS = [ - { title: 'Обычные', hint: 'только шифрование, ничего не подменяют', ids: [ 'google', 'cloudflare', 'quad9', 'controld', 'dns4eu' ] }, - { title: 'С обходом геоблокировок', hint: 'открывают сервисы, закрытые для России', ids: [ 'xbox', 'comss', 'dnsai', 'bezmezhau', 'geohide_ru', 'geohide_eu', 'geohide_us', 'malw', 'zapretmoe', 'webshift', 'dagami' ] }, - { title: 'Семейные', hint: 'блокируют опасные сайты, «Семейный» — ещё и взрослые', ids: [ 'yandex_safe', 'yandex_family' ] } + { title: 'Без подмены', hint: 'только шифрование; у Cloudflare, Quad9, Control D, DNS4EU и Яндекса есть варианты с защитой от вирусов и семейным фильтром', ids: [ 'google', 'cloudflare', 'quad9', 'controld', 'dns4eu', 'yandex' ] }, + { title: 'С обходом геоблокировок', hint: 'открывают сервисы, закрытые для России', ids: [ 'xbox', 'comss', 'dnsai', 'bezmezhau', 'geohide', 'malw', 'zapretmoe', 'webshift', 'dagami' ] } ]; +function famIds(key) { return FAMS[key] ? FAMS[key].ids : [ key ]; } +function famOf(id) { for (var k in FAMS) if (FAMS[k].ids.indexOf(id) >= 0) return k; return ''; } +function groupIds(g) { var out = []; g.ids.forEach(function(t) { out = out.concat(famIds(t)); }); return out; } function prov(id) { for (var i = 0; i < PROVIDERS.length; i++) if (PROVIDERS[i].id === id) return PROVIDERS[i]; @@ -25577,6 +27278,7 @@ return view.extend({ var logEl = E('pre', { 'class': 'zm-log' }); var busy = false, pickProv = null, pickForce = null, bootOwn = !!data.bootstrap_custom; var probe = {}, probing = false, subs = {}, health = null, healthSeq = 0, own = []; + var openFam = null, mirrors = {}; var svcBox = E('span', { 'class': 'zm-doh-svc' }); var probeBtn = E('button', { 'class': 'cbi-button', 'click': runProbe }, 'Проверить все'); var ownBusy = false; @@ -25694,6 +27396,7 @@ return view.extend({ paintLat(sub(id), r); if (r === 'wait') sub(id).textContent = '…'; (foLats[id] || []).forEach(function(el) { paintLat(el, r); }); + (mirrors[id] || []).forEach(function(el) { paintLat(el, r); }); } function probeOne(id) { @@ -25954,18 +27657,72 @@ return view.extend({ return E('button', { 'class': 'zm-doh-own-btn' + (cls ? ' ' + cls : ''), 'type': 'button', 'title': title, 'aria-label': title, 'disabled': busy || ownBusy ? '' : null, 'click': function(ev) { ev.stopPropagation(); fn(); } }, text); }; + mirrors = {}; + var pick = function(id) { + if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + pickProv = id === cur() ? null : id; + render(); + }; + var mirror = function(id) { + var el = E('span', { 'class': 'zm-lat' }); + (mirrors[id] = mirrors[id] || []).push(el); + paintLat(el, probe[id]); + return el; + }; + var folder = function(key) { + var f = FAMS[key], on = f.ids.indexOf(curProv) >= 0, show = on ? curProv : f.ids[0], open = openFam === key; + var toggle = function() { openFam = open ? null : key; render(); }; + return E('div', { + 'class': 'zm-node zm-doh-fold' + (on ? ' zm-active' : '') + (open ? ' zm-doh-fold-open' : ''), + 'role': 'button', 'tabindex': '0', 'aria-expanded': open ? 'true' : 'false', + 'title': open ? 'Свернуть' : 'Показать варианты ' + f.label, + 'click': toggle, + 'keydown': function(ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); toggle(); } } + }, [ + E('div', { 'class': 'zm-node-name zm-doh-fold-name' }, [ E('span', {}, f.label), E('span', { 'class': 'zm-svc-chev', 'aria-hidden': 'true' }, '›') ]), + on ? E('div', { 'class': 'zm-doh-fold-cur' }, prov(curProv).v) : '', + E('div', { 'class': 'zm-node-foot' }, [ E('span', {}, on ? prov(curProv).url.replace(/^https:\/\//, '') : f.ids.length + ' ' + (f.ids.length < 5 ? 'варианта' : 'вариантов')), mirror(show) ]) + ]); + }; + var panel = function(key) { + var f = FAMS[key]; + return E('div', { 'class': 'zm-doh-fold-panel' }, [ + E('div', { 'class': 'zm-doh-fold-head' }, [ E('b', {}, f.label + ' — выберите вариант'), + E('button', { 'class': 'zm-doh-fold-x', 'type': 'button', 'title': 'Свернуть', 'aria-label': 'Свернуть', 'click': function() { openFam = null; render(); } }, '✕') ]), + E('div', { 'class': 'zm-nodes zm-nodes-3' }, f.ids.map(function(id) { + var p = prov(id); + paintProbe(id); + return E('div', { 'class': 'zm-node' + (curProv === id ? ' zm-active' : ''), 'role': 'button', 'tabindex': '0', + 'click': function() { pick(id); }, + 'keydown': function(ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); pick(id); } } + }, [ + E('div', { 'class': 'zm-node-name' }, [ (p.k === 'safe' ? '🛡 ' : p.k === 'family' ? '👪 ' : '') + p.v ]), + E('div', { 'class': 'zm-doh-var-desc' }, p.d || VAR_TEXT[p.k] || ''), + E('div', { 'class': 'zm-node-foot' }, [ E('span', {}, [ p.url.replace(/^https:\/\//, '') ]), sub(id) ]) + ]); + })) + ]); + }; var nodes = function(ids, mine) { + var tiles = []; + if (!mine) { + ids.forEach(function(key) { + if (!FAMS[key]) { tiles = tiles.concat(single([ key ], false)); return; } + tiles.push(folder(key)); + if (openFam === key) tiles.push(panel(key)); + }); + return E('div', { 'class': 'zm-nodes zm-nodes-3 zm-doh-grid' }, tiles); + } + return E('div', { 'class': 'zm-nodes zm-nodes-3' }, single(ids, true)); + }; + var single = function(ids, mine) { var tiles = ids.map(function(id) { var p = prov(id); paintProbe(id); return E('div', { 'class': 'zm-node' + (curProv === id ? ' zm-active' : '') + (mine ? ' zm-doh-own' : ''), 'title': mine ? id : null, - 'click': function() { - if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } - pickProv = id === cur() ? null : id; - render(); - } + 'click': function() { pick(id); } }, [ E('div', { 'class': 'zm-node-name' }, [ label(id) ]), mine ? E('div', { 'class': 'zm-doh-own-tools' }, ownSaved(id) ? [ @@ -25982,7 +27739,7 @@ return view.extend({ 'click': function() { addOwn(); }, 'keydown': function(ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); addOwn(); } } }, [ E('span', { 'class': 'zm-sec-add-text' }, ownBusy ? 'Сохраняем…' : '+ Добавить свой сервер') ])); - return E('div', { 'class': 'zm-nodes zm-nodes-3' }, tiles); + return tiles; }; GROUPS.concat([ { title: 'Свои', hint: own.length ? 'выбираются основным и запасными так же, как серверы выше' : 'добавьте свой DoH-сервер — его можно будет выбрать и запасным', ids: own, mine: true } ]).forEach(function(g) { card.appendChild(E('div', { 'class': 'zm-doh-group' }, [ @@ -26032,19 +27789,16 @@ return view.extend({ } var foCard = E('div', { 'class': 'zm-card', 'style': 'display:none' }), foDraft = null, foPicking = false, foLats = {}; - var FO_MAX = 3, KINDS = [ 'plain', 'geo', 'family' ]; - var KIND_TEXT = { plain: 'Обычный — только шифрование', geo: 'С обходом геоблокировок', family: 'Семейный фильтр', own: 'Свой сервер' }; + var FO_MAX = 3; + var KIND_TEXT = { plain: 'Обычный — только шифрование', safe: 'Защита от вирусов', geo: 'С обходом геоблокировок', family: 'Семейный фильтр', own: 'Свой сервер' }; function at(ts) { if (!ts) return ''; var d = new Date(ts * 1000), n = new Date(), p = function(x) { return (x < 10 ? '0' : '') + x; }; var t = p(d.getHours()) + ':' + p(d.getMinutes()); return d.toDateString() === n.toDateString() ? t : p(d.getDate()) + '.' + p(d.getMonth() + 1) + ' ' + t; } - function kind(id) { - for (var i = 0; i < GROUPS.length; i++) if (GROUPS[i].ids.indexOf(id) >= 0) return KINDS[i]; - return 'own'; - } - function kindIds(k) { var i = KINDS.indexOf(k); return i >= 0 ? GROUPS[i].ids : own; } + function kind(id) { var p = prov(id); return p ? p.k : 'own'; } + function kindIds(k) { return k === 'own' ? own : PROVIDERS.filter(function(p) { return p.k === k; }).map(function(p) { return p.id; }); } function foMain() { var f = data.failover || {}; return pickProv || f.pref || cur(); } function foSaved() { var f = data.failover || {}; @@ -26065,7 +27819,7 @@ return view.extend({ render(); } function foTiers(main) { - var k = kind(main), order = k === 'family' ? [ 'family', 'plain' ] : k === 'plain' ? [ 'plain', 'geo' ] : [ 'geo', 'plain' ]; + var k = kind(main), order = k === 'family' ? [ 'family', 'safe', 'plain' ] : k === 'safe' ? [ 'safe', 'family', 'plain' ] : k === 'plain' ? [ 'plain', 'geo' ] : [ 'geo', 'plain' ]; if (own.length) order.push('own'); return order.map(function(x) { return kindIds(x).filter(function(id) { return id !== main; }); }); } @@ -26126,7 +27880,7 @@ return view.extend({ } function foSelect(val, main, used, n) { var sel = E('select', { 'class': 'cbi-input-select zm-fo-sel', 'aria-label': 'Запасной сервер ' + n }); - GROUPS.concat(own.length ? [ { title: 'Свои', ids: own } ] : []).forEach(function(g) { + GROUPS.map(function(g) { return { title: g.title, ids: groupIds(g) }; }).concat(own.length ? [ { title: 'Свои', ids: own } ] : []).forEach(function(g) { var og = E('optgroup', { 'label': g.title }); g.ids.forEach(function(id) { if (id === main || (id !== val && used.indexOf(id) >= 0)) return; @@ -26183,7 +27937,8 @@ return view.extend({ chain.forEach(function(id, i) { var k = kind(id), isNow = live && f.mode !== 'direct' && now === id, warn = ''; if (mk === 'geo' && k !== 'geo') warn = '⚠ Не обходит геоблокировки: пока работает он, ChatGPT, Gemini и другие закрытые для России сервисы могут не открываться'; - else if (mk === 'family' && k !== 'family') warn = '⚠ Без семейного фильтра: пока работает он, опасные и взрослые сайты не блокируются'; + else if (mk === 'family' && k !== 'family') warn = k === 'safe' ? '⚠ Без семейного фильтра: пока работает он, взрослые сайты не блокируются' : '⚠ Без семейного фильтра: пока работает он, опасные и взрослые сайты не блокируются'; + else if (mk === 'safe' && k !== 'safe' && k !== 'family') warn = '⚠ Без защиты от вирусов: пока работает он, опасные сайты не блокируются'; box.appendChild(foStep(String(i + 2), isNow ? 'zm-fo-now' : '', [ E('div', { 'class': 'zm-fo-name' }, [ foSelect(id, main, used, i + 2), isNow ? foTag('сейчас работает', 'zm-fo-tag-now') : '' ]), E('div', { 'class': 'zm-fo-sub' + (warn ? ' zm-fo-warn' : '') }, warn || KIND_TEXT[k]) @@ -26952,12 +28707,37 @@ return view.extend({ if (data.running && !canEmbed) actEl.appendChild(E('p', { 'class': 'zm-hint' }, 'Панель открыта по https, а терминал работает по http — встроить его сюда нельзя, он откроется в отдельной вкладке.')); } - function renderAll() { renderHead(); renderView(); renderActions(); } + var accCard = E('div', { 'class': 'zm-card' }); + function renderAccess() { + accCard.innerHTML = ''; + accCard.style.display = data.installed && data.ts ? '' : 'none'; + if (accCard.style.display) return; + var m = data.mode || 'home', on = m === 'all'; + accCard.appendChild(E('h3', {}, [ 'Доступ через Tailscale ', on ? E('span', { 'class': 'zm-badge zm-ok' }, [ E('span', { 'class': 'zm-dot' }), 'дома и в Tailscale' ]) + : m === 'ts' ? E('span', { 'class': 'zm-badge zm-warn' }, [ E('span', { 'class': 'zm-dot' }), 'только Tailscale' ]) + : m === 'other' ? E('span', { 'class': 'zm-badge zm-warn' }, [ E('span', { 'class': 'zm-dot' }), 'своя настройка' ]) + : E('span', { 'class': 'zm-badge zm-off' }, [ E('span', { 'class': 'zm-dot' }), 'только дома' ]) ])); + var hint = on ? 'Работает дома и через Tailscale. Из интернета закрыт.' + : m === 'ts' ? 'Сейчас только через Tailscale — дома не откроется.' + : m === 'other' ? 'Выбран интерфейс «' + (data.iface || '') + '». Включите, чтобы работал и дома, и через Tailscale.' + : 'Сейчас только дома. Вне дома вместо терминала будет пустая страница.'; + if (!data.wan_closed && !on) hint = 'Нельзя: в firewall открыт вход из интернета (зона wan → Входящие: отклонять).'; + accCard.appendChild(zm.swRow(on, 'Открывать через Tailscale', hint, function() { + quick(on ? 'ts_off' : 'ts_on', on ? 'Терминал теперь только для домашней сети' : 'Терминал открывается и через Tailscale'); + }, busy || (!on && !data.wan_closed))); + var addrs = []; + if (data.lan_ip) addrs.push(E('b', {}, 'http://' + data.lan_ip + ':7788')); + if (data.ts_ip) addrs.push(E('b', {}, 'http://' + data.ts_ip + ':7788')); + if (on && addrs.length) accCard.appendChild(E('p', { 'class': 'zm-hint' }, [ 'Вне дома откройте панель: ', addrs[0], addrs[1] ? E([ ' или ', addrs[1] ]) : '' ])); + } + + function renderAll() { renderHead(); renderView(); renderActions(); renderAccess(); } card.appendChild(headEl); card.appendChild(viewEl); card.appendChild(actEl); wrap.appendChild(card); + wrap.appendChild(accCard); wrap.appendChild(logEl); renderAll(); if (data.job) follow(''); else autoStart(); @@ -26966,6 +28746,594 @@ return view.extend({ }); ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/terminal.js' +cat > '/www/luci-static/resources/view/zapret-manager/tailscale.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require view'; +'require zapret-manager.common as zm'; +var E = (function(raw) { return function() { var a = Array.prototype.slice.call(arguments), i = a.length - 1; if (i >= 1 && (typeof a[i] === 'string' || typeof a[i] === 'number')) a[i] = [ String(a[i]) ]; return raw.apply(null, a); }; })(window.E); + +var ROUTERICH = 'https://rc.routerich.ru'; +var ROUTERICH_NEW = 'https://remote.routerich.ru/create-tailnet'; +var APK_URL = 'https://github.com/tailscale/tailscale-android/releases'; +var PLAY_URL = 'https://play.google.com/store/apps/details?id=com.tailscale.ipn'; +var IOS_URL = 'https://apps.apple.com/app/tailscale/id1470499037'; +var TESTFLIGHT_URL = 'https://testflight.apple.com/join/tLcYLZJV'; +var TAILSOCKS_URL = 'https://github.com/bropines/tailsocks/releases/latest'; + +function badge(cls, text) { + return E('span', { 'class': 'zm-badge ' + cls }, [ E('span', { 'class': 'zm-dot' }), text ]); +} + +function row(label, node) { + return E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, label), node ]); +} + +function ext(href, text) { + return E('a', { 'href': href, 'target': '_blank', 'rel': 'noopener' }, text); +} + +function link(href, text, cls) { + return E('a', { 'class': 'cbi-button ' + (cls || ''), 'href': href, 'target': '_blank', 'rel': 'noopener' }, text); +} + +function steps(items) { + return E('ol', { 'class': 'zm-ts-steps' }, items.filter(Boolean).map(function(it) { return E('li', {}, [ E('div', {}, it) ]); })); +} + +function code(text, okMsg) { + return E('div', { 'class': 'zm-ts-cmd' }, [ + E('code', {}, text), + E('button', { 'type': 'button', 'class': 'cbi-button', 'click': function() { zm.copyText(text, okMsg || 'Скопировано'); } }, 'Скопировать') + ]); +} + +return view.extend({ + load: function() { + zm.injectCss(); + return null; + }, + + render: function() { + var data = {}, loading = true, loadErr = '', slowTimer = null; + var wrap = E('div', { 'class': 'zm-wrap zm-tsc' }); + var mainCard = E('div', { 'class': 'zm-card' }); + var setCard = E('div', { 'class': 'zm-card' }); + var phoneCard = E('div', { 'class': 'zm-card' }); + var bypassCard = E('div', { 'class': 'zm-card' }); + var logEl = E('pre', { 'class': 'zm-log' }); + var busy = false, loginUrl = '', watchTimer = null, watchLeft = 0, luciMode = false; + var DONE = { install: 'Tailscale установлен — осталось подключить роутер к сети', remove: 'Tailscale удалён', repair: 'Настройки для телефона восстановлены' }; + + function loggedIn() { return data.state === 'Running'; } + function official() { return !data.server_kind || data.server_kind === 'official'; } + function srvName() { return official() ? 'Tailscale' : data.server_kind === 'routerich' ? 'Routerich' : (data.server || 'свой сервер'); } + function panel(path) { + if (!data.admin) return ''; + if (official()) return data.admin + path; + if (data.server_kind === 'routerich') return data.admin + ({ '/machines': '/devices', '/dns': '/settings' }[path] || ''); + return data.admin; + } + function panelLink(path, text) { var u = panel(path); return u ? ext(u, text) : E('b', {}, text); } + function luciUrl() { return '/cgi-bin/luci/' + (data.luci_path || 'admin/services/tailscale'); } + function luciWhere() { return 'LuCI → ' + (/^admin\/vpn\//.test(data.luci_path || '') ? 'VPN' : 'Службы') + ' → Tailscale'; } + function keyLogin() { return !official() && data.has_key; } + var FOLD_KEY = 'zm-tsc-fold', folded = {}; + try { folded = JSON.parse(localStorage.getItem(FOLD_KEY) || '{}') || {}; } catch (e) { folded = {}; } + function foldHead(id, title) { + var open = !folded[id]; + function flip() { + folded[id] = open; if (!folded[id]) delete folded[id]; + try { localStorage.setItem(FOLD_KEY, JSON.stringify(folded)); } catch (e) {} + renderAll(); + } + return E('div', { 'class': 'zm-tsc-fhead' + (open ? ' zm-svc-open' : ''), 'role': 'button', 'tabindex': '0', 'aria-expanded': open ? 'true' : 'false', + 'title': open ? 'Свернуть' : 'Развернуть', 'click': flip, + 'keydown': function(ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); flip(); } } }, [ title, E('span', { 'class': 'zm-svc-chev' }, '›') ]); + } + function keyDate() { var d = data.key_expiry ? new Date(data.key_expiry) : null; return d && !isNaN(d) && d.getFullYear() > 2000 ? d : null; } + function keyExp() { var d = keyDate(); return d ? ('0' + d.getDate()).slice(-2) + '.' + ('0' + (d.getMonth() + 1)).slice(-2) + '.' + d.getFullYear() : ''; } + function keyDays() { var d = keyDate(); return d ? Math.floor((d - Date.now()) / 86400000) : 9999; } + + // Ссылку для входа берём из состояния роутера: вход могли начать или закончить и в LuCI, + // и в другой вкладке. Не ждёт входа (NeedsLogin) — плашки «Войдите» нет. + function syncLogin() { + var s = data.state || ''; + if (s === 'Running') luciMode = false; + if (!data.running || s === 'Running' || s === 'Starting' || s === 'NeedsMachineAuth' || s === 'Stopped') loginUrl = ''; + else if (s === 'NeedsLogin' && data.auth_url) loginUrl = data.auth_url; + } + function waiting() { return data.running && !loggedIn() && (data.state === 'NeedsLogin' || data.state === 'Starting' || data.state === 'NoState' || data.state === 'NeedsMachineAuth'); } + + function refresh() { + return zm.tscStatus().then(function(d) { data = d || {}; syncLogin(); renderAll(); }); + } + + // Пока роутер не в сети — следим за входом: первые 3 минуты раз в 3 с, дальше раз в 15 с, + // пока открыта страница. Вход мог занять больше 3 минут или пройти в LuCI. + function watchLogin() { + if (watchTimer) clearTimeout(watchTimer); + watchLeft = 0; + (function tick() { + watchTimer = setTimeout(function() { + if (!document.body.contains(wrap)) { watchTimer = null; return; } + if (busy) { tick(); return; } + zm.tscStatus().then(function(d) { + var was = loggedIn(); + data = d || {}; + syncLogin(); + renderAll(); + if (loggedIn()) { + watchTimer = null; + if (!was) zm.toast('Вход выполнен — роутер в вашей сети ' + srvName(), 'info'); + return; + } + tick(); + }).catch(function() { tick(); }); + }, ++watchLeft <= 60 ? 3000 : 15000); + })(); + } + + function follow(action) { + busy = true; + renderAll(); + zm.pollJob('tsc', logEl, function(ok) { + busy = false; + zm.toast(ok ? (DONE[action] || 'Готово') : 'Не получилось — подробности в журнале', ok ? 'info' : 'error'); + refresh(); + }); + } + + function job(action, text) { + if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } + zm.tscAction(action, '').then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return; } + zm.toast(text, 'warning'); + follow(action); + }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); + } + + function quick(action, mode, okText) { + if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return Promise.resolve(null); } + busy = true; + renderAll(); + return zm.tscAction(action, mode || '').then(function(res) { + busy = false; + if (res.error) zm.toast(res.error, 'error'); else if (okText) zm.toast(okText, 'info'); + return refresh().then(function() { return res; }); + }).catch(function() { busy = false; zm.toast('Роутер не ответил', 'error'); renderAll(); return null; }); + } + + // Вход через аккаунт из панели у части роутеров не завершается — роутер ждёт входа, хотя сайт + // пишет «Login successful». Кнопка Login в LuCI входит надёжно, поэтому ведём туда. + function luciLogin() { + var w = null; + luciMode = true; + try { w = window.open(luciUrl(), '_blank'); } catch (e) { w = null; } + zm.toast(w ? 'Открыли LuCI → Tailscale — войдите на вкладке Account Settings' : 'Нажмите «Войти через LuCI»', 'info'); + renderAll(); + watchLogin(); + } + + function login(noPopup) { + if (!keyLogin() && data.luci_app) { luciLogin(); return Promise.resolve(null); } + var w = null; + if (!keyLogin() && !noPopup) { try { w = window.open('', '_blank'); } catch (e) { w = null; } } + if (keyLogin()) zm.toast('Подключаем роутер к ' + srvName() + ' по ключу…', 'info'); + return quick('login', '', '').then(function(res) { + if (!res || res.error) { if (w) w.close(); return; } + if (res.url) { + loginUrl = res.url; + if (w) { try { w.location.href = res.url; } catch (e) { w.close(); w = null; } } + zm.toast(w ? 'Войдите в открывшейся вкладке' : 'Нажмите «Открыть страницу входа»', 'info'); + renderAll(); + watchLogin(); + } else { + if (w) w.close(); + zm.toast(res.state === 'NeedsMachineAuth' ? 'Роутер подключён — подтвердите его в панели сервера' : 'Роутер подключён к сети ' + srvName(), 'info'); + } + }); + } + + function routerichDialog() { + return zm.dialog({ + title: 'Подключение к Routerich', okText: 'Подключить', + blocks: [ + { type: 'list', title: 'Если сети ещё нет', items: [ + E('span', {}, [ 'Откройте ', ext(ROUTERICH_NEW, 'remote.routerich.ru/create-tailnet'), ' и нажмите «Create My Tailnet»' ]), + 'Сохраните оба ключа (кнопка «Download Details») — их показывают один раз и не восстанавливают', + 'Management Key — для входа в панель Routerich, Device Auth Key — сюда и на телефон', + 'Ключ действует год. Сетей можно создать не больше двух в день с одного адреса' + ] }, + { type: 'input', id: 'key', title: 'Device Auth Key', placeholder: 'ключ устройства' } + ], + canOk: function(x) { return String(x.key || '').trim().length >= 8; } + }).then(function(x) { + if (!x) return; + var k = String(x.key).trim(); + if (/^[0-9a-f]{64}$/i.test(k)) { zm.toast('Это Management Key — ключ входа в панель. Для роутера нужен Device Auth Key', 'error'); return; } + return apply(ROUTERICH, k); + }); + } + + function customDialog() { + return zm.dialog({ + title: 'Свой сервер', okText: 'Подключить', + blocks: [ + { type: 'input', id: 'url', title: 'Адрес сервера', placeholder: 'https://headscale.example.com', value: data.server_kind === 'custom' ? data.server : '' }, + { type: 'input', id: 'key', title: 'Ключ устройства (если есть)', placeholder: 'auth key', hint: 'Без ключа панель покажет ссылку для входа, если сервер её выдаёт.' } + ], + canOk: function(x) { return /^https?:\/\/\S+$/.test(String(x.url || '').trim()); } + }).then(function(x) { if (x) return apply(String(x.url).trim(), String(x.key || '').trim()); }); + } + + function officialLogin() { + if (official()) return login(false); + return apply('', ''); + } + + function changeServer() { + if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } + var cur = official() ? 'official' : data.server_kind; + zm.dialog({ + title: 'Сервер Tailscale', okText: 'Дальше', + intro: 'Через сервер устройства находят друг друга. Сам трафик идёт напрямую между телефоном и роутером и зашифрован — сервер его не видит.', + blocks: [ { type: 'choice', id: 'k', value: cur, title: 'Где ваша сеть', options: [ + { id: 'routerich', label: 'Routerich — рекомендуется', hint: 'Вход по ключу, без аккаунта. Сеть создаётся в один клик на remote.routerich.ru. Работает из России.' }, + { id: 'official', label: 'Аккаунт Tailscale', hint: 'Вход через Google, Apple, Microsoft или GitHub. Сайт Tailscale из России часто не открывается.' }, + { id: 'custom', label: 'Свой сервер', hint: 'Headscale, ionscale или другой совместимый сервер.' } + ] } ] + }).then(function(v) { + if (!v) return; + if (v.k === 'official') return apply('', ''); + if (v.k === 'routerich') return routerichDialog(); + return customDialog(); + }); + } + + function apply(srv, key) { + return quick('server', srv + '\t' + key, '').then(function(res) { + if (!res || res.error) return; + if (!data.running) { zm.toast('Сервер сохранён — запустите Tailscale', 'info'); return; } + if (loggedIn() && !res.changed && !key) { zm.toast('Сервер не изменился', 'info'); return; } + return login(true); + }); + } + + function btn(cls, text, fn) { + return E('button', { 'class': 'cbi-button ' + cls, 'disabled': busy ? '' : null, 'click': fn }, text); + } + + function renderIntro() { + var free = +data.free_kb || 0, need = +data.need_kb || 20480, low = free > 0 && free < need; + mainCard.appendChild(E('h3', {}, 'Tailscale')); + mainCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Телефон в любой сети — мобильный интернет, кафе, поездка — выходит в интернет через домашний роутер. С ним работают Zapret, Steer и sTGWS, как дома. Белый IP не нужен: телефон и роутер находят друг друга сами.')); + mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Всё ставится с GitHub: уменьшенная сборка Tailscale для OpenWrt и страница для LuCI. Сайт Tailscale из России не открывается, поэтому подключиться можно через Routerich — по ключу, без аккаунта.')); + mainCard.appendChild(E('div', { 'class': 'zm-kv' }, [ + row('Нужно', E('span', {}, 'около ' + Math.round(need / 1024) + ' МБ флеш-памяти')), + free ? row('Свободно', low ? badge('zm-warn', Math.round(free / 1024) + ' МБ — не хватает') : E('span', {}, Math.round(free / 1024) + ' МБ')) : E([]) + ])); + mainCard.appendChild(E('div', { 'class': 'zm-actions' }, [ + btn('cbi-button-positive', 'Установить', function() { job('install', 'Устанавливаем Tailscale — ход работы виден в окне вывода'); }) + ])); + } + + function renderMain() { + mainCard.innerHTML = ''; + if (!data.installed) { renderIntro(); return; } + mainCard.appendChild(E('h3', {}, 'Tailscale')); + var st = data.state || '', kv = E('div', { 'class': 'zm-kv' }); + kv.appendChild(row('Служба', data.running ? badge('zm-ok', 'работает') : badge(data.enabled ? 'zm-bad' : 'zm-off', 'остановлена'))); + kv.appendChild(row('Сервер', E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap' }, [ + E('span', {}, srvName() + (keyLogin() ? ' · вход по ключу' : '')), + E('button', { 'type': 'button', 'class': 'cbi-button', 'style': 'padding:2px 10px', 'disabled': busy ? '' : null, 'click': changeServer }, 'Сменить') + ]))); + if (data.running) { + kv.appendChild(row('Сеть', loggedIn() ? E('span', {}, data.tailnet || 'подключена') : + st === 'NeedsMachineAuth' ? badge('zm-warn', 'ждёт подтверждения роутера') : + st === 'Starting' ? badge('zm-warn', 'вход выполнен — подключаемся к серверу') : badge('zm-warn', 'роутер не подключён'))); + } + if (data.ip) kv.appendChild(row('Роутер в сети', E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap' }, [ + E('span', {}, (data.host ? data.host + ' · ' : '') + data.ip), + E('button', { 'type': 'button', 'class': 'cbi-button', 'style': 'padding:2px 10px', 'click': function() { zm.copyText(data.ip, 'Адрес скопирован'); } }, 'Скопировать') + ]))); + if (loggedIn()) { + kv.appendChild(row('Выход в интернет', !data.exit_want ? badge('zm-off', 'выключен') : + data.exit_ok ? badge('zm-ok', 'готов — выберите роутер на телефоне') : badge('zm-warn', 'ждёт одобрения на сервере'))); + if (data.lan_want) kv.appendChild(row('Домашняя сеть', data.lan_ok ? badge('zm-ok', 'доступна · ' + data.lan_cidr) : badge('zm-warn', 'ждёт одобрения · ' + data.lan_cidr))); + kv.appendChild(row('Вход роутера', keyExp() ? badge(keyDays() < 30 ? 'zm-bad' : 'zm-warn', 'истекает ' + keyExp()) : badge('zm-ok', 'бессрочный'))); + } + var srcName = data.source === 'github' ? ' · сборка с GitHub' : data.source === 'openwrt' ? ' · из репозитория OpenWrt' : ''; + kv.appendChild(row('Версия', E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap' }, [ + E('span', {}, (data.version || '—') + srcName), + data.newer ? E('button', { 'type': 'button', 'class': 'cbi-button cbi-button-action', 'style': 'padding:2px 10px', 'disabled': busy ? '' : null, + 'click': function() { job('update', 'Обновляем Tailscale — ход работы в окне вывода'); } }, 'Обновить до ' + data.latest) : E([]) + ]))); + mainCard.appendChild(kv); + + if (data.on === false) { + mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Tailscale установлен не через панель. Нажмите «Настроить для телефона» — панель подготовит сеть и firewall.')); + } else if (data.running && (!data.net || !data.zone)) { + mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Часть настроек сети пропала (интерфейс tailscale или его место в домашней зоне firewall). Нажмите «Исправить».')); + } + + var viaLuci = data.luci_app && !keyLogin(); + if (data.running && !loggedIn() && viaLuci && (luciMode || loginUrl)) { + mainCard.appendChild(E('div', { 'class': 'zm-tsc-login' }, [ + E('b', {}, 'Войдите через LuCI'), + E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Так вход проходит надёжнее всего. Страница обновится сама, когда роутер подключится.'), + steps([ + [ 'Нажмите ', E('b', {}, 'Войти через LuCI'), ' — откроется ', luciWhere(), '. Перейдите на вкладку ', E('b', {}, 'Account Settings') ], + official() + ? [ 'Поля ', E('b', {}, 'Custom Login Server'), ' и ', E('b', {}, 'Auth Key'), ' оставьте пустыми' ] + : [ 'В ', E('b', {}, 'Custom Login Server'), ' вставьте ', E('code', {}, data.server || ''), ', поле ', E('b', {}, 'Auth Key'), ' оставьте пустым' ], + [ 'Нажмите ', E('b', {}, 'Login'), ', откройте ссылку и войдите в ', E('b', {}, 'тот же аккаунт'), official() ? ' (Google, Apple, Microsoft или GitHub), что и на телефоне' : ', что и на телефоне' ], + [ 'Вернитесь сюда — панель сама увидит вход и включит выход в интернет и домашнюю сеть' ] + ]), + E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Если LuCI пишет «NOT RUNNING» и не даёт войти — нажмите здесь «Перезапустить» и попробуйте снова.'), + E('div', { 'class': 'zm-actions', 'style': 'margin:2px 0 0' }, [ link(luciUrl(), 'Войти через LuCI', 'cbi-button-positive') ]) + ])); + } + if (data.running && !loggedIn() && !viaLuci && loginUrl) { + mainCard.appendChild(E('div', { 'class': 'zm-tsc-login' }, [ + E('b', {}, 'Войдите в ' + srvName()), + E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, official() ? 'Подойдёт Google, Microsoft, Apple или GitHub. Страница обновится сама, когда вход пройдёт.' : 'Страница обновится сама, когда вход пройдёт.'), + link(loginUrl, 'Открыть страницу входа', 'cbi-button-positive') + ])); + } + if (data.running && !loggedIn() && !loginUrl && !(viaLuci && luciMode) && st !== 'NeedsMachineAuth' && !keyLogin()) { + mainCard.appendChild(E('div', { 'class': 'zm-tsc-login' }, [ + E('b', {}, 'Подключите роутер к сети'), + E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Сайт Tailscale из России часто не открывается, поэтому проще Routerich: сеть создаётся в один клик, роутер и телефон входят по ключу.'), + E('div', { 'class': 'zm-actions', 'style': 'margin:2px 0 0' }, [ + btn('cbi-button-positive', 'Routerich — по ключу', routerichDialog), + btn('', 'Аккаунт Tailscale', officialLogin), + btn('', 'Свой сервер', customDialog) + ]), + E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, [ E('b', {}, 'Роутер и телефон должны быть в одной сети: '), 'войдите в один и тот же аккаунт Tailscale или используйте один и тот же ключ Routerich.' ]), + data.luci_app ? E('div', { 'class': 'zm-tsc-luci' }, [ + E('b', {}, 'Или подключите роутер в LuCI'), + steps([ + [ 'Откройте ', ext(luciUrl(), luciWhere()), ' → вкладка ', E('b', {}, 'Account Settings'), '. Если там «Service Status: NOT RUNNING» — сначала нажмите «Перезапустить» на этой странице' ], + [ E('b', {}, 'Routerich'), ': в ', E('b', {}, 'Custom Login Server'), ' вставьте ', E('code', {}, ROUTERICH), ', в ', E('b', {}, 'Custom Login Server Auth Key'), ' — ваш Device Auth Key. ', E('b', {}, 'Аккаунт Tailscale'), ': оба поля оставьте пустыми' ], + [ 'Нажмите ', E('b', {}, 'Login'), '. Для аккаунта Tailscale откроется ссылка — войдите в ', E('b', {}, 'тот же аккаунт'), ' (Google, Apple, Microsoft или GitHub), что и на телефоне' ], + [ 'Вернитесь на эту страницу — панель увидит подключение сама, включит выход в интернет и домашнюю сеть' ] + ]) + ]) : E([]) + ])); + } + if (data.luci_app && data.on !== false) { + mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'В LuCI кнопку «Auto Configure Firewall» нажимать не нужно — панель уже настроила сеть и firewall для телефона.')); + } + if (st === 'NeedsMachineAuth') { + mainCard.appendChild(E('p', { 'class': 'zm-hint' }, [ 'В вашей сети новые устройства нужно подтверждать. Откройте ', panelLink('/machines', 'список устройств'), ' и подтвердите роутер.' ])); + } + if (loggedIn() && data.prefs_ok === false) { + mainCard.appendChild(E('div', { 'class': 'zm-tsc-box' }, [ + E('b', {}, 'Настройки Tailscale на роутере сбились'), + E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Так бывает после входа через LuCI: роутер не предлагает себя как выход в интернет и домашнюю сеть, а Tailscale ставит свои правила firewall, из-за которых перестают работать Steer и Forkozz. Панель исправляет это сама раз в минуту — или нажмите кнопку сейчас.'), + E('div', {}, [ btn('cbi-button-action', 'Применить настройки', function() { quick('apply', '', 'Настройки применены — роутер предлагает себя как выход'); }) ]) + ])); + } + if (loggedIn() && data.lan_prev && data.lan_ip) { + var dnsLan = data.lan_want !== false; + mainCard.appendChild(E('div', { 'class': 'zm-tsc-box' }, [ + E('b', {}, 'Адрес роутера в домашней сети изменился: ' + data.lan_prev + ' → ' + data.lan_ip), + E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Роутер, Telegram через sTGWS и маршрут домашней сети панель уже перенастроила сама.' + (dnsLan ? ' Осталось одно — DNS телефона: он указан в панели сервера, и туда роутер сам не дотянется.' : '')), + dnsLan ? steps([ + [ 'Откройте ', official() ? panelLink('/dns', 'настройки DNS') : data.server_kind === 'routerich' ? panelLink('/dns', 'настройки панели Routerich') : 'панель своего сервера', ' и замените сервер имён ', E('code', {}, data.lan_prev), ' на ', code(data.lan_ip, 'Адрес роутера скопирован') ], + data.lan_ok ? [ 'Подсеть ', E('b', {}, data.lan_cidr), ' уже одобрена' ] : [ 'Одобрите новую подсеть ', E('b', {}, data.lan_cidr), ' — инструкция ниже' ] + ]) : E([]), + E('div', {}, [ btn('cbi-button-action', dnsLan ? 'Готово, DNS заменил' : 'Понятно', function() { quick('lanack', '', 'Отмечено'); }) ]) + ])); + } + if (loggedIn() && keyExp() && folded.key && keyDays() >= 14) { + mainCard.appendChild(E('div', { 'class': 'zm-tsc-box' }, [ foldHead('key', E('b', {}, 'Вход роутера закончится ' + keyExp() + ' (через ' + keyDays() + ' дн.)')) ])); + } else if (loggedIn() && keyExp()) { + mainCard.appendChild(E('div', { 'class': 'zm-tsc-box' }, [ + keyDays() >= 14 ? foldHead('key', E('b', {}, 'Вход роутера закончится ' + keyExp() + ' (через ' + keyDays() + ' дн.)')) + : E('b', {}, 'Вход роутера закончится ' + keyExp() + (keyDays() >= 0 ? ' (через ' + keyDays() + ' дн.)' : '')), + E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'После этого телефон потеряет связь с домом, пока роутер не войдёт заново. Сделайте вход роутера бессрочным — это делается один раз, адрес ' + (data.ip || '100.x') + ' останется прежним.'), + official() ? steps([ + [ 'Откройте ', panelLink('/machines', 'список устройств'), ' в панели Tailscale' ], + [ 'У роутера ', E('b', {}, data.host || 'openwrt'), ' нажмите «···» → ', E('b', {}, 'Disable key expiry') ] + ]) : steps([ + [ 'Откройте ', data.server_kind === 'routerich' ? panelLink('/machines', 'устройства в панели Routerich') : 'панель своего сервера', ' и найдите роутер ', E('b', {}, data.host || 'openwrt') ], + [ 'Отключите для него истечение ключа (Key expiry / Disable expiry), если сервер это позволяет' ] + ]), + E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Статус обновится сам.') + ])); + } + if (loggedIn() && data.prefs_ok !== false && ((data.exit_want && !data.exit_ok) || (data.lan_want && !data.lan_ok))) { + var what = []; + if (data.exit_want && !data.exit_ok) what.push(E('span', {}, official() ? [ 'Включите ', E('b', {}, 'Use as exit node') ] : [ 'Одобрите ', E('b', {}, 'Exit node'), ' (0.0.0.0/0)' ])); + if (data.lan_want && !data.lan_ok) what.push(E('span', {}, [ what.length ? 'отметьте подсеть ' : 'Отметьте подсеть ', E('b', {}, data.lan_cidr) ])); + var whatNode = what.length > 1 ? E('span', {}, [ what[0], ' и ', what[1] ]) : what[0]; + mainCard.appendChild(E('div', { 'class': 'zm-tsc-box' }, [ + E('b', {}, 'Одобрите роутер на сервере — это делается один раз'), + official() ? steps([ + [ 'Откройте ', panelLink('/machines', 'список устройств'), ' в панели Tailscale' ], + [ 'У роутера ', E('b', {}, data.host || 'openwrt'), ' нажмите «···» → «Edit route settings»' ], + [ whatNode, ' и сохраните. Галочки неактивны? Роутер ещё не предложил маршруты — нажмите «Применить настройки заново» ниже и обновите страницу панели Tailscale' ] + ]) : steps([ + data.server_kind === 'routerich' + ? [ 'Откройте ', panelLink('/machines', 'устройства в панели Routerich'), ' (вход по Management Key)' ] + : [ 'Откройте панель своего сервера' + (data.server ? ' (' + data.server + ')' : '') ], + data.server_kind === 'routerich' + ? (data.exit_want && !data.exit_ok ? [ 'У роутера ', E('b', {}, data.host || 'openwrt'), ' включите тумблер ', E('b', {}, 'Exit Node'), ' — он между кнопкой «Маршруты» и удалением, его легко не заметить' ] : [ 'Найдите роутер ', E('b', {}, data.host || 'openwrt') ]) + : [ 'Найдите роутер ', E('b', {}, data.host || 'openwrt'), ' в списке устройств и откройте его маршруты' ], + data.server_kind === 'routerich' + ? (data.lan_want && !data.lan_ok ? [ 'Нажмите ', E('b', {}, 'Маршруты'), ', отметьте подсеть ', E('b', {}, data.lan_cidr), ' и сохраните' ] : null) + : [ whatNode ] + ].filter(Boolean)), + E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Статус здесь обновится в течение минуты.') + ])); + } + + var b = []; + if (!data.running) b.push(btn('cbi-button-positive', 'Запустить', function() { quick('restart', '', 'Tailscale запущен'); })); + else if (!loggedIn() && (keyLogin() || (loginUrl && !viaLuci))) b.push(btn('cbi-button-positive', keyLogin() ? 'Подключить' : 'Получить новую ссылку', function() { login(false); })); + if (data.on === false) b.push(btn('cbi-button-action', 'Настроить для телефона', function() { job('repair', 'Настраиваем сеть для телефона — ход работы в окне вывода'); })); + else if (data.running && (!data.net || !data.zone)) b.push(btn('cbi-button-action', 'Исправить', function() { job('repair', 'Восстанавливаем настройки — ход работы в окне вывода'); })); + if (data.running) b.push(btn('', 'Перезапустить', function() { quick('restart', '', 'Tailscale перезапущен'); })); + if (panel('/machines')) b.push(link(panel('/machines'), official() ? 'Панель Tailscale' : 'Панель ' + srvName())); + if (data.luci_app) b.push(link(luciUrl(), 'Открыть в LuCI')); + if (loggedIn()) b.push(btn('', 'Отключить роутер', function() { + zm.dialog({ title: 'Отключить роутер от сети?', okText: 'Отключить', + blocks: [ { type: 'list', title: 'Что произойдёт', items: [ + 'Роутер пропадёт из вашей сети ' + srvName(), + 'Телефоны перестанут выходить в интернет через него', + 'Если роутер больше не нужен в сети — удалите его и в панели ' + srvName(), + 'Подключить снова можно в любой момент' + ] } ] + }).then(function(v) { if (v) quick('logout', '', 'Роутер отключён от сети'); }); + })); + b.push(btn('cbi-button-remove', 'Удалить', function() { + zm.dialog({ title: 'Удалить Tailscale?', danger: true, okText: 'Удалить Tailscale', + blocks: [ { type: 'list', title: 'Что произойдёт', items: [ + 'Роутер отключится и пропадёт из вашей сети', + 'Пакет tailscale удалится вместе с настройками и ключом', + 'Сеть, firewall и DNS вернутся к прежнему виду' + ] } ] + }).then(function(v) { if (v) job('remove', 'Удаляем Tailscale — ход работы виден в окне вывода'); }); + })); + mainCard.appendChild(E('div', { 'class': 'zm-actions' }, b)); + } + + function renderSet() { + setCard.innerHTML = ''; + setCard.style.display = data.installed && data.on !== false ? '' : 'none'; + if (setCard.style.display) return; + setCard.appendChild(E('h3', {}, 'Настройки')); + setCard.appendChild(zm.swRow(data.exit_want !== false, 'Выход в интернет через роутер', 'Телефон ходит в интернет через дом — с обходом блокировок.', function() { + var on = data.exit_want === false; + quick('exit', on ? 'on' : 'off', on ? 'Роутер снова предлагается как выход в интернет' : 'Роутер больше не выход в интернет'); + }, busy)); + setCard.appendChild(zm.swRow(!!data.lan_want, 'Доступ к домашней сети', 'С телефона откроются роутер, эта панель и устройства дома' + (data.lan_cidr ? ' (' + data.lan_cidr + ')' : '') + '.', function() { + var on = !data.lan_want; + quick('lan', on ? 'on' : 'off', on ? 'Домашняя сеть будет доступна после одобрения на сервере' : 'Доступ к домашней сети выключен'); + }, busy)); + if (loggedIn()) setCard.appendChild(E('div', { 'class': 'zm-actions', 'style': 'margin-top:4px' }, [ + btn('', 'Применить настройки заново', function() { quick('apply', '', 'Настройки применены'); }) + ])); + if (data.term_mode && data.term_mode !== 'all') setCard.appendChild(E('p', { 'class': 'zm-hint' }, [ E('b', {}, 'Терминал'), ' сейчас открывается только ' + (data.term_mode === 'ts' ? 'через Tailscale, а дома нет' : 'дома') + '. Включите «Открывать через Tailscale» на ', E('a', { 'href': '#terminal' }, 'странице Терминала'), ', чтобы он работал и там, и там.' ])); + setCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Панель раз в минуту проверяет, что всё на месте, и сама возвращает настройки — после входа через LuCI, перезагрузки роутера и обновления Forkozz, Zapret или sTGWS. Tailscale она держит без своих правил firewall и без MagicDNS на роутере, а после каждого его перезапуска перезапускает Forkozz и Steer: так он им не мешает.')); + } + + function renderPhone() { + phoneCard.innerHTML = ''; + phoneCard.style.display = data.installed && data.on !== false ? '' : 'none'; + if (phoneCard.style.display) return; + var host = E('b', {}, data.host || 'роутер'); + phoneCard.appendChild(foldHead('phone', E('h3', { 'style': 'margin:0' }, 'Как подключить телефон'))); + if (folded.phone) return; + phoneCard.appendChild(E('div', { 'style': 'height:12px' })); + // адрес роутера для DNS телефона: домашний, если одобрена домашняя сеть, иначе адрес в Tailscale + var useLan = data.lan_want !== false && data.lan_ip; + var dnsIp = useLan ? data.lan_ip : data.ip; + var ipNode = dnsIp ? code(dnsIp, 'Адрес роутера скопирован') : E('span', { 'class': 'zm-hint' }, ' — он появится после подключения роутера'); + + phoneCard.appendChild(E('h4', { 'style': 'margin:0 0 6px' }, '1. Один раз — в панели ' + (official() ? 'Tailscale' : srvName()))); + phoneCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin:0 0 4px' }, 'Чтобы телефон узнавал адреса сайтов у роутера: так работают Steer, Forkozz, Mixomo и блокировка рекламы, а запросы не уходят мимо.')); + if (official()) phoneCard.appendChild(steps([ + [ 'Откройте ', panelLink('/dns', 'настройки DNS'), ' → ', E('b', {}, 'Nameservers'), ' → ', E('b', {}, 'Add nameserver'), ' → ', E('b', {}, 'Custom…'), ' и вставьте адрес роутера:', ipNode ], + [ 'В этом же окне включите ', E('b', {}, 'Use with exit node'), ' и нажмите ', E('b', {}, 'Save'), '. ', E('b', {}, 'Restrict to domain'), ' (Split DNS) не включайте' ], + [ 'Справа от ', E('b', {}, 'Global nameservers'), ' включите ', E('b', {}, 'Override DNS servers') ], + [ E('b', {}, 'MagicDNS'), ' должен быть ', E('b', {}, 'выключен'), ' — кнопку «Enable MagicDNS» не нажимайте. Если он включён — нажмите «Disable MagicDNS»' ] + ])); + else phoneCard.appendChild(steps([ + [ 'Откройте ', data.server_kind === 'routerich' ? panelLink('/dns', 'настройки панели Routerich') : 'панель своего сервера', ' → раздел ', E('b', {}, 'DNS'), ' и добавьте сервер имён — адрес роутера:', ipNode ], + [ 'Если есть настройка «использовать с exit node» — включите её. Включите замену DNS для всех устройств (', E('b', {}, 'Override'), ') и сохраните' ], + [ E('b', {}, 'MagicDNS'), ' выключите' ] + ])); + if (useLan && !data.lan_ok) phoneCard.appendChild(E('p', { 'class': 'zm-hint zm-ab-warn' }, 'Домашняя сеть ' + (data.lan_cidr || '') + ' ещё не одобрена на сервере — без этого телефон не достучится до ' + dnsIp + '. Одобрите её (см. выше).')); + + phoneCard.appendChild(E('h4', { 'style': 'margin:14px 0 6px' }, '2. На телефоне')); + var android = [ E('b', {}, 'Android'), ': ', ext(PLAY_URL, 'Google Play'), ' или ', ext(APK_URL, 'APK с GitHub'), ' (файл tailscale-android-universal). Или ', E('b', {}, 'TailSocks'), ' — неофициальный клиент Tailscale с открытым кодом (', ext(TAILSOCKS_URL, 'APK на GitHub'), ')' ]; + var iphone = [ E('b', {}, 'iPhone'), ': ', ext(IOS_URL, 'App Store'), ', а если его там нет — ', ext(TESTFLIGHT_URL, 'TestFlight') ]; + phoneCard.appendChild(steps([ + [ 'Установите приложение ', E('b', {}, 'Tailscale'), '. ' ].concat(android, [ '. ' ], iphone), + official() + ? [ 'Войдите в ', E('b', {}, 'тот же аккаунт'), ', что и на роутере' + (data.tailnet ? ' (' + data.tailnet + ')' : '') ] + : [ 'Укажите сервер. Android: шестерёнка → ', E('b', {}, 'Accounts'), ' → «⋮» → ', E('b', {}, 'Use an alternate server'), '. iPhone: «⋯» → ', E('b', {}, 'Settings'), ' → ', E('b', {}, 'Login Server'), '. Затем войдите с ', E('b', {}, 'Device Auth Key'), ' — тем же, что и для роутера', data.server ? code(data.server, 'Адрес сервера скопирован') : E([]) ], + [ 'Откройте ', E('b', {}, 'Exit Node'), ', выберите ', host, ' и включите ', E('b', {}, 'Allow LAN access') ], + [ 'Включите VPN — готово. ', E('b', {}, 'Use Tailscale DNS'), ' в приложении не выключайте (по умолчанию включено)' ] + ])); + phoneCard.appendChild(E('p', { 'class': 'zm-hint' }, [ E('b', {}, 'TailSocks'), ': в настройках выберите ', E('b', {}, 'Режим туннеля → VPN (TUN)'), (official() ? '' : ', сервер — в «Аккаунт и подключение → Сервер авторизации»'), '. Не подключается к серверу — ', E('b', {}, 'Настройки → Обход блокировок'), '.' ])); + phoneCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Роутера нет в списке Exit Node? Подождите минуту после входа роутера и откройте список снова. На iPhone помогает включить и выключить «Run as Exit Node».')); + if (!official() && data.server) phoneCard.appendChild(E('div', { 'class': 'zm-hint' }, [ + E('b', {}, 'Ноутбук в поездке'), ': установите Tailscale с сайта tailscale.com, выполните в терминале (Windows — PowerShell) команду ниже и введите Device Auth Key на открывшейся странице. Затем выберите ', E('b', {}, data.host || 'роутер'), ' как Exit Node.', + code('tailscale up --login-server=' + data.server, 'Команда скопирована'), + 'Ноутбук уже был в другой сети? Добавьте к команде --reset --force-reauth.' + ])); + phoneCard.appendChild(E('p', { 'class': 'zm-hint' }, [ E('b', {}, 'Дома VPN Tailscale выключайте'), ' — и на телефоне, и на компьютере. В Wi-Fi роутера Steer, Forkozz и Zapret и так работают, а через Tailscale трафик идёт лишним кругом.' ])); + } + + function bRow(name, node, hint) { + return E('div', { 'class': 'zm-tsc-svc' }, [ + E('div', { 'class': 'zm-tsc-svc-head' }, [ E('b', {}, name), node ]), + hint ? E('div', { 'class': 'zm-hint', 'style': 'margin:2px 0 0' }, hint) : E([]) + ]); + } + + function renderBypass() { + bypassCard.innerHTML = ''; + bypassCard.style.display = data.installed && data.on !== false ? '' : 'none'; + if (bypassCard.style.display) return; + var dnsNeed = 'Работает, когда DNS телефона — роутер: шаг 1 в «Как подключить телефон».'; + var zrInst = data.zapret_inst !== false && (data.zapret_inst || data.zapret), stInst = data.steer_inst !== false && (data.steer_inst || data.steer); + if (!zrInst && !data.stgws && !stInst && !data.fk && !data.mx && !data.adblock) { bypassCard.style.display = 'none'; return; } + bypassCard.appendChild(E('h3', {}, 'Обход блокировок на телефоне')); + if (zrInst) bypassCard.appendChild(bRow('Zapret', data.zapret ? badge('zm-ok', 'работает') : badge('zm-off', 'не запущен'), + data.zapret ? 'Включать отдельно не нужно — трафик телефона идёт через роутер и проходит через Zapret.' : 'Запустите Zapret на его странице — тогда он заработает и для телефона.')); + if (data.stgws) bypassCard.appendChild(bRow('sTGWS (Telegram)', data.tg_rules >= 2 ? badge('zm-ok', 'работает') : badge('zm-warn', 'подключается'), + 'Telegram с телефона идёт через мост sTGWS. Правила восстанавливаются сами после перезапуска sTGWS.')); + if (stInst) bypassCard.appendChild(bRow('Steer', data.steer ? badge('zm-ok', 'подключён') : badge('zm-off', 'не включён'), + data.steer ? 'Телефон уже в правилах Steer. ' + dnsNeed : '')); + if (data.fk) bypassCard.appendChild(bRow('Forkozz', data.fk_ok ? badge('zm-ok', 'подключён') : badge('zm-warn', 'не подключён'), + data.fk_ok ? 'Устройства Tailscale добавлены к домашней сети Forkozz. ' + dnsNeed + : E('span', {}, [ 'Forkozz пока не берёт трафик телефона. ', btn('cbi-button-action', 'Подключить', function() { job('repair', 'Подключаем Forkozz — ход работы в окне вывода'); }) ]))); + if (data.mx) bypassCard.appendChild(bRow('Mixomo', badge('zm-ok', 'готов'), + 'Настраивать отдельно не нужно. Работает, когда DNS телефона — домашний адрес роутера' + (data.lan_ip ? ' (' + data.lan_ip + ')' : '') + ': шаг 1 в «Как подключить телефон».')); + if (data.adblock) bypassCard.appendChild(bRow('Блокировка рекламы', badge('zm-ok', 'готова'), dnsNeed)); + } + + function renderLoading() { + mainCard.innerHTML = ''; + mainCard.appendChild(E('h3', {}, 'Tailscale')); + if (loadErr) { + mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Роутер не ответил: ' + loadErr)); + mainCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-action', 'click': firstLoad }, 'Повторить') ])); + } else { + mainCard.appendChild(E('div', { 'class': 'zm-tsc-wait' }, [ E('span', { 'class': 'zm-tsc-spin' }), E('span', {}, 'Получаем состояние Tailscale…') ])); + if (slowTimer === true) mainCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin:8px 0 0' }, 'Роутер отвечает дольше обычного — так бывает сразу после запуска Tailscale.')); + } + [ setCard, phoneCard, bypassCard ].forEach(function(c) { c.innerHTML = ''; c.style.display = 'none'; }); + } + function renderAll() { if (loading) { renderLoading(); return; } renderMain(); renderSet(); renderPhone(); renderBypass(); } + function firstLoad() { + loading = true; loadErr = ''; slowTimer = setTimeout(function() { if (loading && !loadErr) { slowTimer = true; renderAll(); } }, 4000); + renderAll(); + return zm.tscStatus().then(function(d) { + if (slowTimer && slowTimer !== true) clearTimeout(slowTimer); + data = d || {}; loading = false; + syncLogin(); + renderAll(); + if (data.job) follow(''); + else if (waiting()) watchLogin(); + }).catch(function(e) { + if (slowTimer && slowTimer !== true) clearTimeout(slowTimer); + loadErr = (e && e.message) || String(e || 'нет ответа'); + renderAll(); + }); + } + + [ mainCard, setCard, phoneCard, bypassCard, logEl ].forEach(function(n) { wrap.appendChild(n); }); + firstLoad(); + return wrap; + } +}); +ZM_INSTALLER_EOF +chmod 0644 '/www/luci-static/resources/view/zapret-manager/tailscale.js' cat > '/www/luci-static/resources/view/zapret-manager/steer.js' << 'ZM_INSTALLER_EOF' @@ -26980,7 +29348,7 @@ var PHASE_TEXT = { check: 'проверяем', remove: 'удаляем', sub: 'настраиваем подписку' }; -var STEER_TABS = [ { id: 'svc', label: 'Сервисы' }, { id: 'dev', label: 'Устройства' }, { id: 'warp', label: 'WARP' }, { id: 'sub', label: 'VPN' } ]; +var STEER_TABS = [ { id: 'svc', label: 'Сервисы' }, { id: 'dev', label: 'Устройства' }, { id: 'warp', label: 'WARP' }, { id: 'sub', label: 'VPN' }, { id: 'set', label: 'Настройки' } ]; function fmtBytes(n) { n = +n || 0; @@ -27091,7 +29459,8 @@ return view.extend({ try { tab = localStorage.getItem('zm.steer.tab') || 'svc'; } catch (e) {} if (!STEER_TABS.some(function(t) { return t.id === tab; })) tab = 'svc'; var tabBar = E('div', { 'class': 'zm-actions', 'style': 'margin:10px 0' }); - var panes = { svc: E('div', {}), dev: E('div', {}), warp: E('div', {}), sub: E('div', {}) }; + var panes = { svc: E('div', {}), dev: E('div', {}), warp: E('div', {}), sub: E('div', {}), set: E('div', {}) }; + var setCard = E('div', { 'class': 'zm-card' }); function refresh() { return zm.steerStatus().then(function(res) { data = res || {}; renderAll(); }); @@ -27155,7 +29524,7 @@ return view.extend({ }); }, function() { if (++ticks % 3) return; - zm.steerStatus().then(function(res) { if (res) { data = res; renderMain(); } }).catch(function() {}); + zm.steerStatus().then(function(res) { if (res) { data = res; renderMain(); renderSet(); } }).catch(function() {}); }); } @@ -27338,6 +29707,52 @@ return view.extend({ return badge('zm-bad', 'не работает'); } + function seg(label, items) { + return E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, label), E('div', { 'class': 'zm-seg' }, items.map(function(it) { + return E('div', { 'class': 'zm-seg-item' + (it[0] ? ' zm-active' : ''), 'click': function() { if (busy || it[0]) return; it[2](); } }, it[1]); + })) ]); + } + + function renderSet() { + setCard.innerHTML = ''; + if (!data.installed || data.blocker) return; + setCard.appendChild(E('h3', {}, 'Настройки Steer')); + var isDrop = data.onfail === 'drop'; + setCard.appendChild(seg('Если туннель упал', [ + [ !isDrop, 'Пустить напрямую', function() { act('onfail', 'direct', 'Без туннеля сервисы пойдут напрямую'); } ], + [ isDrop, 'Остановить', function() { act('onfail', 'drop', 'Без туннеля сервисы будут остановлены'); } ] + ])); + setCard.appendChild(E('p', { 'class': 'zm-hint' }, isDrop + ? 'Сервисы не открываются, пока туннель не заработает. Остальной интернет работает как обычно.' + : 'Сервисы идут через провайдера, как без Steer, пока туннель не заработает.')); + var dnsTun = data.dnsmode !== 'router'; + setCard.appendChild(seg('DNS сервисов', [ + [ dnsTun, 'Через туннель', function() { act('dnsmode', 'tun', 'DNS сервисов — через туннель'); } ], + [ !dnsTun, 'Через роутер', function() { act('dnsmode', 'router', 'DNS сервисов — через роутер'); } ] + ])); + setCard.appendChild(E('p', { 'class': 'zm-hint' }, dnsTun + ? 'Сервисы видят ту же страну, что и их трафик. Рекомендуется.' + : 'Сервисы могут видеть Россию и работать не полностью.')); + var ripOn = data.realip !== 'off'; + setCard.appendChild(seg('Точно по IP', [ + [ ripOn, 'Включено', function() { act('realip', 'on', 'Точно по IP включено'); } ], + [ !ripOn, 'Выключено', function() { act('realip', 'off', 'Точно по IP выключено'); } ] + ])); + setCard.appendChild(E('p', { 'class': 'zm-hint' }, ripOn + ? 'В туннель идёт всё, что ведёт на адреса сервиса, — так работают и приложения, которые запоминают адреса. Иногда за компанию уходят соседние сайты. Рекомендуется.' + : 'В туннель идёт только то, что открыто по имени сайта. Некоторые приложения могут работать не полностью.')); + setCard.appendChild(zm.swRow(!!data.lists_via, 'Скачивать списки через туннель', 'Если GitHub у провайдера не открывается. Не ответит туннель — скачаются напрямую.', function() { + if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } + var on = !data.lists_via; + zm.steerAction('lists_via', on ? 'on' : 'off').then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return; } + data.lists_via = on; + zm.toast(on ? 'Списки будут качаться через туннель' : 'Списки будут качаться напрямую', 'info'); + renderSet(); + }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); + })); + } + function renderMain() { hostsWarn.set(!!(data && data.installed && data.hosts_extra)); mainCard.innerHTML = ''; @@ -27381,19 +29796,8 @@ return view.extend({ var dAll = devAllCount(); mainCard.appendChild(row('Через туннель', E('span', {}, [ n ? n + ' ' + plural(n, 'сервис', 'сервиса', 'сервисов') : (dAll ? 'сервисы не выбраны' : 'ничего не выбрано'), dAll ? ' · весь трафик ' + dAll + ' ' + plural(dAll, 'устройства', 'устройств', 'устройств') : '' ].join('')))); - var isDrop = data.onfail === 'drop'; - mainCard.appendChild(E('div', { 'class': 'zm-row' }, [ - E('span', { 'class': 'zm-label' }, 'Если туннель упал'), - E('div', { 'class': 'zm-seg' }, [ - E('div', { 'class': 'zm-seg-item' + (!isDrop ? ' zm-active' : ''), 'click': function() { if (busy || !isDrop) return; act('onfail', 'direct', 'Без туннеля сервисы пойдут напрямую'); } }, 'Пустить напрямую'), - E('div', { 'class': 'zm-seg-item' + (isDrop ? ' zm-active' : ''), 'click': function() { if (busy || isDrop) return; act('onfail', 'drop', 'Без туннеля сервисы будут остановлены'); } }, 'Остановить') - ]) - ])); - mainCard.appendChild(E('p', { 'class': 'zm-hint' }, isDrop - ? 'Пока туннель не работает, выбранные сервисы не открываются совсем — мимо туннеля, через провайдера, они не пойдут. Остальной интернет работает как обычно.' - : 'Пока туннель не работает, выбранные сервисы идут через провайдера, как без Steer: что не заблокировано — откроется, но ваш настоящий адрес будет виден.')); if (data.doh) { - var DOHN = { cloudflare: 'Cloudflare', google: 'Google', quad9: 'Quad9', xbox: 'XBOX', comss: 'Comss', dnsai: 'DNS-AI', bezmezhau: 'Bezmezhau', yandex_family: 'Яндекс Семейный', yandex_safe: 'Яндекс Безопасный', geohide_ru: 'GeoHide RU', geohide_eu: 'GeoHide EU', geohide_us: 'GeoHide US', malw: 'MALW', zapretmoe: 'zapret.moe', webshift: 'WebShift', dagami: 'Dagami', controld: 'Control D', dns4eu: 'DNS4EU', multi: 'несколько серверов', other: 'свой сервер' }; + var DOHN = { cloudflare: 'Cloudflare', google: 'Google', quad9: 'Quad9', xbox: 'XBOX', comss: 'Comss', dnsai: 'DNS-AI', bezmezhau: 'Bezmezhau', yandex: 'Яндекс', yandex_family: 'Яндекс · Семейный', yandex_safe: 'Яндекс · Безопасный', cloudflare_sec: 'Cloudflare · Без вирусов', cloudflare_fam: 'Cloudflare · Семейный', quad9_open: 'Quad9 · Без фильтра', controld_sec: 'Control D · Без вирусов', controld_fam: 'Control D · Семейный', dns4eu_sec: 'DNS4EU · Защитный', dns4eu_fam: 'DNS4EU · Детский', geohide_ru: 'GeoHide RU', geohide_eu: 'GeoHide EU', geohide_us: 'GeoHide US', malw: 'MALW', zapretmoe: 'zapret.moe', webshift: 'WebShift', dagami: 'Dagami', controld: 'Control D', dns4eu: 'DNS4EU', multi: 'несколько серверов', other: 'свой сервер' }; mainCard.appendChild(row('Шифрованный DNS', E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap' }, [ E('span', {}, 'DNS over HTTPS · ' + (DOHN[data.doh] || data.doh)), badge('zm-ok', 'работает') @@ -27884,16 +30288,6 @@ return view.extend({ act('apply', '', 'Скачиваем и применяем списки'); } }, 'Обновить списки сейчас') ])); - listCard.appendChild(zm.swRow(!!data.lists_via, 'Скачивать списки через VPN', 'Списки и каталог будут качаться через туннель WARP или свой конфиг, а если он не ответит — напрямую. Помогает, если GitHub у провайдера не открывается. В режиме VPN-подписки списки качаются напрямую.', function() { - if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } - var on = !data.lists_via; - zm.steerAction('lists_via', on ? 'on' : 'off').then(function(res) { - if (res.error) { zm.toast(res.error, 'error'); return; } - data.lists_via = on; - zm.toast(on ? 'Списки будут качаться через туннель' : 'Списки будут качаться напрямую', 'info'); - renderLists(); - }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); - })); var bar = zm.saveBar({ saveLabel: data.installed && !data.stopped ? 'Сохранить и применить' : 'Сохранить', onCancel: function() { pick = null; renderLists(); }, @@ -28074,7 +30468,7 @@ return view.extend({ diagBusy = false; diagRes = res || {}; renderCheck(); - zm.steerStatus().then(function(r) { if (r) { data = r; renderMain(); } }).catch(function() {}); + zm.steerStatus().then(function(r) { if (r) { data = r; renderMain(); renderSet(); } }).catch(function() {}); if (!quiet) zm.toast('Проверка закончена', 'info'); }).catch(function() { diagBusy = false; renderCheck(); }); } @@ -29089,6 +31483,7 @@ return view.extend({ renderTabs(); renderSub(); renderMain(); + renderSet(); renderDns(); renderLists(); renderCustom(); @@ -29104,10 +31499,11 @@ return view.extend({ renderAll(); if (tab === 'dev' && !data.blocker && data.installed) loadDevs(); [ listCard, customCard, exclCard, checkCard, routeCard ].forEach(function(n) { panes.svc.appendChild(n); }); - [ warpCard, wfixCard, autoCard ].forEach(function(n) { panes.warp.appendChild(n); }); + [ warpCard, wfixCard ].forEach(function(n) { panes.warp.appendChild(n); }); + [ setCard, autoCard ].forEach(function(n) { panes.set.appendChild(n); }); panes.dev.appendChild(devCard); 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, panes.set ].forEach(function(n) { wrap.appendChild(n); }); 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(); } @@ -31160,6 +33556,7 @@ return view.extend({ if (status.strategy) { currentBanner.appendChild(E('span', {}, 'Сейчас применено: ')); currentBanner.appendChild(E('b', {}, [ String(status.strategy) ])); + if (status.testing) currentBanner.appendChild(E('span', { 'class': 'zm-badge zm-warn', 'style': 'margin-left:10px', 'title': 'Пока идёт тест, роутер по очереди пробует стратегии. Потом вернётся ваша' }, [ E('span', { 'class': 'zm-dot' }), 'идёт тест' + (status.test_now ? ': сейчас ' + status.test_now : '') ])); if (status.rkn_on) currentBanner.appendChild(E('span', { 'class': 'zm-badge zm-ok', 'style': 'margin-left:10px', 'title': 'Стратегия работает только для сайтов из списка РКН и вашего списка' }, [ E('span', { 'class': 'zm-dot' }), 'по спискам РКН' ])); else if (status.rkn) currentBanner.appendChild(E('span', { 'class': 'zm-badge zm-warn', 'style': 'margin-left:10px', 'title': 'Списки РКН включены, но с этой стратегией не работают — выберите другую' }, [ E('span', { 'class': 'zm-dot' }), 'списки РКН ждут другую стратегию' ])); else if (status.ulist_on) currentBanner.appendChild(E('span', { 'class': 'zm-badge zm-ok', 'style': 'margin-left:10px', 'title': 'Стратегия работает только для сайтов из вашего списка хостов' }, [ E('span', { 'class': 'zm-dot' }), 'по вашему списку' ])); @@ -31616,6 +34013,7 @@ return view.extend({ busy = false; curMode = ''; zm.toast(ok ? 'Тест завершён' : 'Тест завершился с ошибкой — смотрите журнал', ok ? 'info' : 'error'); + refreshAll(); zm.testStatus().then(function(res) { status = res; autoInfo = res.auto || {}; timeInfo = res; @@ -32786,6 +35184,18 @@ html.zm-theme-dark .zm-tile:not(.zm-active):not(.zm-tile-off) { .zm-doh-group-head .zm-hint { margin: 0; } .zm-doh-group .zm-node { min-height: 0; gap: 6px; } .zm-doh-group .zm-node.zm-sec-add { min-height: 58px; } +.zm-doh-grid { grid-auto-flow: row dense; } +.zm-doh-fold-name { display: flex; justify-content: space-between; align-items: center; gap: 8px; } +.zm-doh-fold-name .zm-svc-chev { flex: none; } +.zm-doh-fold-open .zm-svc-chev { transform: rotate(90deg); opacity: .8; } +.zm-doh-fold-open:not(.zm-active) { border-color: rgba(26,127,55,.55); } +.zm-doh-fold-cur { font-size: 12.5px; font-weight: 600; color: #1a7f37; margin-top: -2px; } +html.zm-theme-dark .zm-doh-fold-cur { color: #3fb950; } +.zm-doh-fold-panel { grid-column: 1 / -1; border: 1px solid rgba(26,127,55,.35); border-radius: 14px; padding: 12px; background: rgba(26,127,55,.04); } +.zm-doh-fold-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; font-size: 13px; } +.zm-doh-fold-x { flex: none; width: 28px; height: 28px; border-radius: 8px; border: 1px solid rgba(110,118,129,.3); background: transparent; color: inherit; cursor: pointer; font-size: 13px; line-height: 1; opacity: .7; } +.zm-doh-fold-x:hover { opacity: 1; border-color: rgba(110,118,129,.6); } +.zm-doh-var-desc { font-size: 12px; opacity: .75; line-height: 1.35; } .zm-doh-own .zm-node-name { padding-right: 52px; } .zm-doh-own-tools { position: absolute; top: 6px; right: 6px; display: flex; gap: 2px; } .zm-doh-own-btn { display: grid; place-items: center; width: 22px; height: 22px; padding: 0; margin: 0; border: 0; border-radius: 6px; background: transparent; color: inherit; font-size: 12px; line-height: 1; cursor: pointer; opacity: .45; transition: opacity .15s, background .15s, color .15s; } @@ -33331,6 +35741,9 @@ html.zm-theme-dark .zm-fk-chip.zm-fk-chip-x { border-color: rgba(255,255,255,.25 @media (max-width: 600px) { .zm-seg { display: flex; flex-wrap: wrap; max-width: 100%; box-sizing: border-box; } .zm-seg-item { white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; box-sizing: border-box; } } .zm-log-wait { color: #e3c04a; font-weight: 600; white-space: pre-wrap; } .zm-si-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 14px; margin-top: 14px; } +.zm-si-group { margin: 22px 0 10px; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; opacity: .6; } +.zm-si-cols { column-width: 340px; column-gap: 14px; } +.zm-si-cols > .zm-si-box { break-inside: avoid; -webkit-column-break-inside: avoid; display: inline-block; width: 100%; margin: 0 0 14px; vertical-align: top; } .zm-si-box { border: 1px solid rgba(0,0,0,.1); border-radius: 12px; padding: 12px 14px; background: rgba(0,0,0,.02); min-width: 0; box-sizing: border-box; } div > .zm-si-box { margin-top: 14px; } .zm-si-grid > .zm-si-box { margin-top: 0; } @@ -33580,6 +35993,16 @@ html.zm-wn-lock, html.zm-wn-lock body { overflow: hidden; } .zm-wn-list li span { opacity: .72; } .zm-wn-check { display: flex; align-items: flex-start; gap: 10px; margin: 12px 0 2px; padding: 10px 14px; border-radius: 12px; border: 1px dashed rgba(217,119,6,.6); font-size: 13.5px; font-weight: 600; line-height: 1.45; cursor: pointer; } .zm-wn-check input { width: 18px; height: 18px; margin: 1px 0 0; flex-shrink: 0; cursor: pointer; } +/* Темы, которые рисуют чекбокс переключателем (proton2025 и др.): возвращаем обычную галочку */ +html .zm-wn-check input[type="checkbox"], html input[type="checkbox"].zm-pg-check { + -webkit-appearance: checkbox; appearance: auto; position: static; display: inline-block; + width: 18px; height: 18px; min-width: 0; min-height: 0; padding: 0; + background: none; border: 0; border-radius: 0; box-shadow: none; transform: none; transition: none; flex: 0 0 auto; +} +html input[type="checkbox"].zm-pg-check { width: 16px; height: 16px; margin: 0; } +html:root input[type="checkbox"].zm-pg-check { accent-color: #cf222e; } +html .zm-wn-check input[type="checkbox"]::before, html .zm-wn-check input[type="checkbox"]::after, +html input[type="checkbox"].zm-pg-check::before, html input[type="checkbox"].zm-pg-check::after { content: none; display: none; } .zm-wn-tip { margin: 10px 0 0; padding: 9px 12px; border-radius: 12px; background: rgba(37,99,235,.08); font-size: 13px; line-height: 1.5; } html.zm-theme-dark .zm-wn-tip { background: rgba(96,165,250,.1); } .zm-wn .zm-dlg-note { margin-top: 12px; } @@ -33780,6 +36203,20 @@ html.zm-theme-dark .zm-mon-xtip { background: #1c2128; } .zm-mon-grid { stroke: rgba(127,127,127,.28); stroke-width: 1; vector-effect: non-scaling-stroke; } .zm-mon-grid.zm-mon-grid-d, .zm-mon-grid.zm-mon-grid-v { stroke: rgba(127,127,127,.18); stroke-dasharray: 3 4; } .zm-mon-area { fill: var(--zm-c-cpu); opacity: .14; } +.zm-mon-nchart { display: block; width: 100%; height: 84px; margin: 0 0 8px; } +.zm-mon-nrx, .zm-mon-ntx { stroke-width: 1.8; stroke-linejoin: round; vector-effect: non-scaling-stroke; fill-opacity: .2; } +.zm-mon-nrx { stroke: var(--zm-acc2); fill: var(--zm-acc2); } +.zm-mon-ntx { stroke: var(--zm-c-mem); fill: var(--zm-c-mem); } +.zm-mon-nleg { display: flex; justify-content: space-between; gap: 6px 14px; flex-wrap: wrap; font-size: 13px; margin: 0 0 14px; } +.zm-mon-nleg i { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: 1px; } +.zm-mon-nleg i.zm-mon-nrx { background: var(--zm-acc2); } .zm-mon-nleg i.zm-mon-ntx { background: var(--zm-c-mem); } +.zm-mon-nleg small { opacity: .6; margin-left: 6px; } +.zm-mon-netsel { font-size: 12.5px; padding: 3px 8px; height: auto; max-width: 60%; } +.zm-mon-fan { gap: 6px; } +.zm-mon-fan svg { width: 14px; height: 14px; flex: none; } +.zm-mon-fan.zm-ok svg { animation: zm-mon-spin 1.4s linear infinite; } +@keyframes zm-mon-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { .zm-mon-fan.zm-ok svg { animation: none; } } .zm-mon-line { fill: none; stroke-width: 2.4; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; } .zm-mon-line.zm-mon-l-cpu { stroke: var(--zm-c-cpu); } .zm-mon-line.zm-mon-l-mem { stroke: var(--zm-c-mem); } @@ -33922,6 +36359,29 @@ html.zm-theme-dark .zm-wn.zm-cl .zm-wn-list li .zm-cl-del { background: rgba(148 .zm-cl-all { text-align: center; padding: 4px 0; } .zm-cl-btns .cbi-button { flex: 1 1 0; } } +.zm-tsc-login, .zm-tsc-box { display: flex; flex-direction: column; gap: 8px; margin-top: 14px; padding: 12px 14px; border-radius: 12px; border: 1px solid rgba(110,118,129,.25); background: rgba(110,118,129,.06); } +.zm-tsc-login { border-color: rgba(46,160,67,.4); background: rgba(46,160,67,.07); } +.zm-tsc-box { border-color: rgba(191,135,0,.4); background: rgba(191,135,0,.07); } +.zm-tsc-login .cbi-button { align-self: flex-start; margin: 2px 0 0; } +.zm-tsc-svc { padding: 10px 0; border-top: 1px solid rgba(110,118,129,.18); } +.zm-tsc-svc:first-of-type { border-top: 0; padding-top: 2px; } +.zm-tsc-svc-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; } +.zm-tsc .zm-ts-steps { margin: 6px 0 4px; } +.zm-tsc .zm-ts-cmd { max-width: 460px; } +.zm-tsc .zm-ts-cmd code { overflow-wrap: break-word; } +.zm-tsc-wait { display: flex; align-items: center; gap: 10px; padding: 6px 0 2px; font-size: 14px; opacity: .85; } +@media (prefers-reduced-motion: reduce) { html .zm-tsc-spin { animation-duration: 1.2s !important; animation-iteration-count: infinite !important; } } +.zm-tsc-spin { width: 18px; height: 18px; flex: none; border-radius: 50%; border: 2.5px solid rgba(127,127,127,.25); border-top-color: var(--zm-acc2, #3b82f6); animation: zm-mon-spin .8s linear infinite; } +.zm-tsc-fhead { display: flex; align-items: center; justify-content: space-between; gap: 10px; cursor: pointer; user-select: none; outline: none; } +.zm-tsc-fhead:hover .zm-svc-chev { opacity: .9; } +.zm-tsc-fhead:focus-visible { box-shadow: 0 0 0 2px rgba(59,130,246,.5); border-radius: 6px; } +.zm-tsc-fhead .zm-svc-chev { flex: none; width: 22px; text-align: center; } +.zm-tsc-luci { margin-top: 4px; padding-top: 10px; border-top: 1px dashed rgba(110,118,129,.3); } +.zm-tsc-luci code { font-size: 12px; padding: 1px 6px; border-radius: 6px; background: rgba(110,118,129,.14); } +html.zm-theme-dark .zm-ts-steps li::before { background: rgba(88,166,255,.16); color: #79c0ff; } +@media (max-width: 600px) { + .zm-tsc .zm-ts-cmd { max-width: none; } +} ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/style.css' @@ -34030,7 +36490,7 @@ return view.extend({ var siActs = E('div', { 'class': 'zm-actions' }); var siCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Системная информация'), - E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Роутер, сеть, Zapret, hosts, пакеты и доступность сайтов — одним отчётом. Займёт до минуты.'), + E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Всё о роутере и Zapret Manager одним отчётом: память, система, сеть, каждый установленный компонент с настройками, стратегия, пакеты и доступность сайтов. Адреса, ключи, пароли и ссылки подписок в отчёт не попадают — его можно смело отправлять в чат поддержки. Займёт до минуты.'), siActs, siLog, siBody ]); @@ -34045,9 +36505,19 @@ return view.extend({ return E('div', { 'class': 'zm-si-box' }, [ E('div', { 'class': 'zm-si-head' }, [ E('h4', {}, [ title ]), badge || E([]) ]) ].concat(body)); } + function siSections() { + var l = (si.sections || []).slice(), i = -1; + l.forEach(function(s, k) { if (s.title === 'Компоненты ZM') i = k; }); + if (i < 0) return l; + var c = l.splice(i, 1)[0], at = 0; + l.forEach(function(s, k) { if ({ 'Роутер': 1, 'Память': 1, 'Система': 1, 'Сеть': 1 }[s.title]) at = k + 1; }); + l.splice(at, 0, c); + return l; + } + function siText() { var out = [ 'Zapret Manager — системная информация' + (si.ts ? ' (' + si.ts + ')' : '') ]; - (si.sections || []).forEach(function(s) { + siSections().forEach(function(s) { out.push('', '== ' + s.title + ' =='); s.rows.forEach(function(r) { out.push(r[0] + ': ' + r[1]); }); }); @@ -34058,6 +36528,7 @@ return view.extend({ out.push('', '== Доступность сайтов: ' + si.sites_ok + '/' + si.sites_total + ' =='); si.sites.forEach(function(x) { out.push((x.ok ? '[ OK ] ' : '[FAIL] ') + x.name); }); } + out.push('', '(адреса, ключи, пароли и ссылки подписок в отчёт не включены)'); return out.join('\n'); } @@ -34083,14 +36554,17 @@ return view.extend({ if (si && si.ts && !siBusy) siActs.appendChild(E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Собрано ' + si.ts)); siBody.innerHTML = ''; if (!si || siBusy) return; - var grid = E('div', { 'class': 'zm-si-grid' }); - (si.sections || []).forEach(function(s) { - grid.appendChild(siBox(s.title, s.rows.map(function(r) { + var SYS = { 'Роутер': 1, 'Память': 1, 'Система': 1, 'Сеть': 1 }; + var gSys = E('div', { 'class': 'zm-si-cols' }), gZm = E('div', { 'class': 'zm-si-cols' }); + siSections().forEach(function(s) { + if (!s.rows || !s.rows.length) return; + (SYS[s.title] ? gSys : gZm).appendChild(siBox(s.title, s.rows.map(function(r) { return E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, [ r[0] ]), siState(r[1], r[2]) ]); }))); }); - if (si.hosts && si.hosts.length) grid.appendChild(siBox('Домены в hosts', [ E('div', { 'class': 'zm-chips' }, si.hosts.map(function(h) { return E('span', { 'class': 'zm-chip' }, [ h ]); })) ])); - siBody.appendChild(grid); + if (si.hosts && si.hosts.length) gZm.appendChild(siBox('Домены в hosts', [ E('div', { 'class': 'zm-chips' }, si.hosts.map(function(h) { return E('span', { 'class': 'zm-chip' }, [ h ]); })) ])); + if (gSys.childNodes.length) { siBody.appendChild(E('div', { 'class': 'zm-si-group' }, 'Роутер и сеть')); siBody.appendChild(gSys); } + if (gZm.childNodes.length) { siBody.appendChild(E('div', { 'class': 'zm-si-group' }, 'Zapret Manager и компоненты')); siBody.appendChild(gZm); } if (si.strategy) siBody.appendChild(siBox('Стратегия Zapret', [ E('pre', { 'class': 'zm-si-pre' }, [ si.strategy ]) ])); if (si.sites && si.sites.length) { var ok = si.sites_ok, all = si.sites_total, cls = ok === all ? 'zm-ok' : ok * 2 >= all ? 'zm-warn' : 'zm-bad'; @@ -34243,6 +36717,7 @@ return view.extend({ setTimeout(tick, 1000); var netBusy = false; + var msFmt = function(v) { var n = parseFloat(v); return isNaN(n) ? String(v || '') : n < 10 ? String(Math.round(n * 10) / 10) : String(Math.round(n)); }; var netCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Проверка сети'), netEl, @@ -34259,12 +36734,12 @@ return view.extend({ netEl.innerHTML = ''; netEl.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'IPv4 (google.com)'), - zm.badge(res.ipv4_ok === true, 'доступен, ' + res.ipv4_ms + ' мс', 'недоступен') + zm.badge(res.ipv4_ok === true, 'доступен, ' + msFmt(res.ipv4_ms) + ' мс', 'недоступен') ])); var noDns = res.ipv6_ok === true && res.ipv6_dns === false; netEl.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, noDns ? 'IPv6 (по адресу)' : 'IPv6 (google.com)'), - zm.badge(res.ipv6_ok === true, 'доступен, ' + res.ipv6_ms + ' мс', 'недоступен') + zm.badge(res.ipv6_ok === true, 'доступен, ' + msFmt(res.ipv6_ms) + ' мс', 'недоступен') ])); if (noDns) netEl.appendChild(E('p', { 'class': 'zm-hint' }, 'IPv6 на роутере работает, но DNS роутера не отдаёт IPv6-адреса сайтов — устройства ходят на сайты по IPv4. Включать IPv6 в Zapret не нужно.')); }).catch(function() { netBusy = false; }); @@ -34668,6 +37143,8 @@ return view.extend({ { g: 'vpn', product: 'hev-socks5-tunnel', author: 'heiher', url: 'https://github.com/heiher/hev-socks5-tunnel' }, { g: 'vpn', product: 'MagiTrickle', author: 'MagiTrickle', url: 'https://github.com/MagiTrickle/MagiTrickle' }, { g: 'vpn', product: 'AmneziaWG', author: 'amnezia-vpn', url: 'https://github.com/amnezia-vpn' }, + { g: 'vpn', product: 'Tailscale', author: 'Tailscale', url: 'https://github.com/tailscale/tailscale' }, + { g: 'vpn', product: 'TailSocks (клиент для Android)', author: 'bropines', url: 'https://github.com/bropines/tailsocks' }, { g: 'vpn', product: 'awg-openwrt (пакеты AmneziaWG)', author: 'Slava-Shchipunov', url: 'https://github.com/Slava-Shchipunov/awg-openwrt' }, { g: 'vpn', product: 'awg-openwrt (сборки)', author: '2Grey', url: 'https://github.com/2Grey/awg-openwrt' }, { g: 'vpn', product: 'warpscout (разведка WARP)', author: 'vernette', url: 'https://github.com/vernette/warpscout' }, @@ -34828,7 +37305,7 @@ return view.extend({ var snap = null, prev = null, procs = [], prevProc = null, prevProcTotal = 0, procPct = {}; var hist = { cpu: [], mem: [], temp: [], t: [] }; var paused = false, timer = null, ptimer = null, failed = 0, sortBy = 'cpu', mode = 'over', procErr = ''; - var cpuNow = { all: 0, cores: [], user: 0, sys: 0, irq: 0, io: 0 }, netNow = null; + var cpuNow = { all: 0, cores: [], user: 0, sys: 0, irq: 0, io: 0 }; var devEl = E('span', { 'class': 'zm-mon-dev' }, '—'); var liveEl = E('span', { 'class': 'zm-badge zm-ok' }, [ E('span', { 'class': 'zm-dot' }), 'обновляется каждые 2 с' ]); @@ -34894,8 +37371,9 @@ return view.extend({ var plot = E('div', { 'class': 'zm-mon-plot' }, [ chart, xLine, xDots.cpu, xDots.mem, xDots.temp, xTip ]); var hover = null; function legend(cls, text) { return E('span', { 'class': 'zm-mon-leg' }, [ E('i', { 'class': cls }), text ]); } + var fanEl = E('span', { 'class': 'zm-badge zm-off zm-mon-fan', 'title': 'Вентилятор', 'style': 'display:none' }); var chartCard = E('div', { 'class': 'zm-card zm-mon-chartcard zm-mon-flex' }, [ - E('div', { 'class': 'zm-mon-cardhead' }, [ E('h3', {}, 'Последние 5 минут'), E('div', { 'class': 'zm-mon-legs' }, [ + E('div', { 'class': 'zm-mon-cardhead' }, [ E('div', { 'style': 'display:flex; align-items:center; gap:10px; flex-wrap:wrap' }, [ E('h3', { 'style': 'margin:0' }, 'Последние 5 минут'), fanEl ]), E('div', { 'class': 'zm-mon-legs' }, [ legend('zm-mon-l-cpu', 'Процессор'), legend('zm-mon-l-mem', 'Память'), legend('zm-mon-l-temp', 'Температура, °C') ]) ]), E('div', { 'class': 'zm-mon-chartbox' }, [ axL, plot, axR, E('div', {}), axX, E('div', {}) ]) ]); @@ -35007,7 +37485,80 @@ return view.extend({ ]); var netBox = E('div', {}), sensBox = E('div', {}), infoBox = E('div', {}); - var netCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Сеть сейчас'), netBox ]); + var NET_WIN = 120, NW = 300, NH = 84, NET_KEY = 'zm-mon-net'; + var netHist = {}, netPick = '', netList = ''; + try { netPick = localStorage.getItem(NET_KEY) || ''; } catch (e) { netPick = ''; } + var netSel = E('select', { 'class': 'cbi-input-select zm-mon-netsel', 'aria-label': 'Какую сеть показывать', 'change': function() { + netPick = netSel.value; + try { localStorage.setItem(NET_KEY, netPick); } catch (e) {} + renderSnap(); + } }); + var pRx = svg('path', { 'class': 'zm-mon-nrx' }), pTx = svg('path', { 'class': 'zm-mon-ntx' }); + var nChart = svg('svg', { viewBox: '0 0 ' + NW + ' ' + NH, preserveAspectRatio: 'none', 'class': 'zm-mon-nchart', role: 'img', 'aria-label': 'Загрузка и отдача за 2 минуты' }, [ pRx, pTx ]); + var nLeg = E('div', { 'class': 'zm-mon-nleg' }); + var netCard = E('div', { 'class': 'zm-card' }, [ E('div', { 'class': 'zm-mon-cardhead' }, [ E('h3', { 'style': 'margin:0' }, 'Сеть сейчас'), netSel ]), nChart, nLeg, netBox ]); + function netCur(s) { + var l = (s && s.net && s.net.ifs) || []; + return l.filter(function(x) { return x.n === netPick; })[0] || l.filter(function(x) { return x.i; })[0] || l.filter(function(x) { return x.n === 'wan'; })[0] || l[0] || null; + } + function netTrack(s, p) { + var l = (s.net && s.net.ifs) || [], pl = (p && p.net && p.net.ifs) || [], dt = p ? s.t - p.t : 0; + l.forEach(function(x) { + var o = pl.filter(function(y) { return y.d === x.d; })[0], h = netHist[x.d] || (netHist[x.d] = []); + if (!o || dt <= 0 || dt > 30) return; + h.push({ t: s.t, rx: Math.max(0, (x.c[0] - o.c[0]) / dt), tx: Math.max(0, (x.c[1] - o.c[1]) / dt) }); + while (h.length && s.t - h[0].t > NET_WIN + 4) h.shift(); + }); + } + function netArea(h, k, mx, tl) { + if (h.length < 2) return ''; + var pts = h.map(function(q) { return [ NW * (1 - (tl - q.t) / NET_WIN), NH - 2 - (NH - 6) * q[k] / mx ]; }); + return 'M' + pts.map(function(q) { return q[0].toFixed(1) + ' ' + q[1].toFixed(1); }).join(' L') + + ' L' + pts[pts.length - 1][0].toFixed(1) + ' ' + NH + ' L' + pts[0][0].toFixed(1) + ' ' + NH + ' Z'; + } + function renderNet(s) { + var l = (s.net && s.net.ifs) || [], cur = netCur(s); + var key = l.map(function(x) { return x.n + (x.i ? '*' : ''); }).join(','); + if (key !== netList) { + netList = key; netSel.innerHTML = ''; + l.forEach(function(x) { netSel.appendChild(E('option', { 'value': x.n }, x.n + (x.i ? ' · интернет' : ''))); }); + } + netSel.style.display = l.length > 1 ? '' : 'none'; + if (cur) { netSel.value = cur.n; netSel.title = cur.n + ' (' + cur.d + ')'; } + var h = cur ? (netHist[cur.d] || []) : [], last = h[h.length - 1], mx = 1; + h.forEach(function(q) { mx = Math.max(mx, q.rx, q.tx); }); + var tl = last ? last.t : 0; + pRx.setAttribute('d', netArea(h, 'rx', mx * 1.1, tl)); + pTx.setAttribute('d', netArea(h, 'tx', mx * 1.1, tl)); + nChart.style.display = cur ? '' : 'none'; + nLeg.innerHTML = ''; + if (!cur) { nLeg.appendChild(E('span', { 'class': 'zm-mon-sub' }, 'Роутер не сообщил сетевые интерфейсы')); return; } + function side(cls, arrow, k) { + var v = last ? rate(last[k]) : [ '…', '' ], pk = 0; + h.forEach(function(q) { pk = Math.max(pk, q[k]); }); + return E('span', {}, [ E('i', { 'class': cls }), arrow + ' ', E('b', {}, v[0] + ' ' + v[1]), h.length > 2 ? E('small', {}, 'пик ' + rate(pk).join(' ')) : '' ]); + } + nLeg.appendChild(side('zm-mon-nrx', '↓', 'rx')); + nLeg.appendChild(side('zm-mon-ntx', '↑', 'tx')); + } + function fanIcon() { + return svg('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': 2, 'stroke-linecap': 'round', 'aria-hidden': 'true' }, [ + svg('circle', { cx: 12, cy: 12, r: 2 }), + svg('path', { d: 'M12 10c0-4 1-7 4-7s3 4-2 7M14 12c4 0 7 1 7 4s-4 3-7-2M12 14c0 4-1 7-4 7s-3-4 2-7M10 12c-4 0-7-1-7-4s4-3 7 2' }) ]); + } + function renderFan(f) { + if (!f) { fanEl.style.display = 'none'; return; } + var on, txt; + if (f.rpm >= 0) { on = f.rpm > 0; txt = on ? num(f.rpm, 0) + ' об/мин' + (f.pwm >= 0 ? ' · ' + f.pwm + '%' : '') : 'выключен'; } + else if (f.pwm >= 0) { on = f.pwm > 0; txt = on ? f.pwm + '%' : 'выключен'; } + else { on = f.st > 0; txt = on ? 'ступень ' + f.st + ' из ' + f.mx : 'выключен'; } + fanEl.className = 'zm-badge zm-mon-fan ' + (on ? 'zm-ok' : 'zm-off'); + fanEl.innerHTML = ''; + fanEl.appendChild(fanIcon()); + fanEl.appendChild(document.createTextNode(txt)); + fanEl.title = 'Вентилятор: ' + txt; + fanEl.style.display = ''; + } var sensCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Датчики'), sensBox ]); infoBox.className = 'zm-mon-info'; var infoCard = E('div', { 'class': 'zm-card' }, [ E('div', { 'class': 'zm-mon-cardhead' }, [ E('h3', {}, 'Роутер') ]), infoBox ]); @@ -35041,15 +37592,13 @@ return view.extend({ function onSnap(s) { if (!s || s.error || !s.mem) return; prev = snap; snap = s; + netTrack(s, prev); if (prev && prev.cpu && s.cpu) { var c = cpuCalc(prev.cpu, s.cpu); if (c) { cpuNow = c; cpuNow.cores = (s.cores || []).map(function(x, i) { var r = prev.cores && prev.cores[i] ? cpuCalc(prev.cores[i], x) : null; return r ? r.all : 0; }); } - var dt = s.t - prev.t; - if (dt > 0 && s.net && prev.net && s.net.wan.length && prev.net.wan.length) - netNow = { rx: Math.max(0, (s.net.wan[0] - prev.net.wan[0]) / dt), tx: Math.max(0, (s.net.wan[1] - prev.net.wan[1]) / dt) }; hist.cpu.push(cpuNow.all); hist.mem.push(100 * memUsed(s.mem) / s.mem.total); var tc = cpuTemp(s); hist.temp.push(tc == null ? null : tc); hist.t.push(s.t); while (hist.t.length && (hist.t[hist.t.length - 1] - hist.t[0] > WIN + 4 || hist.t.length > HIST_MAX)) [ 'cpu', 'mem', 'temp', 't' ].forEach(function(k) { hist[k].shift(); }); @@ -35083,11 +37632,8 @@ return view.extend({ [ [ 'программы', cpuNow.user ], [ 'система', cpuNow.sys ], [ 'прерывания', cpuNow.irq ], [ 'ожидание диска', cpuNow.io ] ].forEach(function(x) { splitBox.appendChild(tile(x[0], prev ? pct(x[1]) : '…')); }); freqEl.textContent = s.freq ? num(s.freq, 0) + ' МГц' : ''; netBox.innerHTML = ''; - var rx = netNow ? rate(netNow.rx) : [ '…', '' ], tx = netNow ? rate(netNow.tx) : [ '…', '' ]; - netBox.appendChild(E('div', { 'class': 'zm-mon-tiles2' }, [ - E('div', { 'class': 'zm-mon-tile' }, [ E('span', {}, '↓ загрузка' + (s.net && s.net.wan_dev ? ' · ' + s.net.wan_dev : '')), E('b', { 'class': 'zm-mon-rate' }, [ rx[0], E('small', {}, ' ' + rx[1]) ]) ]), - E('div', { 'class': 'zm-mon-tile' }, [ E('span', {}, '↑ отдача'), E('b', { 'class': 'zm-mon-rate' }, [ tx[0], E('small', {}, ' ' + tx[1]) ]) ]) - ])); + renderNet(s); + renderFan(s.fan); if (s.conn && s.conn[1]) { var cp = 100 * s.conn[0] / s.conn[1]; netBox.appendChild(E('div', { 'class': 'zm-mon-meter' }, [ @@ -35930,7 +38476,9 @@ var DNS_CAT = { { id: 'malw', name: 'MALW', value: 'https://dns.malw.link/dns-query', boot: '77.88.8.8', alias: [ 'dns.malw.link' ] }, { id: 'zapretmoe', name: 'zapret.moe', value: 'https://dns.zapret.moe/dns-query', boot: '77.88.8.8', alias: [ 'dns.zapret.moe' ] }, { id: 'webshift', name: 'WebShift', value: 'https://d.webshiftvpn.ru/dns-query', boot: '77.88.8.8', alias: [ 'd.webshiftvpn.ru' ] }, - { id: 'dagami', name: 'Dagami', value: 'https://dns.dagami.ru/dns-query', boot: '77.88.8.8', alias: [ 'dns.dagami.ru' ] } + { id: 'dagami', name: 'Dagami', value: 'https://dns.dagami.ru/dns-query', boot: '77.88.8.8', alias: [ 'dns.dagami.ru' ] }, + { id: 'controld', name: 'Control D', value: 'https://freedns.controld.com/p0', boot: '76.76.2.0', alias: [ 'freedns.controld.com' ] }, + { id: 'dns4eu', name: 'DNS4EU', value: 'https://unfiltered.joindns4.eu/dns-query', boot: '86.54.11.100', alias: [ 'unfiltered.joindns4.eu' ] } ], dot: [ { id: 'quad9', name: 'Quad9', value: 'dns.quad9.net', boot: '9.9.9.9', alias: [ '9.9.9.9' ] }, @@ -39571,12 +42119,15 @@ function renderInstalled(all) { ? bt.badge(false, '', 'нужен dnsmasq-full') : bt.badge(st.dns, 'домены загружены', 'нет'))); items.push(row('Firewall forward', bt.badge(st.fw, 'разрешён', 'нет'))); - items.push(row('IP в наборах', E('span', {}, [ 'IPv4: ' + (st.ips4 || 0) + (st.ipv6 ? ', IPv6: ' + (st.ips6 || 0) : '') ]))); + items.push(row('IP в наборах', E('span', {}, [ 'IPv4: ' + (st.ips4 || 0) + ((st.ipv6_on != null ? st.ipv6_on : st.ipv6) ? ', IPv6: ' + (st.ips6 || 0) : '') ]))); var half = Math.ceil(items.length / 2); var kids = [ E('div', { 'class': 'bt-cols' }, [ E('div', { 'class': 'bt-col' }, items.slice(0, half)), E('div', { 'class': 'bt-col' }, items.slice(half)) ]) ]; + if (st.enabled && st.hev === false && st.hev_log) + kids.push(E('p', { 'class': 'zm-hint zm-ab-warn' }, 'hev-socks5-tunnel не запускается: ' + st.hev_log + + (/ipv6/i.test(st.hev_log) ? ' — выключите IPv6 в настройках ниже и перезапустите ByeTube' : ' — подробности в журнале роутера'))); kids.push(E('div', { 'class': 'zm-actions' }, [ cfg.enabled ? btn('Выключить', 'cbi-button-remove', function() { @@ -39616,6 +42167,9 @@ function renderInstalled(all) { applyConfig({ ipv6: cfg.ipv6 ? 0 : 1 }, cfg.ipv6 ? 'IPv6 выключен' : 'IPv6 включён'); }) ])), + cfg.ipv6 && st.ipv6_ok === false + ? E('p', { 'class': 'zm-hint zm-ab-warn' }, 'На роутере IPv6 выключен — ByeTube работает только по IPv4, эта настройка пока не действует. Когда IPv6 на роутере включат, ByeTube начнёт использовать его сам после перезапуска.') + : '', row('Порт SOCKS5 ByeDPI', E('div', { 'class': 'zm-actions', 'style': 'margin:0' }, [ portInput, btn('Сохранить', 'cbi-button-positive', function() { @@ -40097,7 +42651,7 @@ cat > '/www/zm/app.js' << 'ZM_INSTALLER_EOF' var BUILD = '__ZMW_BUILD__'; var RES = '/luci-static/resources/'; var NULL_SID = '00000000000000000000000000000000'; -var K_SID = 'zmw.sid', K_THEME = 'zmw.theme', K_USER = 'zmw.user', K_NOTE = 'zmw.note', K_ANIM = 'zmw.anim'; +var K_SID = 'zmw.sid', K_THEME = 'zmw.theme', K_USER = 'zmw.user', K_NOTE = 'zmw.note', K_ANIM = 'zmw.anim', K_NAV = 'zmw.nav'; function store(kind) { try { return kind === 'local' ? window.localStorage : window.sessionStorage; } catch (e) { return null; } @@ -40593,6 +43147,7 @@ var ICONS = { close: '', external: '', eye: '', + gear: '', eyeOff: '', user: '', lock: '', @@ -40602,6 +43157,7 @@ var ICONS = { route: '', tunnel: '', terminal: '', + phone: '', activity: '', anarchy: '', rocket: '', @@ -40929,6 +43485,7 @@ var ROUTES = [ { id: 'adblock', title: 'Блокировка рекламы', sub: 'Реклама, трекеры и фишинг — для всех устройств в сети', icon: 'adblock', group: 'Сеть', dot: 'adblock' }, { id: 'doh', title: 'DNS over HTTPS', sub: 'Шифрованный DNS для всей сети', icon: 'globe', group: 'Сеть', dot: 'doh' }, { id: 'awg', title: 'AmneziaWG', sub: 'Туннели AmneziaWG и WARP: установка, ключи, интерфейсы', icon: 'anarchy', group: 'Сеть', dot: 'awg' }, + { id: 'tailscale', title: 'Tailscale', sub: 'Телефон в любой сети — через домашний роутер', icon: 'phone', group: 'Сеть', dot: 'ts' }, { id: 'terminal', title: 'Терминал', sub: 'Командная строка роутера в браузере (ttyd)', icon: 'terminal', group: 'Сеть', dot: 'term' }, { id: 'system', title: 'Система', sub: 'Параметры роутера, зеркала и обслуживание', icon: 'cpu', group: 'Сервис' }, { id: 'monitor', title: 'Монитор ресурсов', sub: 'Процессор, память, температура и процессы', icon: 'activity', group: 'Сервис' } @@ -40942,6 +43499,84 @@ function currentRoute() { } var root, shell = null, viewEl, titleEl, subEl, navLinks = {}, navDots = {}, deviceEl, verEl, updateEl, memEl; + +/* Скрытые пункты меню: список на роутере (health.nav_hidden), копия в браузере — чтобы меню + не мигало при загрузке. Дашборд скрыть нельзя. Hosts и блокировка рекламы — встроенные + функции, «неустановленными» не считаются. */ +var NAV_LOCK = { dashboard: 1 }, NAV_BUILTIN = { dashboard: 1, hosts: 1, adblock: 1, system: 1, monitor: 1 }; +var navHidden = navParse(sget(K_NAV)), navEdit = null, navSaving = false, navGroups = [], navGear = null, navNav = null, navHealth = null; +function navParse(v) { var o = {}; String(v || '').split(/[\s,]+/).forEach(function (k) { if (/^[a-z0-9_]{1,32}$/.test(k) && !NAV_LOCK[k]) o[k] = 1; }); return o; } +function navList(o) { return ROUTES.map(function (r) { return r.id; }).filter(function (k) { return o[k]; }); } +function navCount() { return navList(navHidden).length; } +function navApply() { + var edit = !!navEdit, cur = edit ? navEdit : navHidden; + if (navNav) navNav.classList.toggle('zmw-nav-editing', edit); + ROUTES.forEach(function (r) { + var a = navLinks[r.id]; if (!a) return; + var off = !!cur[r.id]; + a.hidden = !edit && off; + a.classList.toggle('zmw-nav-off', edit && off); + if (a._eye) { a._eye.innerHTML = ''; a._eye.appendChild(icon(off ? 'eyeOff' : 'eye')); a._eye.setAttribute('aria-pressed', off ? 'false' : 'true'); a._eye.title = off ? 'Показать в меню' : 'Скрыть из меню'; } + }); + navGroups.forEach(function (g) { g.el.hidden = !edit && g.ids.every(function (k) { return navHidden[k]; }); }); + if (navGear) { + var n = navCount(); + navGear.classList.toggle('zmw-on', edit); + navGear.classList.toggle('zmw-has', n > 0); + navGear.title = edit ? 'Сохранить меню' : n ? 'Настроить меню · скрыто: ' + n : 'Настроить меню'; + navGear.setAttribute('aria-label', edit ? 'Готово' : 'Настроить меню'); + navGear.setAttribute('aria-expanded', edit ? 'true' : 'false'); + } +} +function navOpen() { + if (navEdit) { navDone(); return; } + navEdit = {}; Object.keys(navHidden).forEach(function (k) { navEdit[k] = 1; }); + navApply(); + if (navNav) navNav.scrollTop = 0; +} +function navToggle(id) { + if (!navEdit || NAV_LOCK[id]) return; + if (navEdit[id]) delete navEdit[id]; else navEdit[id] = 1; + navApply(); +} +function navUninstalled() { + if (!navEdit) return; + var h = navHealth || {}, d = h.dots || {}, n = 0; + ROUTES.forEach(function (r) { + if (!r.dot || NAV_BUILTIN[r.id]) return; + var st = typeof d[r.dot] === 'number' ? d[r.dot] : h[r.dot]; + if (st === 0 && !navEdit[r.id]) { navEdit[r.id] = 1; n++; } + }); + navApply(); + toast(n ? 'Отмечено неустановленных: ' + n + ' — нажмите «Готово»' : (navHealth ? 'Неустановленных разделов нет' : 'Состояние компонентов ещё загружается — попробуйте через пару секунд'), 'info'); +} +function navAll() { if (navEdit) { navEdit = {}; navApply(); } } +function navDone() { + if (!navEdit) return; + var next = navEdit, was = navList(navHidden).join(' '), now = navList(next).join(' '); + navEdit = null; navHidden = next; + sset(K_NAV, now || null, true); + navApply(); + if (was === now) return; + navSaving = true; + ubus('zapret-manager', 'ui_nav_set', { ids: now }).then(function (r) { + var d = r && r[1]; + if (r && r[0] === 6) throw new Error('нет прав — выйдите из панели и войдите снова'); + if (!r || r[0] !== 0) throw new Error('роутер не ответил (код ' + (r ? r[0] : '?') + ')'); + if (!d || d.error || !d.ok) throw new Error((d && d.error) || 'неизвестный ответ'); + toast(now ? 'Меню сохранено · скрыто: ' + navList(next).length : 'Меню сохранено · показаны все разделы', 'info'); + }).catch(function (e) { + toast('Меню не сохранилось на роутере: ' + ((e && e.message) || e) + '. В этом браузере оно уже изменено', 'error'); + }).then(function () { navSaving = false; }); +} +function navFromHealth(h) { + navHealth = h; + if (!h || typeof h.nav_hidden !== 'string' || navEdit || navSaving) return; + var srv = navList(navParse(h.nav_hidden)).join(' '); + if (srv === navList(navHidden).join(' ')) return; + navHidden = navParse(srv); sset(K_NAV, srv || null, true); navApply(); +} +document.addEventListener('keydown', function (ev) { if (navEdit && ev.key === 'Escape') { ev.preventDefault(); navDone(); } }); var alertsEl = E('div', { 'class': 'zmw-alerts' }), alertsKey = ''; var termKeep = { host: null, frame: null, src: '' }; @@ -41010,19 +43645,40 @@ function buildShell() { nav.addEventListener('scroll', navEdges, { passive: true }); window.addEventListener('resize', navEdges); setTimeout(navEdges, 0); setTimeout(navEdges, 600); + navNav = nav; navGroups = []; ROUTES.forEach(function (r) { - if (r.group !== lastGroup) { nav.appendChild(E('div', { 'class': 'zmw-nav-group' }, [ r.group ])); lastGroup = r.group; } + if (r.group !== lastGroup) { + var gEl = E('div', { 'class': 'zmw-nav-group' }, [ E('span', {}, [ r.group ]) ]); + if (!navGroups.length) { + navGear = E('button', { 'class': 'zmw-nav-gear', 'type': 'button', 'aria-label': 'Настроить меню', 'aria-expanded': 'false', 'click': function (ev) { ev.preventDefault(); navOpen(); } }, [ icon('gear'), E('span', { 'class': 'zmw-nav-gear-done' }, [ 'Готово' ]), E('i', {}) ]); + gEl.appendChild(navGear); + } + navGroups.push({ el: gEl, ids: [] }); + nav.appendChild(gEl); lastGroup = r.group; + } + navGroups[navGroups.length - 1].ids.push(r.id); var dot = r.dot ? E('span', { 'class': 'zmw-nav-dot' }) : null; + var tail = NAV_LOCK[r.id] ? E('span', { 'class': 'zmw-nav-lock' }, [ 'всегда' ]) : E('span', { 'class': 'zmw-nav-eye', 'role': 'switch', 'aria-label': 'Показывать «' + r.title + '» в меню' }); var a = E('a', { 'class': 'zmw-nav-item', 'href': '#/' + r.id, 'data-route': r.id }, [ E('span', { 'class': 'zmw-nav-ico' }, [ icon(r.icon) ]), E('span', { 'class': 'zmw-nav-label' }, [ r.title ]), - dot + dot, + tail ]); - a.addEventListener('click', function () { closeDrawer(); }); + if (!NAV_LOCK[r.id]) a._eye = tail; + a.addEventListener('click', function (ev) { + if (navEdit) { ev.preventDefault(); navToggle(r.id); return; } + closeDrawer(); + }); navLinks[r.id] = a; if (dot) navDots[r.dot] = dot; nav.appendChild(a); }); + nav.appendChild(E('div', { 'class': 'zmw-nav-editfoot' }, [ + E('button', { 'class': 'zmw-nav-btn', 'type': 'button', 'click': function () { navUninstalled(); } }, [ 'Скрыть неустановленные' ]), + E('button', { 'class': 'zmw-nav-btn', 'type': 'button', 'click': function () { navAll(); } }, [ 'Показать все' ]) + ])); + navApply(); deviceEl = E('div', { 'class': 'zmw-device' }, [ E('span', { 'class': 'zmw-device-model' }, [ 'Роутер' ]), @@ -41085,7 +43741,7 @@ function luciUrl() { } function openDrawer() { document.body.classList.add('zmw-drawer-open'); } -function closeDrawer() { document.body.classList.remove('zmw-drawer-open'); } +function closeDrawer() { if (navEdit) navDone(); document.body.classList.remove('zmw-drawer-open'); } var callStatus = declare({ object: 'zapret-manager', method: 'status', expect: {} }); var callSysInfo = declare({ object: 'zapret-manager', method: 'system_info', expect: {} }); @@ -41225,6 +43881,7 @@ function refreshShellStatus() { } function applyHealth(h) { if (!h) return; + navFromHealth(h); renderAlerts(h); Object.keys(navDots).forEach(function(k) { var title = null; @@ -41815,6 +44472,44 @@ html[data-theme="dark"] .zmw-brand-name b, html[data-theme="depth"] .zmw-brand-n color: var(--muted); opacity: .8; padding: 12px 12px 4px; } .zmw-nav-group:first-child { padding-top: 4px; } +.zmw-nav-group { display: flex; align-items: center; justify-content: space-between; gap: 8px; opacity: 1; } +.zmw-nav-group > span { opacity: .8; } +.zmw-nav-group[hidden], .zmw-nav-item[hidden] { display: none; } +.zmw-nav-gear { + position: relative; height: 24px; min-width: 24px; margin: -4px -6px -4px 0; padding: 0; flex: none; + display: inline-flex; align-items: center; justify-content: center; gap: 0; + border-radius: 8px; cursor: pointer; outline: none; box-sizing: border-box; + border: 1px solid var(--border); background: var(--surface-2); color: var(--muted); + font: inherit; font-size: 12px; font-weight: 650; letter-spacing: 0; text-transform: none; line-height: 1; + transition: color .15s, border-color .15s, background .15s, box-shadow .15s; +} +.zmw-nav-gear .zmw-i { width: 14px; height: 14px; display: block; transition: transform .35s; } +.zmw-nav-gear:hover { color: var(--text); border-color: var(--a1); } +.zmw-nav-gear:hover .zmw-i { transform: rotate(60deg); } +.zmw-nav-gear:focus-visible { box-shadow: 0 0 0 2px var(--a1); } +.zmw-nav-gear-done { display: none; } +.zmw-nav-gear.zmw-on { padding: 0 11px; color: #fff; background: var(--grad); border-color: transparent; } +.zmw-nav-gear.zmw-on .zmw-i { display: none; } +.zmw-nav-gear.zmw-on .zmw-nav-gear-done { display: inline; } +.zmw-nav-gear > i { display: none; position: absolute; top: 2px; right: 2px; width: 6px; height: 6px; border-radius: 50%; background: var(--a1); box-shadow: 0 0 0 1.5px var(--surface-2); pointer-events: none; } +.zmw-nav-gear.zmw-has > i { display: block; } +.zmw-nav-gear.zmw-on > i { display: none; } +.zmw-nav-editfoot, .zmw-nav-eye, .zmw-nav-lock { display: none; } +.zmw-nav-editing .zmw-nav-editfoot { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 4px 4px; } +.zmw-nav-btn { border: 1px solid var(--border); background: var(--surface-2); color: var(--text-2); border-radius: 9px; padding: 6px 10px; font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; } +.zmw-nav-btn:hover { color: var(--text); border-color: var(--a1); } +.zmw-nav-editing .zmw-nav-eye { + display: grid; place-items: center; width: 26px; height: 26px; border-radius: 8px; flex: none; margin-right: -6px; + color: var(--a1); background: var(--grad-soft); +} +.zmw-nav-editing .zmw-nav-eye .zmw-i { width: 15px; height: 15px; } +.zmw-nav-editing .zmw-nav-lock { display: block; font-size: 11.5px; font-weight: 600; color: var(--muted); } +.zmw-nav-editing .zmw-nav-item.zmw-active .zmw-nav-lock { color: inherit; opacity: .75; } +.zmw-nav-editing .zmw-nav-item { cursor: pointer; user-select: none; } +.zmw-nav-editing .zmw-nav-item.zmw-active::before { display: none; } +.zmw-nav-editing .zmw-nav-item.zmw-nav-off { opacity: .45; } +.zmw-nav-editing .zmw-nav-item.zmw-nav-off .zmw-nav-eye { color: var(--muted); background: var(--surface-2); } +.zmw-nav-editing .zmw-nav-item.zmw-nav-off .zmw-nav-label { text-decoration: line-through; text-decoration-color: rgba(127,127,127,.5); } .zmw-nav-item { position: relative; display: flex; align-items: center; gap: 12px; @@ -42510,6 +45205,7 @@ html.zm-theme-dark .zmw-body .zm-toast-spin, html.zm-theme-dark.zmw-body .zm-toa @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } html .zm-toast-spin, html .zm-reboot-spin, html .zmw-btn-primary.zmw-loading::after { animation-duration: 1.4s !important; animation-iteration-count: infinite !important; } + html .zm-tsc-spin { animation-duration: 1.2s !important; animation-iteration-count: infinite !important; } } #zmw-view .zm-node { border-color: var(--border); background: var(--surface-2); border-radius: 14px; } html.zm-theme-dark #zmw-view .zm-node:not(.zm-active) { background: var(--surface-2); border-color: var(--border); } @@ -42841,6 +45537,12 @@ html[data-theme="retro"] #zmw-view .zm-card { html[data-theme="retro"] #zmw-view .zm-card { padding: 0 14px 14px; } html[data-theme="retro"] #zmw-view .zm-card h3 { margin: 3px -11px 14px; padding: 5px 8px; background: linear-gradient(90deg, #000080, #1084d0); color: #ffffff; font-size: 13px; font-weight: 700; } html[data-theme="retro"] #zmw-view .zm-card h3::before { display: none; } +/* в синей полосе заголовка плашки — светлым текстом, иначе тёмно-зелёное на синем не читается */ +html[data-theme="retro"] #zmw-view .zm-card h3 .zm-badge { color: #ffffff; background: rgba(255,255,255,.14); padding: 0 6px; } +/* температура на сером окне: стандартные цвета слишком светлые */ +html[data-theme="retro"] #zmw-view .zm-mon-t-ok { color: #006400; } +html[data-theme="retro"] #zmw-view .zm-mon-t-warn { color: #7a4100; } +html[data-theme="retro"] #zmw-view .zm-mon-t-bad { color: #a00000; } html[data-theme="retro"] #zmw-view .cbi-button, html[data-theme="retro"] .zmw-icon-btn, html[data-theme="retro"] .zmw-btn-primary, html[data-theme="retro"] .zm-dock .cbi-button, html[data-theme="retro"] .zmw-modal .cbi-button { border-radius: 0; background: #c0c0c0; color: #000000; box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf; border-top: 2px solid #ffffff; border-left: 2px solid #ffffff; border-right: 2px solid #000000; border-bottom: 2px solid #000000; font-weight: 400; @@ -43261,6 +45963,9 @@ html[data-theme="oled"] .zmw-nav-item { border-radius: 999px; } html[data-theme="oled"] .zmw-nav-item.zmw-active { background: #ffffff; color: #000000; } html[data-theme="oled"] .zmw-nav-item.zmw-active::before { display: none; } html[data-theme="oled"] .zmw-nav-item.zmw-active .zmw-nav-ico { background: #000000; color: #ffffff; border-color: #000000; } +html[data-theme="oled"] .zmw-nav-gear.zmw-on { background: #ffffff; color: #000000; border-color: #ffffff; } +html[data-theme="oled"] .zmw-nav-editing .zmw-nav-eye { color: #ffffff; background: rgba(255,255,255,.12); } +html[data-theme="oled"] .zmw-nav-gear > i { background: #ffffff; } html[data-theme="oled"] #zmw-view .zm-card { border-radius: 20px; } html[data-theme="oled"] #zmw-view .zm-card h3::before { background: #ffffff; } html[data-theme="oled"] #zmw-view .cbi-button, html[data-theme="oled"] .zmw-icon-btn, html[data-theme="oled"] .zm-dock .cbi-button, html[data-theme="oled"] .zmw-modal .cbi-button, html[data-theme="oled"] .zmw-btn-primary { border-radius: 999px; } @@ -43511,6 +46216,13 @@ if [ -x /usr/bin/netshift ] && [ -f /usr/lib/netshift/constants.sh ] && ! _zmi_h else _zmi_warn "Сверить не удалось — откройте вкладку Forkozz и нажмите «Перезапустить»"; fi fi +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 перезапущен" ;; + *'"changed":true'*) _zmi_ok "Скрипты ByeTube обновлены" ;; + esac +fi + if [ -n "$_zm_old_ver" ] && [ "$_zm_old_ver" != "$ZM_NEW_VER" ]; then mkdir -p /opt/zapret-manager-luci/state echo "$_zm_old_ver" > /opt/zapret-manager-luci/state/cl.pending 2>/dev/null || true