diff --git a/ZapretManager_LuCI.sh b/ZapretManager_LuCI.sh
index 09e7780..f93454c 100644
--- a/ZapretManager_LuCI.sh
+++ b/ZapretManager_LuCI.sh
@@ -1,5 +1,5 @@
#!/bin/sh
-# Version: 2.21
+# Version: 2.26
set -e
GREEN="\033[1;32m"; CYAN="\033[1;36m"; YELLOW="\033[1;33m"; MAGENTA="\033[1;35m"; BLUE="\033[0;34m"; NC="\033[0m"; DGRAY="\033[38;5;244m"
@@ -50,7 +50,7 @@ cat > '/opt/zapret-manager-luci/backend.sh.zm-new' << 'ZM_INSTALLER_EOF'
umask 022
CONF="/etc/config/zapret"
-ZM_VERSION="2.21"
+ZM_VERSION="2.26"
ZM_SCRIPT_URL="https://raw.githubusercontent.com/StressOzz/Zapret-Manager/refs/heads/main/ZapretManager_LuCI.sh"
GH_RAW="https://raw.githubusercontent.com"
GH_MAIN="https://github.com"
@@ -10495,56 +10495,85 @@ steer_sub_status() {
}
# TCP-пинг узла (как «TCP» в Happ): время установки соединения с сервером, без туннеля. Хост и порт
-# берём из вывода модуля ядра steer (кэш рядом со списком узлов). UDP-протокол (Hysteria2) так не
-# проверить — для него остаётся проверка ядром.
-_st_hostport() {
+# берём из вывода модулей ядра steer (кэш рядом со списком узлов). Hysteria2 работает по UDP — по TCP
+# его не проверить, для него остаётся проверка ядром.
+_st_hp_build() {
local m hp="$ST_SUB_NODES.hp"
- if [ ! -s "$hp" ] || [ "$ST_SUB" -nt "$hp" ]; then
- mkdir -p "$ST_DIR"
- { for m in vless proxy; do
- [ -x "$STL_SBIN/steer-$m" ] || continue
- _stl_t 30 steer "$m-nodes" "$ST_SUB" 2>/dev/null | tr '\n\t' ' ' | awk -v M="$m" '{
- j = $0; p = index(j, "\"nodes\":["); if (!p) next
- j = substr(j, p + 9); n = split(j, C, /\{"index":/)
- for (i = 2; i <= n; i++) {
- ch = C[i]; idx = ch + 0; h = ""; pt = 0
- if (match(ch, /"host":"[^"]*"/)) h = substr(ch, RSTART + 8, RLENGTH - 9)
- if (match(ch, /"port":[0-9]+/)) pt = substr(ch, RSTART + 7, RLENGTH - 7) + 0
- if (h != "" && pt > 0) printf "%s\t%d\t%s\t%d\n", M, idx, h, pt
- }
- }'
- done; } > "$hp.tmp" 2>/dev/null
- mv -f "$hp.tmp" "$hp" 2>/dev/null
- fi
- awk -F'\t' -v m="$1" -v i="$2" '$1 == m && $2 == i { print $3, $4; exit }' "$hp" 2>/dev/null
+ [ -s "$hp" ] && [ ! "$ST_SUB" -nt "$hp" ] && return 0
+ mkdir -p "$ST_DIR"
+ { for m in vless proxy; do
+ [ -x "$STL_SBIN/steer-$m" ] || continue
+ _stl_t 30 steer "$m-nodes" "$ST_SUB" 2>/dev/null | tr '\n\t' ' ' | awk -v M="$m" '{
+ j = $0; p = index(j, "\"nodes\":["); if (!p) next
+ j = substr(j, p + 9); n = split(j, C, /\{"index":/)
+ for (i = 2; i <= n; i++) {
+ ch = C[i]; idx = ch + 0; h = ""; pt = 0
+ if (match(ch, /"host":"[^"]*"/)) h = substr(ch, RSTART + 8, RLENGTH - 9)
+ if (match(ch, /"port":[0-9]+/)) pt = substr(ch, RSTART + 7, RLENGTH - 7) + 0
+ if (h != "" && pt > 0) printf "%s\t%d\t%s\t%d\n", M, idx, h, pt
+ }
+ }'
+ done; } > "$hp.$$" 2>/dev/null
+ mv -f "$hp.$$" "$hp" 2>/dev/null
}
-_st_tcp_ms() {
- local h="$1" i out ms best=""
+
+# Две попытки сразу, параллельно; в файл — лучшее время в мс или пусто. Прокси из окружения не берём.
+_st_tcp_one() {
+ local h="$1" p="$2" to="$3" out="$4"
case "$h" in *:*) h="[$h]" ;; esac
- for i in 1 2; do
- out="$(curl -s -o /dev/null --connect-timeout 3 --max-time 3 -w '%{time_connect} %{time_namelookup}' "http://$h:$2/" 2>/dev/null)"
- ms="$(printf '%s' "$out" | awk '{ c = $1 + 0; n = $2 + 0; if (c > 0) { if (n > 0 && n < c) c -= n; m = int(c * 1000 + 0.5); print (m < 1 ? 1 : m) } }')"
- if [ -n "$ms" ]; then best="$ms"; break; fi
+ ( curl -s -o /dev/null --noproxy '*' --connect-timeout "$to" --max-time "$to" -w '%{time_connect} %{time_namelookup}\n' "http://$h:$p/" 2>/dev/null > "$out.a" ) &
+ ( curl -s -o /dev/null --noproxy '*' --connect-timeout "$to" --max-time "$to" -w '%{time_connect} %{time_namelookup}\n' "http://$h:$p/" 2>/dev/null > "$out.b" ) &
+ wait
+ cat "$out.a" "$out.b" 2>/dev/null | awk '{ c = $1 + 0; n = $2 + 0; if (c > 0) { if (n > 0 && n < c) c -= n; m = int(c * 1000 + 0.5); if (m < 1) m = 1; if (!b || m < b) b = m } } END { if (b) print b }' > "$out"
+ rm -f "$out.a" "$out.b"
+}
+
+# Пачка узлов: «1,5,7» → {"results":{"1":{"ok":true,"ms":42},"5":{"ok":false},"7":{"core":true}}}.
+# Все узлы пачки проверяются одновременно; кто не ответил за 3 с, проверяется ещё раз с запасом в 6 с —
+# «нет ответа» только после обоих проходов. core — узел по TCP не проверить (Hysteria2): его проверит ядро.
+steer_sub_tcp() {
+ local t dir list="" i p f hp h port retry="" out="" sep="" ms
+ command -v curl >/dev/null 2>&1 || { echo '{"error":"на роутере нет curl"}'; return 1; }
+ [ -s "$ST_SUB" ] && _st_vpn_ok && t="$(_st_nodes)" || { echo '{"error":"подписки нет"}'; return 1; }
+ _st_hp_build
+ dir="$ST_RUN/tcp.$$"; mkdir -p "$dir"
+ for i in $(printf '%s' "$1" | tr ',' ' '); do
+ case "$i" in ''|*[!0-9]*) continue ;; esac
+ set -- $(awk -F'\t' -v g="$i" '$1 == g { print $2, $4; exit }' "$t")
+ p="$(_stl_mod_of "$1")"; f="$2"
+ hp=""
+ [ -n "$1" ] && [ "$p" != hysteria2 ] && hp="$(awk -F'\t' -v m="$p" -v n="$f" '$1 == m && $2 == n { print $3, $4; exit }' "$ST_SUB_NODES.hp" 2>/dev/null)"
+ if [ -z "$hp" ]; then echo udp > "$dir/$i.k"; list="$list $i"; continue; fi
+ echo "$hp" > "$dir/$i.k"
+ _st_tcp_one "${hp% *}" "${hp#* }" 3 "$dir/$i" &
+ list="$list $i"
done
- [ -n "$best" ] && echo "$best"
+ wait
+ for i in $list; do
+ [ "$(cat "$dir/$i.k")" != udp ] && [ ! -s "$dir/$i" ] || continue
+ hp="$(cat "$dir/$i.k")"
+ _st_tcp_one "${hp% *}" "${hp#* }" 6 "$dir/$i" &
+ retry=1
+ done
+ [ -n "$retry" ] && wait
+ for i in $list; do
+ if [ "$(cat "$dir/$i.k")" = udp ]; then out="$out$sep\"$i\":{\"core\":true}"
+ elif ms="$(cat "$dir/$i" 2>/dev/null)" && [ -n "$ms" ]; then out="$out$sep\"$i\":{\"ok\":true,\"ms\":$ms}"
+ else out="$out$sep\"$i\":{\"ok\":false}"; fi
+ sep=","
+ done
+ rm -rf "$dir"
+ printf '{"results":{%s}}\n' "$out"
}
steer_sub_probe() {
- local out t p f hp tcp=""
+ local out t p f
case "$1" in ''|*[!0-9]*) echo '{"ok":false,"error":"неверный номер узла"}'; return 1 ;; esac
[ -s "$ST_SUB" ] && _st_vpn_ok && t="$(_st_nodes)" || { echo '{"ok":false,"error":"подписки нет"}'; return 1; }
set -- $(awk -F'\t' -v g="$1" '$1 == g { print $2, $4; exit }' "$t")
[ -n "$1" ] || { echo '{"ok":false,"error":"узла нет в подписке"}'; return 1; }
p="$(_stl_mod_of "$1")"; f="$2"
stl_has "$p" || { echo '{"ok":false,"error":"модуль ядра steer для этого протокола не установлен"}'; return 1; }
- if [ "$p" != hysteria2 ] && command -v curl >/dev/null 2>&1; then
- hp="$(_st_hostport "$p" "$f")"
- [ -n "$hp" ] && tcp="$(_st_tcp_ms "${hp% *}" "${hp#* }")"
- if [ -n "$tcp" ]; then
- printf '{"ok":true,"results":[{"ok":true,"tcp_ms":%s,"ttfb_ms":0,"handshake_ms":%s}]}\n' "$tcp" "$tcp"
- return 0
- fi
- fi
out="$(stl_probe "$ST_SUB" "$p" "$f")"
case "$out" in '{'*) printf '%s\n' "$out" ;; *) echo '{"ok":false,"error":"проверка не удалась"}' ;; esac
}
@@ -10604,6 +10633,7 @@ steer_action() {
job_start steer do_steer_sub_update auto >/dev/null
;;
sub_probe) steer_sub_probe "$mode" ;;
+ sub_tcp) steer_sub_tcp "$mode" ;;
sub_links_get)
[ -s "$ST_SUB" ] || { echo '{"error":"подписки нет"}'; return 1; }
[ -s "$ST_SUB_URL" ] && { echo '{"error":"это подписка по ссылке — её серверы задаёт сервис, а не вы"}'; return 1; }
@@ -12309,6 +12339,7 @@ do_fk_install() {
[ "$legacy" = 1 ] || _fk_say "DNS по умолчанию: Quad9 9.9.9.9 (обычный UDP). Сменить можно в «Настройках»"
fi
chmod 0644 /etc/config/netshift
+ _fk_log_default
if [ "$legacy" = 1 ]; then
if [ -s "$lconf/forkop" ]; then
@@ -12473,6 +12504,7 @@ do_fk_service() {
[ -n "$b" ] && { echo "ОШИБКА: $(_fk_blocker_text "$b")"; return 1; }
[ -n "$(_fk_sb_ver)" ] || _fk_sb_install || return 1
_fk_ext_auto config "" norestart
+ _fk_log_default
local lx; lx="$(_fk_log_ext)"
/etc/init.d/netshift enable >/dev/null 2>&1
: > "$JOBS_DIR/forkop-svc.out"
@@ -12557,6 +12589,15 @@ do_fk_subs() {
FK_EXT_FAIL="$ZM_STATE_DIR/fk.ext.fail"
_fk_sb_is_ext() { sing-box version 2>/dev/null | head -n1 | grep -qi extended; }
_fk_log_ext() { logread 2>/dev/null | grep -ci 'requires sing-box-extended'; }
+# Журнал sing-box по умолчанию выключен (уровень panic — только аварии). Один раз: дальше выбор в панели.
+_fk_log_default() {
+ [ -s /etc/config/netshift ] || return 0
+ [ "$(uci -q get netshift.settings.zm_log)" = 1 ] && return 0
+ uci -q get netshift.settings >/dev/null || return 0
+ uci -q set netshift.settings.log_level='panic'
+ uci -q set netshift.settings.zm_log='1'
+ uci -q commit netshift
+}
_fk_ext_need() {
local f
[ -n "$(_fk_sb_ver)" ] && _fk_sb_is_ext && return 1
@@ -12821,6 +12862,7 @@ forkop_action() {
secstate) _fk_ucj secstate "$([ "$arg" = probe ] && echo probe)" ;;
servers) _fk_ucj servers "$arg" ;;
latency) _fk_ucj latency "$arg" ;;
+ stats) _fk_ucj stats ;;
cfg) forkop_config_get "$arg" ;;
secdel)
_fk_installed || { echo '{"error":"Forkozz не установлен"}'; return 1; }
@@ -13626,50 +13668,6 @@ function dns_info(st) {
detour: s(st.dns_via_outbound) == "1", failover: length(sv) > 1 };
}
-function cmd_get(want) {
- let c = cursor(), p = pick(c), sec = p.sec, main = p.sec;
- want = s(want);
- if (want != "" && want != main && index(p.conns, want) >= 0) sec = want;
- let m = c.get_all(CFG, sec) || {}, st = c.get_all(CFG, "settings") || {}, mm = c.get_all(CFG, main) || {};
- let mode = sec_mode(m), rf = sec_refs(m), subs = sub_urls(m);
- out({
- sec,
- main,
- is_main: sec == main,
- main_exists: mm[".type"] != null && sec_configured(mm),
- label: sec_label(m, sec == main),
- enabled: sec == main || s(m.disabled) != "1",
- sections: sec_list(c, p),
- bypass: bypass_info(c),
- exists: m[".type"] != null && sec_configured(m),
- mode: mode == "json" ? "links" : mode,
- links: sec_links(m),
- sub: length(subs) ? subs[0] : "",
- subs,
- sub_interval: s(m.subscription_update_interval || "1h"),
- iface: s(m.interface),
- fastest: true,
- exclude: s(m.zm_exclude),
- hide_names: rawlist(m.zm_hide),
- jsons: mode == "json" ? 1 : 0,
- refs: { c: rf.c, s: rf.s, r: rf.r },
- catalog: { items: catalog_items(services_list()), meta: cat_meta() },
- domains: domain_lines(m),
- subnets: subnet_lines(m),
- lists: rf.l,
- full: uniq(words(mm.fully_routed_ips)),
- excl: uniq(words(st.routing_excluded_ips)),
- extra: p.extra,
- dns: dns_info(st),
- quic_off: s(st.disable_quic) == "1",
- list_interval: s(st.update_interval || "1d"),
- list_update: true,
- lists_via: s(st.download_lists_via_proxy) == "1",
- devices: devices(),
- tunnels: tunnels()
- });
-}
-
/* ---------- validation ---------- */
function valid_ip(v) {
@@ -13788,6 +13786,319 @@ function parse_domains(v) {
return uniq(r);
}
+/* ---------- extra options: section (подписка, автовыбор, локальный прокси) and settings (сеть, панель) ---------- */
+
+const SUB_FMTS = [ "auto", "xray", "singbox" ];
+const SUB_GROUPS = [ "off", "country", "prefix" ];
+const UT_IVS = [ "30s", "1m", "3m", "5m" ];
+const UT_URL_DEF = "https://www.gstatic.com/generate_204";
+const LOG_LEVELS = [ "trace", "debug", "info", "warn", "error", "fatal", "panic" ];
+const SUB_MAX = 10;
+
+function flag(v) { return s(v) == "1"; }
+
+function num_in(v, lo, hi) {
+ v = trim(s(v));
+ if (!match(v, /^[0-9]{1,6}$/)) return null;
+ v = int(v);
+ return v >= lo && v <= hi ? v : null;
+}
+
+function kw_list(v) {
+ let r = [];
+ for (let x in arr(v))
+ for (let w in split(s(x), /[|\r\n]+/)) {
+ w = trim(w);
+ if (w != "" && index(r, w) < 0) push(r, w);
+ }
+ return r;
+}
+
+function sec_ext(m) {
+ return {
+ sub_fmt: index(SUB_FMTS, s(m.subscription_format_preference)) >= 0 ? s(m.subscription_format_preference) : "auto",
+ sub_group: index(SUB_GROUPS, s(m.subscription_group_mode)) >= 0 ? s(m.subscription_group_mode) : (flag(m.group_by_countries) ? "country" : "off"),
+ sub_plen: s(num_in(m.subscription_group_prefix_len, 1, 16) || 2),
+ sub_incl: join(" | ", kw_list(m.subscription_filter_include_keywords)),
+ sub_insecure: flag(m.subscription_insecure),
+ sub_ua: s(m.subscription_user_agent),
+ ut_iv: index(UT_IVS, s(m.urltest_check_interval)) >= 0 ? s(m.urltest_check_interval) : "3m",
+ ut_tol: s(num_in(m.urltest_tolerance, 0, 10000) ?? 50),
+ ut_url: s(m.urltest_testing_url) || UT_URL_DEF,
+ uot: flag(m.enable_udp_over_tcp),
+ mixed: flag(m.mixed_proxy_enabled),
+ mixed_port: s(num_in(m.mixed_proxy_port, 1, 65535) || 2080),
+ real_ip: flag(m.resolve_real_ip_for_routing),
+ dres_on: flag(m.domain_resolver_enabled),
+ dres_type: index([ "udp", "dot", "doh" ], s(m.domain_resolver_dns_type)) >= 0 ? s(m.domain_resolver_dns_type) : "udp",
+ dres_server: s(m.domain_resolver_dns_server)
+ };
+}
+
+function set_ext(st) {
+ let src = uniq(words(st.source_network_interfaces));
+ return {
+ dns_ttl: s(num_in(st.dns_rewrite_ttl, 0, 86400) ?? 60),
+ dns_ecs: s(st.dns_client_subnet),
+ src_ifs: length(src) ? src : [ "br-lan" ],
+ out_if: flag(st.enable_output_network_interface) ? s(st.output_network_interface) : "",
+ badwan: flag(st.enable_badwan_interface_monitoring),
+ badwan_ifs: uniq(words(st.badwan_monitored_interfaces)),
+ badwan_delay: s(num_in(st.badwan_reload_delay, 0, 600000) ?? 2000),
+ ipv6: flag(st.enable_ipv6),
+ no_dhcp: flag(st.dont_touch_dhcp),
+ excl_ntp: flag(st.exclude_ntp),
+ excl_bt: flag(st.exclude_bittorrent),
+ block_doh: flag(st.block_doh),
+ /* журнал sing-box по умолчанию выключен (panic — пишет только аварии), пока его не выбрали в панели */
+ log_level: s(st.zm_log) == "1" && index(LOG_LEVELS, s(st.log_level)) >= 0 ? s(st.log_level) : "panic",
+ yacd: flag(st.enable_yacd),
+ yacd_wan: flag(st.enable_yacd_wan_access),
+ yacd_secret: s(st.yacd_secret_key)
+ };
+}
+
+function first_addr(v) {
+ for (let x in arr(v)) {
+ let m = match(s(x), /^([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)/);
+ if (m) return m[1];
+ }
+ return "";
+}
+
+/* Сеть роутера: логические сети (lan, wan…) и устройства (br-lan, eth0, pppoe-wan…). */
+function net_info(c) {
+ let nets = [], devnet = {};
+ try { c.load("network"); } catch (e) {}
+ try {
+ c.foreach("network", "interface", (x) => {
+ let n = x[".name"], proto = s(x.proto), dev = s(x.device || x.ifname);
+ if (dev != "" && !match(dev, /^@/) && !devnet[dev]) devnet[dev] = n;
+ if (proto == "pppoe" || proto == "pppoa") dev = proto + "-" + n;
+ if (match(dev, /^@/)) dev = "";
+ if (dev != "" && !devnet[dev]) devnet[dev] = n;
+ push(nets, { name: n, proto, dev });
+ });
+ } catch (e) {}
+ let lan = "";
+ try { lan = first_addr(c.get("network", "lan", "ipaddr")); } catch (e) {}
+ let devs = [];
+ for (let n in (fs.lsdir(NETDIR) || [])) {
+ if (n == "lo" || match(n, /^(sit|ip6tnl|ip6gre|gre|gretap|erspan|teql|ifb|dummy|bonding_masters|wlan-|mon)/)) continue;
+ let p = NETDIR + "/" + n, t = trim(s(fs.readfile(p + "/type")));
+ let kind = fs.stat(p + "/wireless") || fs.stat(p + "/phy80211") ? "wifi"
+ : fs.stat(p + "/bridge") ? "bridge"
+ : t == "512" ? "ppp"
+ : t == "65534" || t == "776" ? "tun"
+ : t == "1" ? "eth" : "";
+ if (kind == "") continue;
+ let st = trim(s(fs.readfile(p + "/operstate")));
+ push(devs, { name: n, kind, up: st != "down", net: devnet[n] || "" });
+ }
+ devs = sort(devs, (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
+ return { lan_ip: lan, nets, devs };
+}
+
+function valid_ip6(v) {
+ let m = match(v, /^([0-9A-Fa-f:]+)(\/([0-9]+))?$/);
+ return !!(m && index(m[1], ":") >= 0 && (m[3] == null || int(m[3]) <= 128));
+}
+
+function valid_dev(v) { return !!match(v, /^[A-Za-z0-9_.@-]{1,15}$/); }
+
+/* Пишет опцию, только если она отличается от значения по умолчанию — конфиг остаётся чистым. */
+function set_opt(c, sec, key, v, def) {
+ if (v == null || v == def) c.delete(CFG, sec, key);
+ else c.set(CFG, sec, key, type(v) == "array" ? v : s(v));
+}
+
+function set_flag(c, sec, key, on) {
+ if (on) c.set(CFG, sec, key, "1"); else c.delete(CFG, sec, key);
+}
+
+/* Дополнительные опции секции. x — объект из панели (d.x), mode — способ подключения. */
+function write_sec_ext(c, sec, mode, x) {
+ const SUB_KEYS = [ "subscription_format_preference", "subscription_insecure", "subscription_user_agent", "subscription_group_mode", "subscription_group_prefix_len", "group_by_countries", "subscription_filter_include_keywords" ];
+ const UT_KEYS = [ "urltest_check_interval", "urltest_tolerance", "urltest_testing_url" ];
+ const DRES_KEYS = [ "domain_resolver_dns_type", "domain_resolver_dns_server" ];
+ let cur = sec_ext(c.get_all(CFG, sec) || {});
+ if (type(x) != "object") x = cur;
+
+ if (mode == "sub") {
+ let fmt = s(x.sub_fmt), grp = s(x.sub_group), ua = trim(s(x.sub_ua)), inc = kw_list(x.sub_incl);
+ if (index(SUB_FMTS, fmt) < 0) fmt = "auto";
+ if (index(SUB_GROUPS, grp) < 0) grp = "off";
+ if (length(ua) > 200 || match(ua, /[\r\n\t'"\\`$]/) || non_ascii(ua)) fail("User-Agent — одна строка латиницей до 200 символов, без кавычек");
+ if (length(inc) > 40) fail("слов для отбора серверов можно указать не больше 40");
+ let plen = num_in(x.sub_plen, 1, 16);
+ if (grp == "prefix" && plen == null) fail("длина префикса для групп — число от 1 до 16");
+ set_opt(c, sec, "subscription_format_preference", fmt, "auto");
+ set_flag(c, sec, "subscription_insecure", !!x.sub_insecure);
+ set_opt(c, sec, "subscription_user_agent", ua, "");
+ c.delete(CFG, sec, "group_by_countries");
+ set_opt(c, sec, "subscription_group_mode", grp, "off");
+ set_opt(c, sec, "subscription_group_prefix_len", grp == "prefix" ? s(plen) : null, "2");
+ set_list(c, sec, "subscription_filter_include_keywords", inc);
+ }
+ else del_keys(c, sec, SUB_KEYS);
+
+ if (mode != "iface") {
+ let iv = s(x.ut_iv), url = trim(s(x.ut_url)), tol = num_in(x.ut_tol, 0, 10000);
+ if (index(UT_IVS, iv) < 0) iv = "3m";
+ if (tol == null) fail("допуск автовыбора — число миллисекунд от 0 до 10000");
+ if (url == "") url = UT_URL_DEF;
+ if (!valid_url(url) || match(url, /['"\\`$]/)) fail("адрес для проверки серверов должен быть ссылкой http(s)://…");
+ set_opt(c, sec, "urltest_check_interval", iv, "3m");
+ set_opt(c, sec, "urltest_tolerance", s(tol), "50");
+ set_opt(c, sec, "urltest_testing_url", url, UT_URL_DEF);
+ c.set(CFG, sec, "enable_udp_over_tcp", x.uot ? "1" : "0");
+ del_keys(c, sec, [ "domain_resolver_enabled", ...DRES_KEYS ]);
+ } else {
+ del_keys(c, sec, [ ...UT_KEYS, "enable_udp_over_tcp" ]);
+ if (x.dres_on) {
+ let t = s(x.dres_type), v = trim(s(x.dres_server));
+ if (index([ "udp", "dot", "doh" ], t) < 0) fail("неизвестный тип DNS для туннеля");
+ if (!dns_value_ok(t, v))
+ fail(t == "udp" ? "DNS туннеля: нужен IP-адрес, например 1.1.1.1" : t == "dot" ? "DNS туннеля: нужен адрес без https://, например one.one.one.one" : "DNS туннеля: нужна ссылка https://…/dns-query или адрес сервера");
+ c.set(CFG, sec, "domain_resolver_enabled", "1");
+ c.set(CFG, sec, "domain_resolver_dns_type", t);
+ c.set(CFG, sec, "domain_resolver_dns_server", dns_norm(t, v));
+ } else {
+ c.set(CFG, sec, "domain_resolver_enabled", "0");
+ del_keys(c, sec, DRES_KEYS);
+ }
+ }
+
+ if (x.mixed) {
+ let port = num_in(x.mixed_port, 1024, 65535);
+ if (port == null) fail("порт локального прокси — число от 1024 до 65535");
+ if (port == 9090 || port == 5353) fail("порт " + port + " занят службами роутера — выберите другой, например 2080");
+ for (let o in sections(c, "section")) {
+ let n = o[".name"];
+ if (n == sec || !is_conn(o) || s(o.disabled) == "1" || !flag(o.mixed_proxy_enabled)) continue;
+ if ((num_in(o.mixed_proxy_port, 1, 65535) || 2080) == port)
+ fail("порт " + port + " уже занят локальным прокси секции «" + sec_label(o, false) + "» — выберите другой");
+ }
+ c.set(CFG, sec, "mixed_proxy_enabled", "1");
+ c.set(CFG, sec, "mixed_proxy_port", s(port));
+ } else del_keys(c, sec, [ "mixed_proxy_enabled", "mixed_proxy_port" ]);
+ set_flag(c, sec, "resolve_real_ip_for_routing", !!x.real_ip);
+}
+
+/* Общие настройки: DNS (TTL, ECS), сеть, журнал, панель sing-box. g — объект из панели (d.g). */
+function write_set_ext(c, g) {
+ if (type(g) != "object") return;
+ const S = "settings";
+ let ttl = num_in(g.dns_ttl, 0, 86400);
+ if (ttl == null) fail("время жизни DNS-записей — число секунд от 0 до 86400");
+ let ecs = trim(s(g.dns_ecs));
+ if (ecs != "" && !valid_ip(ecs) && !valid_ip6(ecs)) fail("EDNS Client Subnet — IP-адрес или подсеть, например 203.0.113.0/24");
+ c.set(CFG, S, "dns_rewrite_ttl", s(ttl));
+ set_opt(c, S, "dns_client_subnet", ecs, "");
+
+ let src = filter(uniq(words(g.src_ifs)), (v) => v != "");
+ for (let v in src) if (!valid_dev(v)) fail("неверное имя интерфейса: " + v);
+ if (!length(src)) fail("выберите хотя бы одну сеть, из которой Forkozz принимает трафик");
+ if (length(src) > 8) fail("сетей-источников можно выбрать не больше 8");
+ c.set(CFG, S, "source_network_interfaces", src);
+
+ let oi = trim(s(g.out_if));
+ if (oi != "") {
+ if (!valid_dev(oi)) fail("неверное имя интерфейса для выхода: " + oi);
+ if (index(src, oi) >= 0) fail("один и тот же интерфейс не может быть и источником, и выходом: " + oi);
+ c.set(CFG, S, "enable_output_network_interface", "1");
+ c.set(CFG, S, "output_network_interface", oi);
+ } else {
+ c.set(CFG, S, "enable_output_network_interface", "0");
+ c.delete(CFG, S, "output_network_interface");
+ }
+
+ if (g.badwan) {
+ let bw = filter(uniq(words(g.badwan_ifs)), (v) => v != "");
+ for (let v in bw) if (!match(v, /^[A-Za-z0-9_]{1,32}$/) || v == "lan" || v == "loopback") fail("эту сеть следить нельзя: " + v);
+ if (!length(bw)) fail("выберите, за какими подключениями к интернету следить");
+ let dl = num_in(g.badwan_delay, 0, 600000);
+ if (dl == null) fail("задержка перезапуска — число миллисекунд, например 2000");
+ c.set(CFG, S, "enable_badwan_interface_monitoring", "1");
+ c.set(CFG, S, "badwan_monitored_interfaces", bw);
+ c.set(CFG, S, "badwan_reload_delay", s(dl));
+ } else {
+ c.set(CFG, S, "enable_badwan_interface_monitoring", "0");
+ del_keys(c, S, [ "badwan_monitored_interfaces", "badwan_reload_delay" ]);
+ }
+
+ for (let kv in [ [ "ipv6", "enable_ipv6" ], [ "no_dhcp", "dont_touch_dhcp" ], [ "excl_ntp", "exclude_ntp" ], [ "excl_bt", "exclude_bittorrent" ], [ "block_doh", "block_doh" ] ])
+ c.set(CFG, S, kv[1], g[kv[0]] ? "1" : "0");
+ let lv = s(g.log_level);
+ c.set(CFG, S, "log_level", index(LOG_LEVELS, lv) >= 0 ? lv : "panic");
+ c.set(CFG, S, "zm_log", "1");
+
+ if (g.yacd) {
+ c.set(CFG, S, "enable_yacd", "1");
+ if (g.yacd_wan) {
+ let k = trim(s(g.yacd_secret));
+ if (length(k) < 8 || length(k) > 64 || !match(k, /^[A-Za-z0-9._~+=-]+$/)) fail("для доступа из интернета задайте ключ панели: 8–64 латинских букв, цифр или ._-~+=");
+ c.set(CFG, S, "enable_yacd_wan_access", "1");
+ c.set(CFG, S, "yacd_secret_key", k);
+ } else {
+ c.set(CFG, S, "enable_yacd_wan_access", "0");
+ c.delete(CFG, S, "yacd_secret_key");
+ }
+ } else {
+ c.set(CFG, S, "enable_yacd", "0");
+ c.set(CFG, S, "enable_yacd_wan_access", "0");
+ c.delete(CFG, S, "yacd_secret_key");
+ }
+}
+
+
+function cmd_get(want) {
+ let c = cursor(), p = pick(c), sec = p.sec, main = p.sec;
+ want = s(want);
+ if (want != "" && want != main && index(p.conns, want) >= 0) sec = want;
+ let m = c.get_all(CFG, sec) || {}, st = c.get_all(CFG, "settings") || {}, mm = c.get_all(CFG, main) || {};
+ let mode = sec_mode(m), rf = sec_refs(m), subs = sub_urls(m);
+ out({
+ sec,
+ main,
+ is_main: sec == main,
+ main_exists: mm[".type"] != null && sec_configured(mm),
+ label: sec_label(m, sec == main),
+ enabled: sec == main || s(m.disabled) != "1",
+ sections: sec_list(c, p),
+ bypass: bypass_info(c),
+ exists: m[".type"] != null && sec_configured(m),
+ mode: mode == "json" ? "links" : mode,
+ links: sec_links(m),
+ sub: length(subs) ? subs[0] : "",
+ subs,
+ sub_interval: s(m.subscription_update_interval || "1h"),
+ iface: s(m.interface),
+ fastest: true,
+ exclude: s(m.zm_exclude),
+ hide_names: rawlist(m.zm_hide),
+ jsons: mode == "json" ? 1 : 0,
+ refs: { c: rf.c, s: rf.s, r: rf.r },
+ catalog: { items: catalog_items(services_list()), meta: cat_meta() },
+ domains: domain_lines(m),
+ subnets: subnet_lines(m),
+ lists: rf.l,
+ full: uniq(words(mm.fully_routed_ips)),
+ excl: uniq(words(st.routing_excluded_ips)),
+ extra: p.extra,
+ dns: dns_info(st),
+ quic_off: s(st.disable_quic) == "1",
+ list_interval: s(st.update_interval || "1d"),
+ list_update: true,
+ lists_via: s(st.download_lists_via_proxy) == "1",
+ devices: devices(),
+ tunnels: tunnels(),
+ x: sec_ext(m),
+ g: set_ext(st),
+ net: net_info(c)
+ });
+}
+
function cmd_set() {
let raw = fs.stdin.read("all"), d;
try { d = json(s(raw)); } catch (e) { fail("не удалось прочитать настройки"); }
@@ -13827,9 +14138,13 @@ function cmd_set() {
for (let x in links)
if (!match(x, SCHEMES))
fail(match(x, /^(tuic|http|https|anytls|wireguard|hysteria):\/\//i) ? "Forkozz не поддерживает такие ссылки: " + substr(x, 0, 40) + " — подойдут vless, vmess, trojan, ss, socks5, hysteria2" : "ссылка не поддерживается: " + substr(x, 0, 40));
- let sub = trim(s(d.sub)), iface = trim(s(d.iface));
+ let subs = uniq(lines(type(d.subs) == "array" && length(d.subs) ? d.subs : d.sub)), iface = trim(s(d.iface));
if (mode == "links" && !length(links)) fail("добавьте хотя бы одну ссылку на сервер");
- if (mode == "sub" && !valid_url(sub)) fail("ссылка на подписку должна начинаться с https://");
+ if (mode == "sub") {
+ if (!length(subs)) fail("вставьте ссылку на подписку");
+ if (length(subs) > SUB_MAX) fail("подписок в одной секции можно указать не больше " + SUB_MAX);
+ for (let u in subs) if (!valid_url(u) || match(u, /['"\\`]/)) fail("ссылка на подписку должна начинаться с https:// — ошибка в «" + substr(u, 0, 60) + "»");
+ }
if (mode == "iface" && !match(iface, /^[A-Za-z0-9_.@-]{1,15}$/)) fail("выберите сетевой интерфейс");
let known = services_list(), refs = type(d.refs) == "object" ? d.refs : {};
@@ -13897,12 +14212,9 @@ function cmd_set() {
c.set(CFG, sec, "connection_type", "vpn");
c.delete(CFG, sec, "proxy_config_type");
c.set(CFG, sec, "interface", iface);
- c.set(CFG, sec, "domain_resolver_enabled", "0");
del_keys(c, sec, [ "zm_links", "zm_exclude", "zm_hide", "subscription_update_interval" ]);
} else {
c.set(CFG, sec, "connection_type", "proxy");
- c.delete(CFG, sec, "domain_resolver_enabled");
- if (c.get(CFG, sec, "enable_udp_over_tcp") == null) c.set(CFG, sec, "enable_udp_over_tcp", "0");
if (xstr != "") c.set(CFG, sec, "zm_exclude", xstr); else c.delete(CFG, sec, "zm_exclude");
if (mode == "links") {
c.set(CFG, sec, "proxy_config_type", "urltest");
@@ -13910,7 +14222,7 @@ function cmd_set() {
c.delete(CFG, sec, "subscription_update_interval");
} else {
c.set(CFG, sec, "proxy_config_type", "subscription");
- c.set(CFG, sec, "subscription_url", [ sub ]);
+ c.set(CFG, sec, "subscription_url", subs);
let iv = s(d.sub_interval);
if (index(SUB_IVS, iv) < 0) iv = "1h";
c.set(CFG, sec, "subscription_update_interval", iv);
@@ -13918,6 +14230,7 @@ function cmd_set() {
}
apply_servers(c, sec);
}
+ write_sec_ext(c, sec, mode, d.x);
set_lists(c, sec, { s: rsets, r: rsubs, l: lists }, services, domains, subnets);
if (is_main || d.full != null) {
@@ -13989,6 +14302,7 @@ function cmd_set() {
else c.delete(CFG, "settings", "download_lists_via_proxy_section");
}
if (index(LIST_IVS, s(d.list_interval)) >= 0) c.set(CFG, "settings", "update_interval", s(d.list_interval));
+ write_set_ext(c, d.g);
c.save(CFG);
c.commit(CFG);
@@ -14105,13 +14419,11 @@ function cmd_servers(sec) {
let fell = "";
try { fell = trim(s(fs.readfile(fell_file(sec)))); } catch (e) { fell = ""; }
let lat = lat_read(sec), lrun = lat_running(lat);
- if (lat && !lat.running && time() - int(lat.at || 0) < 900) {
+ /* результаты последнего TCP-пинга (и промежуточные, пока он идёт): -2 — сервер ещё в очереди */
+ if (lat && (lrun || time() - int(lat.at || 0) < 900)) {
let dl = type(lat.delays) == "object" ? lat.delays : {};
- for (let t in (type(lat.tested) == "array" ? lat.tested : [])) {
- if (!nodes[t]) continue;
- if (!nodes[t].members) nodes[t].delay = int(dl[t] || 0);
- else if (nodes[t].delay < 0) nodes[t].delay = int(dl[t] || 0);
- }
+ for (let t in (type(lat.tested) == "array" ? lat.tested : [])) if (nodes[t]) nodes[t].delay = int(dl[t] || 0);
+ if (lrun) for (let t in (type(lat.pending) == "array" ? lat.pending : [])) if (nodes[t]) nodes[t].delay = -2;
}
out({ group: group_tag(sec), now: s(g.now), list: g.all, nodes, sub: sec_mode(xm) == "sub" ? sub_info(sec) : null,
hidden: length(xv.h), exclude: s(xm.zm_exclude), hide_names: xv.h, fell, lat: lat ? { running: lrun, at: int(lat.at || 0), error: s(lat.error) } : null });
@@ -14147,7 +14459,7 @@ function urlenc(v) {
}
function lat_cmd(ep, tag, url, ms) {
- return "curl -s --max-time " + (int(ms / 1000) + 3) + (ep.secret != "" ? " -H " + q("Authorization: Bearer " + ep.secret) : "") +
+ return "curl -s --noproxy '*' --max-time " + (int(ms / 1000) + 3) + (ep.secret != "" ? " -H " + q("Authorization: Bearer " + ep.secret) : "") +
" -G --data-urlencode " + q("url=" + url) + " --data-urlencode " + q("timeout=" + ms) +
" " + q(ep.base + "/proxies/" + urlenc(tag) + "/delay");
}
@@ -14194,9 +14506,9 @@ function node_addrs() {
return r;
}
-function tcp_cmd(a) {
+function tcp_cmd(a, to) {
let h = index(a.host, ":") >= 0 ? "[" + a.host + "]" : a.host;
- return "curl -s -o /dev/null --connect-timeout 4 --max-time 4 -w '%{time_connect} %{time_namelookup}' " + q("http://" + h + ":" + a.port + "/");
+ return "curl -s -o /dev/null --noproxy '*' --connect-timeout " + to + " --max-time " + to + " -w '%{time_connect} %{time_namelookup}' " + q("http://" + h + ":" + a.port + "/");
}
function tcp_parse(raw) {
@@ -14208,53 +14520,31 @@ function tcp_parse(raw) {
return ms < 1 ? 1 : ms;
}
-/* по 8 серверов параллельно; две попытки на сервер, берём лучшую (как у клиентов) */
-function tcp_batch(addrs, tags) {
- let res = {}, dir = STATE + "/tcp.tmp." + time();
+/* Одна пачка серверов: все параллельно, у каждого две попытки сразу, берём лучшую. Пачка длится
+ * не дольше таймаута — мёртвый сервер не тормозит остальных. */
+const TCP_PAR = 10;
+let TCP_SEQ = 0;
+
+function tcp_chunk(addrs, part, to) {
+ let res = {}, dir = STATE + "/tcp.tmp." + s(fs.readlink("/proc/self")) + "." + (++TCP_SEQ), cmd = "";
try { fs.mkdir(STATE); } catch (e) {}
try { fs.mkdir(dir); } catch (e) {}
- for (let i = 0; i < length(tags); i += 8) {
- let part = slice(tags, i, i + 8), cmd = "";
- for (let k = 0; k < length(part); k++) {
- let c = tcp_cmd(addrs[part[k]]), f = q(dir + "/" + k);
- cmd += "( " + c + " > " + f + "; " + c + " >> " + f + ".b; ) 2>/dev/null & ";
- }
- sh(cmd + "wait");
- for (let k = 0; k < length(part); k++) {
- let a = 0, b = 0;
- try { a = tcp_parse(fs.readfile(dir + "/" + k)); } catch (e) { a = 0; }
- try { b = tcp_parse(fs.readfile(dir + "/" + k + ".b")); } catch (e) { b = 0; }
- let best = (a > 0 && b > 0) ? (a < b ? a : b) : (a > 0 ? a : b);
- if (best > 0) res[part[k]] = best;
- }
+ for (let k = 0; k < length(part); k++) {
+ let c = tcp_cmd(addrs[part[k]], to), f = q(dir + "/" + k);
+ cmd += "(" + c + " > " + f + ".a) 2>/dev/null & (" + c + " > " + f + ".b) 2>/dev/null & ";
+ }
+ sh(cmd + "wait");
+ for (let k = 0; k < length(part); k++) {
+ let a = 0, b = 0;
+ try { a = tcp_parse(fs.readfile(dir + "/" + k + ".a")); } catch (e) { a = 0; }
+ try { b = tcp_parse(fs.readfile(dir + "/" + k + ".b")); } catch (e) { b = 0; }
+ let best = (a > 0 && b > 0) ? (a < b ? a : b) : (a > 0 ? a : b);
+ if (best > 0) res[part[k]] = best;
}
sh("rm -rf " + q(dir));
return res;
}
-/* Итог: { tag: мс } для ответивших. Сначала TCP-пинг (быстро и сравнимо с Happ). Тем, у кого TCP не
- * получился (UDP-протоколы, группы, закрытый порт), делаем реальный запрос через сервер — в несколько
- * проходов с запасными адресами. */
-function lat_measure(tags, ms) {
- let ep = clash_ep(), res = {};
- let hasCurl = sh("command -v curl") != "";
- if (!hasCurl) return null;
- let addrs = node_addrs(), tl = filter(tags, (x) => !!addrs[x]);
- if (length(tl)) {
- let tr = tcp_batch(addrs, tl);
- for (let k in tr) res[k] = tr[k];
- }
- let left = filter(tags, (x) => !res[x]);
- if (!ep) return length(res) ? res : null;
- for (let pass = 0; pass < length(LAT_URLS) && length(left); pass++) {
- let got = lat_batch(ep, left, LAT_URLS[pass], ms + pass * 1500);
- let next = [];
- for (let x in left) { if (got[x] > 0) res[x] = got[x]; else push(next, x); }
- left = next;
- }
- return res;
-}
-
function probe_ms(tag, ms, passes) {
let ep = clash_ep();
if (ep && sh("command -v curl") != "") {
@@ -14310,46 +14600,65 @@ function cmd_latency(sec) {
out({ ok: true, started: true });
}
-function cmd_latwork(sec) {
- let px = proxies(), g = px ? px[group_tag(sec)] : null, tested = {}, delays = {}, direct = {}, order = [];
- let add = (t) => { if (!tested[t]) { tested[t] = true; push(order, t); } };
- if (g && type(g.all) == "array") {
- for (let t in g.all) { direct[t] = true; add(t); }
- /* вложенные группы (urltest по подписке или по странам): их узлы проверяем, только если
- * среди прямых членов группы секции их нет */
+/* Задержка серверов — TCP-пинг, как «TCP» в Happ: время установки соединения с сервером, без туннеля.
+ * Пачками по TCP_PAR параллельно, по две попытки на сервер; после каждой пачки результат сразу пишется
+ * в файл — панель показывает задержки по мере проверки. Кто не ответил за 3 с, проверяется ещё раз
+ * с запасом в 6 с: «не отвечает» ставится только после обоих проходов. Серверы на UDP (Hysteria2,
+ * TUIC) по TCP не проверить — для них один настоящий запрос через sing-box. Группа (по странам,
+ * автовыбор подписки) получает лучшую задержку своих серверов. */
+function latwork(sec) {
+ let px = proxies(), g = px ? px[group_tag(sec)] : null, seen = {}, order = [], groups = [];
+ let add = (t) => { if (!seen[t]) { seen[t] = true; push(order, t); } };
+ if (g && type(g.all) == "array")
for (let t in g.all) {
let p = px[t];
- if (!p || type(p.all) != "array") continue;
- for (let m in p.all) add(m);
+ if (p && type(p.all) == "array") { push(groups, t); for (let m in p.all) add(m); }
+ else add(t);
}
- }
- let err = g ? "" : "группа серверов секции ещё не готова — примените настройки";
- if (g) {
- /* основной путь: каждый сервер проверяется отдельным запросом, с повторами и запасными адресами */
- let r = lat_measure(order, 6000);
- if (r != null) {
- for (let k in r) delays[k] = r[k];
- if (!length(delays)) err = "ни один сервер не ответил на проверку — проверьте интернет на роутере";
- } else {
- /* запасной путь (нет curl или Clash API): прежний замер группы */
- let groups = [ group_tag(sec) ];
- for (let t in g.all) {
- let p = px[t];
- if (!p || type(p.all) != "array") continue;
- let extra = false;
- for (let m in p.all) if (!direct[m]) extra = true;
- if (extra) push(groups, t);
- }
- for (let gt in groups) {
- let j = jcmd(BIN + " clash_api get_group_latency " + q(gt) + " 8000");
- if (type(j) != "object") { err = err || "sing-box не ответил на проверку задержки"; continue; }
- if (j.message && length(j) == 1) { err = err || ("sing-box: " + s(j.message)); continue; }
- for (let k in j) if (int(j[k]) > 0) delays[k] = int(j[k]);
- }
+ let delays = {}, done = {}, err = g ? "" : "группа серверов секции ещё не готова — примените настройки";
+ let save = (running) => {
+ for (let t in groups) {
+ let best = 0, all = true;
+ for (let m in px[t].all) { if (!done[m]) all = false; else if (delays[m] > 0 && (!best || delays[m] < best)) best = delays[m]; }
+ if (best > 0) delays[t] = best;
+ if (all || best > 0) done[t] = true;
}
+ fs.writefile(lat_path(sec), sprintf("%J", { at: time(), running, tested: keys(done), pending: filter([ ...order, ...groups ], (t) => !done[t]), delays, error: err }));
+ };
+ if (!g) { save(false); return; }
+ if (sh("command -v curl") == "") { err = "на роутере нет curl — задержку проверить нечем"; save(false); return; }
+ save(true);
+
+ let addrs = node_addrs(), tcp = filter(order, (t) => !!addrs[t]), other = filter(order, (t) => !addrs[t]);
+ let fails = [];
+ for (let i = 0; i < length(tcp); i += TCP_PAR) {
+ let part = slice(tcp, i, i + TCP_PAR), r = tcp_chunk(addrs, part, 3);
+ for (let t in part) { if (r[t]) { delays[t] = r[t]; done[t] = true; } else push(fails, t); }
+ save(true);
+ }
+ for (let i = 0; i < length(fails); i += TCP_PAR) {
+ let part = slice(fails, i, i + TCP_PAR), r = tcp_chunk(addrs, part, 6);
+ for (let t in part) { if (r[t]) delays[t] = r[t]; done[t] = true; }
+ save(true);
+ }
+ let ep = clash_ep();
+ if (length(other) && ep) {
+ let r = lat_batch(ep, other, LAT_URLS[0], 5000);
+ for (let t in other) { if (r[t]) delays[t] = r[t]; done[t] = true; }
+ }
+ for (let t in other) done[t] = true;
+ let alive = 0;
+ for (let t in order) if (delays[t] > 0) alive++;
+ if (!alive && length(order)) err = "ни один сервер не ответил — проверьте интернет на роутере";
+ save(false);
+}
+
+function cmd_latwork(sec) {
+ try { latwork(sec); }
+ catch (e) {
+ /* что бы ни случилось — не оставлять «проверяем…» висеть */
+ try { fs.writefile(lat_path(sec), sprintf("%J", { at: time(), running: false, tested: [], delays: {}, error: "проверка прервалась: " + s(e.message || e) })); } catch (x) {}
}
- if (length(delays)) err = "";
- fs.writefile(lat_path(sec), sprintf("%J", { at: time(), running: false, tested: err ? [] : keys(tested), delays, error: err }));
}
function cmd_select(sec, tag) {
@@ -14745,10 +15054,29 @@ function cmd_import(dir) {
out({ ok: true, main, notes });
}
+/* Трафик через sing-box: всего, соединения, память. Скорость панель считает сама по разнице. */
+function sb_rss() {
+ let pid = trim(split(trim(sh("pidof sing-box")), " ")[0] || "");
+ if (!match(pid, /^[0-9]+$/)) return null;
+ let m = match(s(fs.readfile("/proc/" + pid + "/status")), /VmRSS:[ \t]*([0-9]+)/);
+ return m ? int(m[1]) * 1024 : null;
+}
+
+function cmd_stats() {
+ let ep = clash_ep();
+ if (!ep) { out({ ok: false }); return; }
+ let auth = ep.secret != "" ? " -H " + q("Authorization: Bearer " + ep.secret) : "";
+ let j = jcmd("curl -s --noproxy '*' -m 3" + auth + " " + q(ep.base + "/connections"));
+ if (type(j) != "object" || j.downloadTotal == null) { out({ ok: false }); return; }
+ out({ ok: true, at: time(), down: int(j.downloadTotal || 0), up: int(j.uploadTotal || 0),
+ conns: type(j.connections) == "array" ? length(j.connections) : 0,
+ mem: int(j.memory || 0) > 0 ? int(j.memory) : sb_rss() });
+}
+
let mode = ARGV[0] || "";
let sec = ARGV[1] || "";
if (mode != "import" && mode != "route" && sec != "" && !match(sec, /^[A-Za-z0-9_]+$/)) fail("неверное имя секции");
-if (sec == "" && index([ "get", "set", "secdel", "import", "route", "diag", "secnames", "secstate" ], mode) < 0) sec = pick(cursor()).sec;
+if (sec == "" && index([ "get", "set", "secdel", "import", "route", "diag", "secnames", "secstate", "stats" ], mode) < 0) sec = pick(cursor()).sec;
if (mode == "get") cmd_get(sec);
else if (mode == "set") cmd_set();
@@ -14764,6 +15092,7 @@ else if (mode == "hide") cmd_hide(sec);
else if (mode == "diag") cmd_diag();
else if (mode == "secstate") cmd_secstate(sec == "probe");
else if (mode == "subinfo") cmd_subinfo(sec);
+else if (mode == "stats") cmd_stats();
else if (mode == "route") cmd_route(ARGV[1], ARGV[2]);
else if (mode == "import") cmd_import(sec);
else fail("неизвестная команда");
@@ -15385,47 +15714,105 @@ function detectMissingThemeVar() {
} catch (e) { }
}
+/* Номер сборки панели: установщик подставляет его сюда же, в build.txt и в адрес style.css. */
+var ZM_BUILD = '__ZM_BUILD__';
+
+function cssUrl(path) {
+ return L.resource(path) + (/^[0-9]{6,}$/.test(ZM_BUILD) ? '?v=' + ZM_BUILD : '');
+}
+
+/* После обновления браузер может держать старые файлы панели: LuCI грузит их с одним и тем же
+ * адресом (?v= — версия самой LuCI, она не меняется), а uhttpd не запрещает кэш. Проверка:
+ * build.txt читаем всегда с сервера. Если в нём другой номер, чем в этом common.js, — common.js
+ * устарел: перекачиваем все файлы панели мимо кэша и перезагружаем страницу (не больше двух раз
+ * подряд на одну сборку, чтобы не зациклиться). Если номер тот же, но браузер в этой сборке ещё
+ * не проверялся, сверяем остальные файлы (виды страниц): кэш против сервера. */
var cacheGuardDone = false;
function cacheGuard() {
if (cacheGuardDone) return;
cacheGuardDone = true;
try {
- if (document.body.classList.contains('zmw-body') || !window.fetch || !window.localStorage) return;
+ if (document.body.classList.contains('zmw-body') || !window.fetch) return;
var base = String(L.env.base_url || L.resource('x').replace(/\/x$/, '')), ver = L.env.resource_version;
- fetch(base + '/zapret-manager/build.txt?t=' + Date.now(), { cache: 'no-store' }).then(function(r) { return r.ok ? r.text() : ''; }).then(function(t) {
- var lines = String(t || '').split(/\r?\n/).map(function(x) { return x.trim(); }).filter(Boolean);
- var build = lines.shift(), had = null, stale = false;
+ var ls = function(k, v) { try { if (v === undefined) return localStorage.getItem(k); localStorage.setItem(k, v); } catch (e) {} return null; };
+ var ss = function(k, v) { try { if (v === undefined) return sessionStorage.getItem(k); sessionStorage.setItem(k, v); } catch (e) {} return null; };
+ var grab = function(url, mode) { return fetch(url, { cache: mode }).then(function(r) { return r.ok ? r.text() : null; }).catch(function() { return null; }); };
+ /* по 4 запроса одновременно: uhttpd на роутере обслуживает всего несколько запросов разом */
+ var pool = function(list, fn) {
+ var i = 0, run = function() { if (i >= list.length) return Promise.resolve(); var x = list[i++]; return fn(x).then(run, run); };
+ return Promise.all([ run(), run(), run(), run() ]);
+ };
+ fetch(base + '/zapret-manager/build.txt?t=' + Date.now(), { cache: 'no-store' }).then(function(r) { return r.ok ? r.text() : ''; }).then(function(txt) {
+ var lines = String(txt || '').split(/\r?\n/).map(function(x) { return x.trim(); }).filter(Boolean);
+ var build = lines.shift();
if (!/^[0-9]{6,}$/.test(build || '')) return;
- try { had = localStorage.getItem('zm.build'); } catch (e) { return; }
- if (had === build) return;
- function grab(url, mode) {
- return fetch(url, { cache: mode }).then(function(r) { return r.ok ? r.text() : null; }).catch(function() { return null; });
- }
- return lines.filter(function(p) { return /^[A-Za-z0-9_\/.-]+\.(js|css)$/.test(p) && p.indexOf('..') < 0; }).reduce(function(pr, p) {
- var url = base + '/' + p + (/\.js$/.test(p) && ver != null ? '?v=' + ver : '');
- return pr.then(function() {
- return grab(url, 'force-cache').then(function(a) {
- return grab(url, 'reload').then(function(b) { if (a != null && b != null && a !== b) stale = true; });
- });
- });
- }, Promise.resolve()).then(function() {
- try { localStorage.setItem('zm.build', build); if (localStorage.getItem('zm.build') !== build) return; } catch (e) { return; }
- if (!stale) return;
+ var files = lines.filter(function(p) { return /^[A-Za-z0-9_\/.-]+\.(js|css)$/.test(p) && p.indexOf('..') < 0; });
+ var urls = [];
+ files.forEach(function(p) {
+ urls.push(base + '/' + p);
+ if (/\.js$/.test(p) && ver != null) urls.push(base + '/' + p + '?v=' + ver);
+ if (/\.css$/.test(p)) urls.push(base + '/' + p + '?v=' + build);
+ });
+ var reload = function() {
+ var n = +(ss('zm.reload.' + build) || 0);
+ if (n >= 2) return;
+ ss('zm.reload.' + build, String(n + 1));
toast('Панель обновлена — загружаем новую версию', 'info');
- setTimeout(function() { location.reload(); }, 700);
+ setTimeout(function() { location.reload(); }, 600);
+ };
+ if (/^[0-9]{6,}$/.test(ZM_BUILD) && ZM_BUILD !== build) {
+ return pool(urls, function(u) { return grab(u, 'reload'); }).then(function() { ls('zm.build', build); reload(); });
+ }
+ if (ls('zm.build') === build) return;
+ var stale = false;
+ return pool(urls, function(u) {
+ return grab(u, 'force-cache').then(function(a) {
+ return grab(u, 'reload').then(function(b) { if (a != null && b != null && a !== b) stale = true; });
+ });
+ }).then(function() {
+ ls('zm.build', build);
+ if (stale) reload();
});
}).catch(function() {});
} catch (e) {}
}
+/* Windows не рисует флаги стран (🇩🇪 видно как «DE»). Если браузер флаги не умеет, подключаем
+ * шрифт только с флагами (unicode-range — остальной текст остаётся шрифтом темы). Без интернета
+ * всё как раньше: буквы вместо флага. */
+var FLAG_FONT = 'https://cdn.jsdelivr.net/npm/country-flag-emoji-polyfill@0.1/dist/TwemojiCountryFlags.woff2';
+function flagsOk() {
+ try {
+ var c = document.createElement('canvas'); c.width = c.height = 24;
+ var x = c.getContext('2d', { willReadFrequently: true });
+ if (!x) return true;
+ x.textBaseline = 'top'; x.font = '20px sans-serif'; x.fillStyle = '#000';
+ x.fillText('\uD83C\uDDE8\uD83C\uDDED', 0, 0);
+ var d = x.getImageData(0, 0, 24, 24).data;
+ for (var i = 0; i < d.length; i += 4) if (d[i + 3] && (d[i] !== d[i + 1] || d[i + 1] !== d[i + 2])) return true;
+ return false;
+ } catch (e) { return true; }
+}
+function flagFont() {
+ if (document.getElementById('zm-flags') || flagsOk()) return;
+ var fam = '';
+ try { fam = getComputedStyle(document.body).fontFamily || ''; } catch (e) {}
+ var s = document.createElement('style');
+ s.id = 'zm-flags';
+ s.textContent = '@font-face{font-family:"Twemoji Country Flags";unicode-range:U+1F1E6-1F1FF,U+1F3F4,U+E0062-E0063,U+E0065,U+E0067,U+E006C,U+E006E,U+E0073-E0074,U+E0077,U+E007F;src:url("' + FLAG_FONT + '") format("woff2");font-display:swap}' +
+ 'body,.zm-wrap{font-family:"Twemoji Country Flags",' + (fam || 'system-ui,sans-serif') + '}';
+ document.head.appendChild(s);
+}
+
function injectCss() {
cacheGuard();
+ try { flagFont(); } catch (e) {}
detectMissingThemeVar();
if (document.getElementById('zm-css')) return;
var l = document.createElement('link');
l.id = 'zm-css';
l.rel = 'stylesheet';
- l.href = L.resource('view/zapret-manager/style.css');
+ l.href = cssUrl('view/zapret-manager/style.css');
document.head.appendChild(l);
}
@@ -16406,7 +16793,7 @@ function routeCheck(o) {
var btn = E('button', { 'type': 'button', 'class': 'cbi-button cbi-button-action', 'click': function() { run(); } }, 'Проверить');
var chips = E('div', { 'class': 'zm-route-chips' });
var out = E('div', { 'class': 'zm-route-out' });
- (o.examples || [ 'youtube.com', 'instagram.com', 'discord.com', 'chatgpt.com', 'epidemz.net.co', 'speedtest.net', 'telegram.org' ]).forEach(function(x) {
+ (o.examples || [ 'youtube.com', 'instagram.com', 'discord.com', 'chatgpt.com', 'epidemz.net.co', 'speedtest.net', 'telegram.org', 'ozon.ru', 'vk.ru' ]).forEach(function(x) {
chips.appendChild(E('button', { 'type': 'button', 'class': 'zm-route-chip', 'click': function() { inp.value = x; run(); } }, x));
});
function off() { return !!(o.disabled && o.disabled()); }
@@ -18539,26 +18926,40 @@ return view.extend({
onSave: function(names, markers) { return subActP('sub_hide', JSON.stringify({ names: names, markers: markers }), 'Применяем список узлов'); },
onReset: function() { return subActP('sub_hide', JSON.stringify({ reset: true }), 'Возвращаем все узлы'); } });
+ /* Задержка — TCP-пинг, как «TCP» в Happ: пачками по 8 узлов, две пачки одновременно, результаты
+ * появляются по мере проверки. Hysteria2 по TCP не проверить — его проверяет ядро steer. */
function probeAll() {
var hm = subHidden(), nodes = subNodesAll().filter(function(n) { return !hm[n.index]; });
if (probing || !nodes.length) return;
probing = true; probeDone = 0; probeTotal = nodes.length; lat = {};
nodes.forEach(function(n) { lat[n.index] = { busy: true }; });
renderSub();
- var queue = nodes.map(function(n) { return n.index; });
- function next() {
- if (!queue.length) return Promise.resolve();
- var i = queue.shift();
+ var ids = nodes.map(function(n) { return n.index; }), chunks = [], core = [];
+ for (var c = 0; c < ids.length; c += 8) chunks.push(ids.slice(c, c + 8));
+ function one(i) {
return zm.steerAction('sub_probe', String(i)).then(function(res) {
var r = res && res.results && res.results[0];
- lat[i] = r ? { ok: !!r.ok, ms: r.tcp_ms > 0 ? r.tcp_ms : (r.ttfb_ms > 0 ? r.ttfb_ms : r.handshake_ms), why: r.why } : { ok: false, why: (res && res.error) || '' };
- }).catch(function() { lat[i] = { ok: false }; }).then(function() {
- probeDone++;
+ lat[i] = r ? { ok: !!r.ok, ms: r.ttfb_ms > 0 ? r.ttfb_ms : r.handshake_ms, why: r.why } : { ok: false, why: (res && res.error) || '' };
+ }).catch(function() { lat[i] = { ok: false }; }).then(function() { probeDone++; renderSub(); });
+ }
+ function nextChunk() {
+ if (!chunks.length) return Promise.resolve();
+ var part = chunks.shift();
+ return zm.steerAction('sub_tcp', part.join(',')).then(function(res) {
+ var rs = (res && res.results) || {};
+ part.forEach(function(i) {
+ var r = rs[i];
+ if (r && r.core) { core.push(i); return; }
+ lat[i] = r && r.ok ? { ok: true, ms: r.ms } : { ok: false, why: res && res.error ? res.error : 'нет TCP-соединения с сервером' };
+ probeDone++;
+ });
+ }).catch(function() { part.forEach(function(i) { lat[i] = { ok: false }; probeDone++; }); }).then(function() {
renderSub();
- return next();
+ return nextChunk();
});
}
- Promise.all([ next(), next(), next() ]).then(function() {
+ function nextCore() { return core.length ? one(core.shift()).then(nextCore) : Promise.resolve(); }
+ Promise.all([ nextChunk(), nextChunk() ]).then(function() { return Promise.all([ nextCore(), nextCore() ]); }).then(function() {
probing = false;
var ok = Object.keys(lat).filter(function(k) { return lat[k].ok; }).length;
zm.toast('Проверка закончена: отвечают ' + ok + ' из ' + probeTotal, ok ? 'info' : 'warning');
@@ -21586,6 +21987,10 @@ html.zm-theme-dark .zm-node:not(.zm-active) { background: #22272e; border-color:
.zm-node.zm-sec .zm-node-name::before { content: none; }
.zm-tabsplit { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 40px; margin: 4px 0 0; }
.zm-tabsplit > .zm-tabgrp { margin: 0; }
+.zm-tabsplit.zm-tabs-one > .zm-tabgrp { flex: 1 1 100%; width: 100%; max-width: 100%; display: flex; }
+.zm-tabsplit.zm-tabs-one > .zm-tabgrp > .cbi-button { flex: 1 1 0; justify-content: center; text-align: center; }
+#zmw-view .zm-tabsplit.zm-tabs-one > .zm-actions.zmw-tabs { width: 100%; }
+#zmw-view .zm-tabsplit.zm-tabs-one .zmw-tabs .cbi-button { flex: 1 0 auto; }
.zm-sec-dim .zm-nodes { opacity: .45; filter: grayscale(.6); transition: opacity .15s, filter .15s; }
.zm-sec-dim .zm-nodes:hover { opacity: .85; filter: none; }
.zm-node.zm-sec-add { border-style: dashed; display: flex; align-items: center; justify-content: center; text-align: center; min-height: 64px; box-sizing: border-box; }
@@ -21809,6 +22214,17 @@ html.zm-theme-dark .zm-stopbar { background: #2a1416; color: #fecaca; border-col
.zm-route-v { min-width: 0; overflow-wrap: anywhere; }
.zm-route-note { margin: 8px 0 0; }
.zm-route-raw { margin-top: 8px; font-size: 12px; }
+.zm-fold { margin-top: 14px; border-top: 1px solid rgba(0,0,0,.08); padding-top: 10px; }
+html.zm-theme-dark .zm-fold { border-top-color: rgba(255,255,255,.1); }
+.zm-fold-head { display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600; font-size: 13px; user-select: none; flex-wrap: wrap; }
+.zm-fold-head:hover { opacity: .85; }
+.zm-fold-open > .zm-fold-head .zm-svc-chev { transform: rotate(90deg); }
+.zm-fold-sum { font-weight: 400; font-size: 12px; opacity: .6; overflow-wrap: anywhere; }
+.zm-fold-body { padding: 4px 0 0 22px; }
+.zm-fold-body h4 { margin: 16px 0 4px; }
+.zm-fold-body h4:first-child { margin-top: 8px; }
+@media (max-width: 600px) { .zm-fold-body { padding-left: 0; } }
+.zm-fk-traffic { font-variant-numeric: tabular-nums; }
.zm-route-raw summary { cursor: pointer; opacity: .75; }
.zm-route-raw pre { margin: 6px 0 0; max-height: 220px; white-space: pre-wrap; overflow-wrap: anywhere; }
@media (max-width: 600px) { .zm-route-line { flex-direction: column; gap: 0; } .zm-route-k { width: auto; } }
@@ -22149,7 +22565,7 @@ return view.extend({
{ product: 'tg-ws-proxy-go (SOCKS5)', author: 'd0mhate', url: 'https://github.com/d0mhate/-tg-ws-proxy-Manager-go' },
{ product: 'tg-ws-proxy-rs (Rust)', author: 'valnesfjord', url: 'https://github.com/valnesfjord/tg-ws-proxy-rs' },
{ product: 'Mixomo, GeoHideDNS', author: 'Internet-Helper', url: 'https://github.com/Internet-Helper' },
- { product: 'Podkop, allow-domains', author: 'itdoginfo', url: 'https://github.com/itdoginfo' },
+ { product: 'allow-domains', author: 'itdoginfo', url: 'https://github.com/itdoginfo' },
{ product: 'NetShift (основа Forkozz)', author: 'yandexru45', url: 'https://github.com/yandexru45/netshift' },
{ product: 'sing-box', author: 'SagerNet', url: 'https://github.com/SagerNet/sing-box' },
{ product: 'sing-box-extended', author: 'shtorm-7', url: 'https://github.com/shtorm-7/sing-box-extended' },
@@ -22157,9 +22573,7 @@ return view.extend({
{ product: 'b4geoip (списки игр и сервисов)', author: 'DanielLavrushin', url: 'https://github.com/DanielLavrushin/b4geoip' },
{ product: 'b4geoip-forkop (сборка для sing-box)', author: 'Greeg0ry', url: 'https://github.com/Greeg0ry/b4geoip-forkop' },
{ product: 'domain-list-community', author: 'v2fly', url: 'https://github.com/v2fly/domain-list-community' },
- { product: 'supercell-ruleset', author: 'ushan0v', url: 'https://github.com/ushan0v/sing-box-supercell-ruleset' },
{ product: 'dpi-checkers', author: 'hyperion-cs', url: 'https://github.com/hyperion-cs/dpi-checkers' },
- { product: 'awg-openwrt (AmneziaWG)', author: 'Slava-Shchipunov', url: 'https://github.com/Slava-Shchipunov/awg-openwrt' },
{ product: 'awg-openwrt (сборки)', author: '2Grey', url: 'https://github.com/2Grey/awg-openwrt' },
{ product: 'warpscout (разведка WARP)', author: 'vernette', url: 'https://github.com/vernette/warpscout' },
{ product: 'base-relay (реле регистрации WARP)', author: 'nellimonix', url: 'https://github.com/nellimonix/base-relay' },
@@ -22698,6 +23112,42 @@ var TABS = [ { id: 'conn', label: 'Подключение' }, { id: 'svc', label
function isSecTab(id) { return id === 'conn' || id === 'svc'; }
var SEC_MAX = 9;
var MODES = [ { id: 'links', label: 'Серверы' }, { id: 'sub', label: 'Подписка' }, { id: 'iface', label: 'Туннель' } ];
+var SUB_FMT = [ { id: 'auto', label: 'Авто' }, { id: 'xray', label: 'Xray JSON (Happ)' }, { id: 'singbox', label: 'sing-box' } ];
+var SUB_GROUP = [ { id: 'off', label: 'Нет' }, { id: 'country', label: 'По флагу страны' }, { id: 'prefix', label: 'По началу имени' } ];
+var SUB_MAX = 10;
+var UT_IV = [ { id: '30s', label: '30 с' }, { id: '1m', label: '1 мин' }, { id: '3m', label: '3 мин' }, { id: '5m', label: '5 мин' } ];
+var UT_URL_DEF = 'https://www.gstatic.com/generate_204';
+var UT_URLS = [ { id: UT_URL_DEF, label: 'Google' }, { id: 'https://cp.cloudflare.com/generate_204', label: 'Cloudflare' }, { id: 'https://captive.apple.com', label: 'Apple' } ];
+var LOG_LV = [ { id: 'panic', label: 'Выключен' }, { id: 'error', label: 'Только ошибки' }, { id: 'warn', label: 'Обычный' }, { id: 'info', label: 'Подробный' }, { id: 'debug', label: 'Отладка' } ];
+var DRES_DEF = { udp: '1.1.1.1', dot: 'one.one.one.one', doh: 'https://cloudflare-dns.com/dns-query' };
+var SEC_EXT = { sub_fmt: 'auto', sub_group: 'off', sub_plen: '2', sub_incl: '', sub_insecure: false, sub_ua: '', ut_iv: '3m', ut_tol: '50', ut_url: UT_URL_DEF,
+ uot: false, mixed: false, mixed_port: '2080', real_ip: false, dres_on: false, dres_type: 'udp', dres_server: '' };
+var SET_EXT = { dns_ttl: '60', dns_ecs: '', src_ifs: [ 'br-lan' ], out_if: '', badwan: false, badwan_ifs: [], badwan_delay: '2000', ipv6: false, no_dhcp: false,
+ excl_ntp: false, excl_bt: false, block_doh: false, log_level: 'panic', yacd: false, yacd_wan: false, yacd_secret: '' };
+
+/* Дополнительные опции секции (SEC_EXT) и общие (SET_EXT) живут в черновике плоско, рядом с остальными. */
+function extFrom(def, src, into) {
+ src = src || {};
+ Object.keys(def).forEach(function(k) {
+ var v = src[k] != null ? src[k] : def[k];
+ into[k] = Array.isArray(def[k]) ? (Array.isArray(v) ? v.slice() : []) : typeof def[k] === 'boolean' ? !!v : String(v);
+ });
+ return into;
+}
+
+function inclWords(v) {
+ return String(v || '').split(/[|\n]/).map(function(w) { return w.trim().toLowerCase(); }).filter(Boolean);
+}
+
+function extVal(v) { return Array.isArray(v) ? v.slice().sort().join(',') : typeof v === 'string' ? v.trim() : v; }
+
+function extSig(d, def) { return Object.keys(def).map(function(k) { return extVal(d[k]); }); }
+
+function extPick(d, def) {
+ var o = {};
+ Object.keys(def).forEach(function(k) { o[k] = Array.isArray(d[k]) ? d[k].slice() : typeof d[k] === 'string' ? d[k].trim() : d[k]; });
+ return o;
+}
var SB_VARS = [
{ id: 'tiny', label: 'Облегчённый', hint: 'Пакет sing-box-tiny из OpenWrt: меньше памяти, все протоколы Forkozz, кроме XHTTP и VMess — для них Forkozz сам поставит расширенный. Подходит почти всем.', warn: '' },
{ id: 'stable', label: 'Обычный', hint: 'Полный sing-box из репозитория OpenWrt. Нужно больше места.', warn: '' },
@@ -22759,7 +23209,7 @@ function latClass(ms) {
}
function latText(ms) {
- return ms > 0 ? ms + ' мс' : ms === 0 ? 'нет ответа' : '';
+ return ms > 0 ? ms + ' мс' : ms === 0 ? 'нет ответа' : ms === -2 ? '…' : '';
}
function flag(cc) {
@@ -22798,10 +23248,10 @@ function pageLink(id) {
function fromCfg(c) {
c = c || {};
var dns = c.dns || {}, pick = pickFromCfg(c);
- return {
+ var d = {
sec: c.sec || 'main', isMain: c.is_main !== false, label: c.label || '', enabled: c.enabled !== false,
byDomains: ((c.bypass && c.bypass.domains) || []).join('\n'), bySubnets: ((c.bypass && c.bypass.subnets) || []).join('\n'),
- mode: c.mode || 'links', links: (c.links || []).join('\n'), sub: c.sub || '',
+ mode: c.mode || 'links', links: (c.links || []).join('\n'), sub: (c.subs && c.subs.length ? c.subs : c.sub ? [ c.sub ] : []).join('\n'),
sub_interval: c.sub_interval || '12h', iface: c.iface || '', fastest: true, exclude: c.exclude || '',
items: pick.items, sel: pick.sel, domains: (c.domains || []).join('\n'), subnets: (c.subnets || []).join('\n'),
lists: pick.lists.join('\n'), full: (c.full || []).slice(), excl: (c.excl || []).slice(),
@@ -22809,6 +23259,9 @@ function fromCfg(c) {
bootstraps: (dns.bootstraps && dns.bootstraps.length ? dns.bootstraps : [ dns.bootstrap || '9.9.9.9' ]).slice(), detour: !!dns.detour },
quic_off: c.quic_off !== false, list_interval: c.list_interval || '1d', lists_via: !!c.lists_via
};
+ extFrom(SEC_EXT, c.x, d);
+ extFrom(SET_EXT, c.g, d);
+ return d;
}
function withBootMode(d) {
@@ -22824,12 +23277,12 @@ function validCfg(x) {
function sig(d) {
var srt = function(a) { return (a || []).slice().sort(); };
var tl = function(v) { return String(v || '').split(/\n/).map(function(x) { return x.trim(); }).filter(Boolean).join('\n'); };
- return JSON.stringify([ d.sec, d.mode, tl(d.links), d.sub.trim(), d.sub_interval, d.iface, d.fastest, (function(r) { return [ srt(r.c), srt(r.s), srt(r.r), srt(r.l) ]; })(refsOf(d)), tl(d.domains),
+ return JSON.stringify([ extSig(d, SEC_EXT), extSig(d, SET_EXT), d.sec, d.mode, tl(d.links), tl(d.sub), d.sub_interval, d.iface, d.fastest, (function(r) { return [ srt(r.c), srt(r.s), srt(r.r), srt(r.l) ]; })(refsOf(d)), tl(d.domains),
tl(d.subnets), tl(d.lists), srt(d.full), srt(d.excl), d.mode === 'iface' ? '' : String(d.exclude || ''), d.dns.type, d.dns.servers, d.dns.bootstraps, d.dns.detour, d.quic_off, d.list_interval, d.lists_via,
String(d.label || '').trim(), d.isMain || d.enabled, tl(d.byDomains), tl(d.bySubnets) ]);
}
-var GLOBAL_KEYS = [ 'byDomains', 'bySubnets', 'full', 'excl', 'dns', 'quic_off', 'list_interval', 'lists_via' ];
+var GLOBAL_KEYS = [ 'byDomains', 'bySubnets', 'full', 'excl', 'dns', 'quic_off', 'list_interval', 'lists_via' ].concat(Object.keys(SET_EXT));
function secSig(d) {
var x = {}, k;
@@ -22877,7 +23330,7 @@ function newCfg(c) {
var n = ((c && c.sections) || []).length + 1;
x.sec = 'new'; x.is_main = false; x.exists = false; x.enabled = true; x.label = warp && /^zmwarp/.test(warp.name) && !((c && c.sections) || []).some(function(s) { return s.label === 'WARP'; }) ? 'WARP' : 'Секция ' + n;
x.mode = 'iface'; x.iface = warp ? warp.name : ''; x.links = []; x.sub = ''; x.sub_interval = '12h'; x.exclude = ''; x.hide_names = [];
- x.refs = { c: [], s: [], r: [] }; x.domains = []; x.subnets = []; x.lists = [];
+ x.refs = { c: [], s: [], r: [] }; x.domains = []; x.subnets = []; x.lists = []; x.subs = []; x.x = {};
return x;
}
@@ -22928,6 +23381,10 @@ return view.extend({
var dnsCard = E('div', { 'class': 'zm-card zm-kv' });
var miscCard = E('div', { 'class': 'zm-card zm-kv' });
var sbCard = E('div', { 'class': 'zm-card zm-kv' });
+ var netCard = E('div', { 'class': 'zm-card zm-kv' });
+ var panelCard = E('div', { 'class': 'zm-card zm-kv' });
+ var openFold = {}, ownUtUrl = false;
+ var trafficEl = E('span', { 'class': 'zm-fk-traffic' }, '—'), trafficLast = null, trafficTimer = null;
function area(key, placeholder, minh) {
var ta = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false', 'autocapitalize': 'off', 'placeholder': placeholder, 'style': 'min-height:' + (minh || 120) + 'px' });
@@ -22941,7 +23398,7 @@ return view.extend({
empty: 'Серверов пока нет — вставьте ссылки ниже и нажмите «Добавить».',
busy: function() { return busy || saving; },
onChange: function(v) { draft.links = v; taLinks.value = v; touch(); renderLinkCount(); } });
- var taSub = area('sub', 'https://…/sub/…', 60);
+ var taSub = area('sub', 'https://…/sub/…\nhttps://… — вторая подписка, если есть', 60);
taSub.classList.add('zm-sub-input');
var taDomains = area('domains', 'chatgpt.com\nopenai.com\nspotify.com', 140);
var taSubnets = area('subnets', '91.108.4.0/22\n149.154.160.0/20', 90);
@@ -23079,7 +23536,8 @@ return view.extend({
return JSON.stringify({
sec: draft.sec, label: String(draft.label || '').trim(), enabled: draft.isMain || draft.enabled,
bypass: { domains: draft.byDomains, subnets: draft.bySubnets },
- mode: draft.mode, links: draft.links, sub: draft.sub.trim(), sub_interval: draft.sub_interval,
+ mode: draft.mode, links: draft.links, sub: textLines(draft.sub)[0] || '', subs: textLines(draft.sub), sub_interval: draft.sub_interval,
+ x: extPick(draft, SEC_EXT), g: extPick(draft, SET_EXT),
iface: draft.iface, fastest: true, refs: refsOf(draft), domains: draft.domains,
exclude: draft.mode === 'iface' ? '' : draft.exclude,
subnets: draft.subnets, lists: draft.lists, full: draft.full, excl: draft.excl,
@@ -23090,7 +23548,27 @@ return view.extend({
function check() {
if (draft.mode === 'links' && !linksEd.flush()) return [ 'conn', 'В поле «Добавить» есть строки, которые не похожи на ссылки — исправьте или очистите поле' ];
if (draft.mode === 'links' && !textLines(draft.links).length) return [ 'conn', 'Вставьте хотя бы одну ссылку на сервер' ];
- if (draft.mode === 'sub' && !/^https?:\/\//i.test(draft.sub.trim())) return [ 'conn', 'Вставьте ссылку на подписку' ];
+ if (draft.mode === 'sub') {
+ var sl = textLines(draft.sub);
+ if (!sl.length) return [ 'conn', 'Вставьте ссылку на подписку' ];
+ if (sl.length > SUB_MAX) return [ 'conn', 'Подписок в одной секции — не больше ' + SUB_MAX ];
+ var bs = sl.filter(function(u) { return !/^https?:\/\/[^\s\/]+(\/\S*)?$/i.test(u); })[0];
+ if (bs) return [ 'conn', 'Ссылка на подписку должна начинаться с https:// — ошибка в «' + bs.slice(0, 50) + '»' ];
+ var inc = inclWords(draft.sub_incl);
+ var names = inc.length && !(cfg && cfg.x && cfg.x.sub_incl) && secExists() && cfg.mode === 'sub' && sl.join('\n') === (cfg.subs || []).join('\n') ? exclNames() : [];
+ if (names.length && !names.some(function(n) { n = n.toLowerCase(); return inc.some(function(w) { return n.indexOf(w) >= 0; }); }))
+ return [ 'conn', 'Под отбор «Только серверы со словами» не попал ни один сервер подписки — проверьте слова' ];
+ if (draft.sub_group === 'prefix' && !/^([1-9]|1[0-6])$/.test(draft.sub_plen.trim())) return [ 'conn', 'Длина начала имени для групп — число от 1 до 16' ];
+ }
+ if (draft.mode !== 'iface') {
+ if (!/^\d{1,5}$/.test(draft.ut_tol.trim()) || +draft.ut_tol > 10000) return [ 'conn', 'Допуск автовыбора — число миллисекунд от 0 до 10000' ];
+ if (!/^https?:\/\/[^\s\/]+(\/\S*)?$/i.test(draft.ut_url.trim() || UT_URL_DEF)) return [ 'conn', 'Адрес проверки серверов должен быть ссылкой http(s)://…' ];
+ }
+ if (draft.mode === 'iface' && draft.dres_on && !DNS_RE[draft.dres_type].test(draft.dres_server.trim())) return [ 'conn', 'DNS туннеля: ' + DNS_HELP[draft.dres_type].hint ];
+ if (draft.mixed) {
+ var mp = +draft.mixed_port.trim();
+ if (!/^\d{4,5}$/.test(draft.mixed_port.trim()) || mp < 1024 || mp > 65535) return [ 'conn', 'Порт локального прокси — число от 1024 до 65535' ];
+ }
if (draft.mode !== 'iface' && zm.exclParse(draft.exclude).length) {
var xn = exclNames(), xl = zm.exclParse(draft.exclude);
if (xn.length && xn.every(function(n) { return zm.exclHit(n, xl); })) return [ 'conn', 'Под маркеры фильтра попадают все серверы — уберите лишние маркеры' ];
@@ -23115,6 +23593,14 @@ return view.extend({
if (!draft.dns.servers.length) return [ 'set', 'Выберите хотя бы один DNS-сервер' ];
var badDns = draft.dns.servers.filter(function(v) { return !DNS_RE[draft.dns.type].test(v); })[0];
if (badDns) return [ 'set', 'DNS «' + badDns + '» не подходит для ' + DNS_TYPES[draft.dns.type] + '. ' + DNS_HELP[draft.dns.type].hint ];
+ if (!/^\d{1,5}$/.test(draft.dns_ttl.trim()) || +draft.dns_ttl > 86400) return [ 'set', 'Время жизни DNS-записей — число секунд от 0 до 86400' ];
+ var ecs = draft.dns_ecs.trim();
+ if (ecs && !/^\d{1,3}(\.\d{1,3}){3}(\/\d{1,2})?$/.test(ecs) && !/^[0-9a-f:]*:[0-9a-f:]*(\/\d{1,3})?$/i.test(ecs)) return [ 'set', 'EDNS Client Subnet — IP-адрес или подсеть, например 203.0.113.0/24' ];
+ if (!draft.src_ifs.length) return [ 'set', 'Выберите хотя бы одну сеть, из которой Forkozz принимает трафик' ];
+ if (draft.out_if && draft.src_ifs.indexOf(draft.out_if) >= 0) return [ 'set', 'Интерфейс ' + draft.out_if + ' не может быть и источником, и выходом' ];
+ if (draft.badwan && !draft.badwan_ifs.length) return [ 'set', 'Выберите, за какими подключениями к интернету следить' ];
+ if (draft.badwan && (!/^\d{1,6}$/.test(draft.badwan_delay.trim()) || +draft.badwan_delay > 600000)) return [ 'set', 'Задержка перезапуска — число миллисекунд, например 2000' ];
+ if (draft.yacd && draft.yacd_wan && !/^[A-Za-z0-9._~+=-]{8,64}$/.test(draft.yacd_secret.trim())) return [ 'set', 'Для доступа к панели из интернета задайте ключ: 8–64 латинских букв, цифр или ._-~+=' ];
return null;
}
@@ -23381,6 +23867,32 @@ return view.extend({
]);
}
+ function rate(b) { return fmtBytes(Math.max(0, Math.round(b))) + '/с'; }
+
+ /* Трафик через sing-box раз в 3 с, пока страница открыта и Forkozz работает. Скорость — по разнице счётчиков. */
+ function trafficTick() {
+ trafficTimer = null;
+ if (!document.body.contains(trafficEl) || !st.running) { trafficLast = null; return; }
+ if (document.hidden || busy) { trafficTimer = setTimeout(trafficTick, 3000); return; }
+ zm.forkopAction('stats', '').then(function(r) {
+ if (r && r.ok) {
+ var now = Date.now(), parts = [];
+ if (trafficLast && r.down >= trafficLast.down && r.up >= trafficLast.up) {
+ var dt = Math.max(1, (now - trafficLast.t) / 1000);
+ parts.push('↓ ' + rate((r.down - trafficLast.down) / dt), '↑ ' + rate((r.up - trafficLast.up) / dt));
+ }
+ trafficLast = { t: now, down: r.down, up: r.up };
+ parts.push('всего ' + fmtBytes(r.down + r.up), nn(r.conns, 'соединение', 'соединения', 'соединений'));
+ if (r.mem > 0) parts.push('sing-box ' + fmtBytes(r.mem));
+ trafficEl.textContent = parts.join(' · ');
+ } else trafficEl.textContent = 'нет данных — sing-box ещё запускается';
+ }).catch(function() {}).then(function() {
+ if (!trafficTimer && document.body.contains(trafficEl)) trafficTimer = setTimeout(trafficTick, 3000);
+ });
+ }
+
+ function trafficStart() { if (!trafficTimer) trafficTimer = setTimeout(trafficTick, trafficLast ? 3000 : 300); }
+
function renderMain() {
hostsWarn.set(!!(st && st.installed && st.hosts_extra));
mainCard.innerHTML = '';
@@ -23401,6 +23913,7 @@ return view.extend({
if (st.installed && configured() && secs().length) {
mainCard.appendChild(E('div', { 'class': 'zm-st-stats zm-fk-secs' }, secs().map(secBox).concat([ commonBox() ])));
+ if (st.running && !busy) { mainCard.appendChild(row('Трафик', trafficEl)); trafficStart(); }
} else if (st.installed) {
var sl = secs(), cv, rv = routeValue(cfg);
if (sl.length > 1) cv = [ nn(sl.length, 'секция', 'секции', 'секций'), sl.map(function(s) { return s.label + (s.enabled ? '' : ' (выкл.)'); }).join(' · ') ];
@@ -23466,8 +23979,11 @@ return view.extend({
tabBar.innerHTML = '';
var show = !!st.installed;
tabBar.style.display = show ? '' : 'none';
- [ false, true ].forEach(function(common) {
- tabBar.appendChild(E('div', { 'class': 'zm-actions zmw-tabs zm-tabgrp' }, TABS.filter(function(t) { return !!t.common === common; }).map(function(t) {
+ /* одна секция — делить вкладки на «секцию» и «общие» незачем: одна полоса во всю ширину */
+ var one = secs().length <= 1 && draft.sec !== 'new';
+ tabBar.className = 'zm-tabsplit' + (one ? ' zm-tabs-one' : '');
+ (one ? [ null ] : [ false, true ]).forEach(function(common) {
+ tabBar.appendChild(E('div', { 'class': 'zm-actions zmw-tabs zm-tabgrp' }, TABS.filter(function(t) { return common === null || !!t.common === common; }).map(function(t) {
return E('button', { 'class': 'cbi-button' + (t.id === tab ? ' cbi-button-positive' : ''), 'click': function() { tabTo(t.id); } }, t.label);
})));
});
@@ -23519,6 +24035,112 @@ return view.extend({
]);
}
+ /* Сворачиваемый блок «Дополнительно»: заголовок со сводкой включённого, содержимое по клику. */
+ function fold(id, title, summary, build) {
+ var open = !!openFold[id], box = E('div', { 'class': 'zm-fold' + (open ? ' zm-fold-open' : '') });
+ box.appendChild(E('div', { 'class': 'zm-fold-head', 'click': function() { openFold[id] = !openFold[id]; renderPanes(); } }, [
+ E('span', { 'class': 'zm-svc-chev' }, '›'), E('span', {}, title), summary ? E('span', { 'class': 'zm-fold-sum' }, summary) : E([])
+ ]));
+ if (open) { var body = E('div', { 'class': 'zm-fold-body' }); build(body); box.appendChild(body); }
+ return box;
+ }
+
+ function inp(key, ph, width, tail) {
+ var el = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'autocapitalize': 'off', 'spellcheck': 'false', 'placeholder': ph || '', 'style': 'width:' + (width || 260) + 'px; max-width:100%' });
+ el.value = draft[key];
+ el.addEventListener('input', function() { draft[key] = el.value; touch(); });
+ return tail ? E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap' }, [ el, E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, tail) ]) : el;
+ }
+
+ function hint(text, top) { return E('p', { 'class': 'zm-hint', 'style': 'margin-top:' + (top == null ? 2 : top) + 'px' }, text); }
+
+ function netInfo() { return (cfg && cfg.net) || { lan_ip: '', nets: [], devs: [] }; }
+
+ function lanIp() { return netInfo().lan_ip || 'IP-роутера'; }
+
+ function devFoot(d) {
+ var k = { bridge: 'мост', eth: 'порт', ppp: 'PPP', tun: 'туннель', wifi: 'Wi-Fi' }[d.kind] || '';
+ return (d.net ? 'сеть ' + d.net + (k ? ' · ' + k : '') : k) + (d.missing ? ' · не найден' : '');
+ }
+
+ function devTiles(list, isOn, click) {
+ return E('div', { 'class': 'zm-nodes zm-nodes-3' }, list.map(function(d) {
+ var on = isOn(d.name), up = d.up && !d.missing;
+ return node(d.name, devFoot(d), on, function() { click(d.name, on); }, d.kind ? E('span', { 'class': 'zm-lat ' + (up ? 'zm-lat-good' : 'zm-lat-bad') }, up ? 'активен' : 'выключен') : E('span'));
+ }));
+ }
+
+ function withMissing(list, names) {
+ var have = {};
+ list.forEach(function(d) { have[d.name] = true; });
+ return list.concat(names.filter(function(n) { return n && !have[n]; }).map(function(n) { return { name: n, kind: '', up: false, missing: true, net: '' }; }));
+ }
+
+ function connExtSummary() {
+ var p = [];
+ if (draft.mode === 'sub') {
+ if (textLines(draft.sub).length > 1) p.push('подписок: ' + textLines(draft.sub).length);
+ if (draft.sub_fmt !== 'auto') p.push(draft.sub_fmt === 'xray' ? 'формат Happ' : 'формат sing-box');
+ if (draft.sub_group !== 'off') p.push(draft.sub_group === 'country' ? 'группы по странам' : 'группы по имени');
+ if (inclWords(draft.sub_incl).length) p.push('отбор по словам');
+ if (draft.sub_insecure) p.push('без проверки сертификата');
+ }
+ if (draft.mode !== 'iface') {
+ if (draft.ut_iv !== '3m' || draft.ut_tol.trim() !== '50' || (draft.ut_url.trim() || UT_URL_DEF) !== UT_URL_DEF) p.push('свой автовыбор');
+ if (draft.uot) p.push('UDP поверх TCP');
+ } else if (draft.dres_on) p.push('DNS ' + dnsName(draft.dres_type, draft.dres_server.trim()));
+ if (draft.mixed) p.push('прокси :' + draft.mixed_port.trim());
+ if (draft.real_ip) p.push('настоящий IP');
+ return p.join(' · ');
+ }
+
+ function renderConnExt(box) {
+ var many = secs().length > 1 || draft.sec === 'new';
+ if (draft.mode === 'sub') {
+ box.appendChild(E('h4', {}, 'Подписка'));
+ box.appendChild(row('Формат', seg(SUB_FMT, draft.sub_fmt, function(v) { set('sub_fmt', v); })));
+ box.appendChild(hint('Какой формат просить у сервиса первым. «Авто» подходит почти всем. Xray JSON (Happ) — если часть серверов (например, с XHTTP) сервис отдаёт только приложению Happ; sing-box — если сервис умеет отдавать его напрямую.'));
+ box.appendChild(row('Группы', seg(SUB_GROUP, draft.sub_group, function(v) { set('sub_group', v); })));
+ if (draft.sub_group === 'prefix') box.appendChild(row('Символов', inp('sub_plen', '2', 70, 'первые символы имени, по которым серверы собираются в группу')));
+ box.appendChild(hint('Серверы собираются в группы, и в каждой работает автовыбор лучшего. Удобно, когда в подписке десятки серверов: в карточке «Серверы» выбираете страну, а не отдельный сервер.'));
+ box.appendChild(row('Только со словами', inp('sub_incl', 'Германия | NL | 🇫🇮', 340)));
+ box.appendChild(hint('Из подписки останутся только серверы, в имени которых есть хоть одно из слов (через |, регистр не важен). Убрать отдельные серверы удобнее маркерами в карточке «Серверы».'));
+ box.appendChild(row('User-Agent', inp('sub_ua', 'подбирается сам', 340)));
+ box.appendChild(hint('Нужен редко: если сервис отдаёт подписку только определённому приложению, например Happ/2.0.'));
+ box.appendChild(sw(draft.sub_insecure, 'Не проверять сертификат подписки', 'Только для панелей по IP-адресу с самоподписанным сертификатом. Небезопасно: подписку могут подменить по дороге.', function() { set('sub_insecure', !draft.sub_insecure); }));
+ }
+ if (draft.mode !== 'iface') {
+ box.appendChild(E('h4', {}, 'Автовыбор сервера'));
+ box.appendChild(hint('Работает, когда серверов несколько и в карточке «Серверы» выбран автовыбор: Forkozz сам проверяет серверы и держит самый быстрый.', 0));
+ box.appendChild(row('Проверять', seg(UT_IV, draft.ut_iv, function(v) { set('ut_iv', v); })));
+ box.appendChild(row('Допуск', inp('ut_tol', '50', 80, 'мс — на сервер быстрее меньше чем на столько Forkozz не переходит')));
+ var url = draft.ut_url.trim() || UT_URL_DEF, preset = UT_URLS.some(function(u) { return u.id === url; }) && !ownUtUrl;
+ box.appendChild(row('Проверка через', seg(UT_URLS.concat([ { id: 'own', label: 'Свой адрес' } ]), preset ? url : 'own', function(v) {
+ ownUtUrl = v === 'own';
+ if (!ownUtUrl) set('ut_url', v); else renderPanes();
+ })));
+ if (!preset) box.appendChild(row('Адрес', inp('ut_url', 'https://…/generate_204', 340)));
+ box.appendChild(sw(draft.uot, 'UDP поверх TCP', 'Для серверов Shadowsocks и SOCKS: UDP (звонки, игры, QUIC) пойдёт внутри TCP. Включайте, если сервер не пропускает UDP.', function() { set('uot', !draft.uot); }));
+ } else {
+ box.appendChild(E('h4', {}, 'DNS туннеля'));
+ box.appendChild(sw(draft.dres_on, 'Свой DNS для сайтов секции', 'Адреса сайтов этой секции будут искаться через сам туннель указанным DNS — ответ совпадёт со страной туннеля, а провайдер его не увидит.', function() {
+ if (!draft.dres_on && !DNS_RE[draft.dres_type].test(draft.dres_server.trim())) draft.dres_server = DRES_DEF[draft.dres_type];
+ set('dres_on', !draft.dres_on);
+ }));
+ if (draft.dres_on) {
+ box.appendChild(row('Протокол', seg([ { id: 'doh', label: 'DoH' }, { id: 'dot', label: 'DoT' }, { id: 'udp', label: 'UDP' } ], draft.dres_type, function(v) {
+ if (!DNS_RE[v].test(draft.dres_server.trim())) draft.dres_server = DRES_DEF[v];
+ set('dres_type', v);
+ })));
+ box.appendChild(row('Сервер', inp('dres_server', DNS_HELP[draft.dres_type].ph, 320)));
+ }
+ }
+ box.appendChild(E('h4', {}, 'Для программ и роутера'));
+ box.appendChild(sw(draft.mixed, 'Локальный прокси HTTP/SOCKS5', 'Программы в сети (браузер, Telegram, торрент-клиент) смогут ходить через подключение ' + (many ? 'секции «' + secName() + '»' : 'Forkozz') + ' целиком, если указать им прокси ' + lanIp() + ':' + (draft.mixed_port.trim() || '2080') + '. Работает на всех устройствах домашней сети, без логина.', function() { set('mixed', !draft.mixed); }));
+ if (draft.mixed) box.appendChild(row('Порт', inp('mixed_port', '2080', 90, many ? 'у каждой секции — свой' : '')));
+ box.appendChild(sw(draft.real_ip, 'Узнавать настоящий IP сайтов', 'Forkozz сам найдёт IP-адрес сайта перед выбором маршрута — тогда правила по подсетям срабатывают и для запросов по домену. Нужно редко.', function() { set('real_ip', !draft.real_ip); }));
+ }
+
function renderConn() {
connCard.innerHTML = '';
@@ -23543,7 +24165,7 @@ return view.extend({
}
else if (draft.mode === 'sub') {
connCard.appendChild(taSub);
- connCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Ссылка на подписку от VPN-сервиса, обычно https://…/sub/…. Выбор сервера — в карточке «Серверы» ниже.'));
+ connCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Ссылка на подписку от VPN-сервиса, обычно https://…/sub/…. Можно несколько — по одной на строку, серверы всех подписок соберутся в один список. Выбор сервера — в карточке «Серверы» ниже.'));
connCard.appendChild(row('Обновлять серверы', seg(SUB_IV, draft.sub_interval, function(v) { set('sub_interval', v); })));
}
else {
@@ -23558,14 +24180,18 @@ return view.extend({
else connCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Туннелей WireGuard или AmneziaWG пока нет.'));
connCard.appendChild(E('p', { 'class': 'zm-hint' }, [ 'Создать туннель WARP или свой можно на вкладке ', E('a', { 'href': pageLink('awg') }, 'AmneziaWG'), '.' ]));
}
+ connCard.appendChild(fold('conn', 'Дополнительно', connExtSummary(), renderConnExt));
if (!draft.isMain && secExists()) connCard.appendChild(E('div', { 'class': 'zm-actions', 'style': 'margin-top:14px' }, [
E('button', { 'class': 'cbi-button cbi-button-remove', 'disabled': busy || saving ? '' : null, 'click': deleteSec }, 'Удалить секцию')
]));
}
var srvRetry = 0;
+ var srvAgain = false;
function loadServers() {
- if (srvBusy || !secLive()) return;
+ if (!secLive()) return;
+ if (srvBusy) { srvAgain = true; return; }
+ srvAgain = false;
srvBusy = true;
renderServers();
var forSec = cfg && cfg.sec;
@@ -23578,7 +24204,8 @@ return view.extend({
} else { servers = res; srvErr = ''; srvRetry = 0; }
renderServers();
renderMain();
- }).catch(function() { srvBusy = false; srvErr = 'роутер не ответил'; renderServers(); });
+ if (srvAgain) loadServers();
+ }).catch(function() { srvBusy = false; srvErr = 'роутер не ответил'; renderServers(); if (srvAgain) loadServers(); });
}
/* Замер идёт на роутере в фоне (подписка в десятки узлов не укладывается в один запрос):
@@ -23587,9 +24214,14 @@ return view.extend({
if (latBusy) return;
var forSec = (cfg && cfg.sec) || '', tries = 0;
latBusy = true;
+ if (servers && servers.nodes) Object.keys(servers.nodes).forEach(function(t) { servers.nodes[t].delay = -2; });
renderServers();
- zm.toast('Проверяем задержку…', 'warning');
function done(text, kind) { latBusy = false; renderServers(); renderMain(); if (text) zm.toast(text, kind); }
+ function score(r) {
+ var ns = (r && r.nodes) || {}, ok = 0, all = 0;
+ Object.keys(ns).forEach(function(t) { if (!ns[t].members) { all++; if (ns[t].delay > 0) ok++; } });
+ return 'отвечают ' + ok + ' из ' + all;
+ }
function poll() {
setTimeout(function() {
tries++;
@@ -23597,11 +24229,11 @@ return view.extend({
if (!cfg || ((cfg.sec || '') !== forSec)) { latBusy = false; return; }
if (r && !r.error) { servers = r; srvErr = ''; }
var running = !!(r && r.lat && r.lat.running);
- if (running && tries < 40) { renderServers(); poll(); return; }
+ if (running && tries < 90) { renderServers(); poll(); return; }
var le = r && r.lat && r.lat.error;
- done(running ? 'Проверка идёт дольше обычного — задержки появятся сами' : le ? 'Задержка не проверена: ' + le : 'Задержка проверена', running || le ? 'warning' : 'info');
- }).catch(function() { if (tries < 40) poll(); else done('Роутер не ответил', 'error'); });
- }, tries ? 2500 : 1500);
+ done(running ? 'Проверка идёт дольше обычного — задержки появятся сами' : le ? 'Задержка не проверена: ' + le : 'Проверка закончена: ' + score(r), running || le ? 'warning' : 'info');
+ }).catch(function() { if (tries < 90) poll(); else done('Роутер не ответил', 'error'); });
+ }, tries ? 1200 : 800);
}
zm.forkopAction('latency', forSec).then(function(res) {
if (res.error) { done(res.error, 'error'); return; }
@@ -23609,11 +24241,26 @@ return view.extend({
}).catch(function() { done('Роутер не ответил', 'error'); });
}
+ /* Выбор сервера: сразу показываем его выбранным (и в списке, и в карточке секции наверху),
+ * затем перечитываем у sing-box — список серверов и состояние секций. */
function pickServer(tag, name) {
- zm.forkopAction('select', ((cfg && cfg.sec) || '') + '|' + tag).then(function(res) {
+ var forSec = (cfg && cfg.sec) || '';
+ zm.forkopAction('select', forSec + '|' + tag).then(function(res) {
if (res.error) { zm.toast(res.error, 'error'); return; }
zm.toast('Выбрано: ' + name, 'info');
+ if (servers && cfg && (cfg.sec || '') === forSec) {
+ servers.now = tag; servers.fell = '';
+ var nt = servers.nodes && servers.nodes[tag];
+ var se = secState && (secState.sections || []).filter(function(e) { return e.name === forSec; })[0];
+ if (se) {
+ se.auto = !!(nt && nt.members);
+ if (nt && nt.members) { var an = servers.nodes[nt.now]; se.server = an ? an.name || nt.now : ''; se.delay = an ? an.delay : -1; }
+ else { se.server = nt ? nt.name || tag : name; se.delay = nt ? nt.delay : -1; }
+ }
+ renderServers(); renderMain();
+ }
loadServers();
+ loadSecState(false);
}).catch(function() { zm.toast('Роутер не ответил', 'error'); });
}
@@ -23669,7 +24316,7 @@ return view.extend({
hn.forEach(function(nm) { if (!shown[nm]) hpItems.push({ name: nm, foot: 'скрыт', hidden: true, locked: false }); });
hidePick.update(hpItems, { markers: servers.exclude || '', hideNames: hn });
if (hideMode) { srvCard.appendChild(hidePick); return; }
- if (sortPing) {
+ if (sortPing && !latBusy) {
var rank = function(t) { var d = ns[t] ? ns[t].delay : -1; return d > 0 ? d : d === 0 ? 1e9 : 1e8; };
tags = tags.map(function(t, i) { return { t: t, i: i }; }).sort(function(a, b) { return rank(a.t) - rank(b.t) || a.i - b.i; }).map(function(x) { return x.t; });
}
@@ -23678,7 +24325,8 @@ return view.extend({
return node(nodeName(n), String(n.type || '').toLowerCase(), on, function() { if (!on) pickServer(t, nodeName(n)); },
E('span', { 'class': 'zm-lat ' + (n.delay === 0 ? 'zm-lat-bad' : latClass(n.delay)) }, latText(n.delay)), n.delay === 0 ? 'zm-node-dead' : '');
})));
- var acts = [ E('button', { 'class': 'cbi-button', 'disabled': latBusy ? '' : null, 'click': testLatency }, latBusy ? 'Проверяем…' : 'Проверить задержку') ];
+ var lt = tags.filter(function(t) { return ns[t] && !ns[t].members; }), lw = lt.filter(function(t) { return ns[t].delay !== -2; }).length;
+ var acts = [ E('button', { 'class': 'cbi-button', 'disabled': latBusy ? '' : null, 'click': testLatency }, latBusy ? (lt.length ? 'Проверяем ' + lw + ' из ' + lt.length : 'Проверяем…') : 'Проверить задержку') ];
if (cfg.mode === 'sub') acts.push(E('button', { 'class': 'cbi-button', 'click': function() { act('subs'); } }, 'Обновить серверы подписки'));
srvCard.appendChild(E('div', { 'class': 'zm-actions' }, acts));
if (tags.length > 1) srvCard.appendChild(zm.swRow(sortPing, 'Сортировать по пингу', 'Серверы идут от быстрых к медленным, неотвечающие — в конце.', function() {
@@ -24045,14 +24693,99 @@ return view.extend({
dnsCard.appendChild(sw(d.detour, 'DNS через VPN', 'Запросы к DNS-серверам пойдут через ваше подключение Forkozz' + (secs().length > 1 ? ' (секция «' + mainName() + '»)' : '') + '. Помогает, если провайдер блокирует или подменяет DoH/DoT.', function() {
draft.dns = { type: d.type, servers: d.servers, bootstraps: d.bootstraps, detour: !d.detour, bootOwn: !!d.bootOwn }; touch(); renderDns(); renderSaveBar();
}));
+ dnsCard.appendChild(sw(draft.block_doh, 'Не пускать приложения в обход DNS роутера', 'Прямые запросы к публичным DoH (Cloudflare, Google, Quad9, AdGuard, Яндекс и другим) будут закрыты — браузеры и приложения вернутся к DNS роутера, и их сайты пойдут по правилам Forkozz.', function() { set('block_doh', !draft.block_doh); }));
+ var ds = [];
+ if (draft.dns_ttl.trim() !== '60') ds.push('кэш ' + draft.dns_ttl.trim() + ' с');
+ if (draft.dns_ecs.trim()) ds.push('ECS ' + draft.dns_ecs.trim());
+ dnsCard.appendChild(fold('dns', 'Дополнительно', ds.join(' · '), function(box) {
+ box.appendChild(row('Время жизни записи', inp('dns_ttl', '60', 80, 'секунд')));
+ box.appendChild(hint('Сколько устройства помнят адрес сайта, полученный от Forkozz. Меньше — быстрее подхватываются смены адресов, больше — меньше запросов. Обычно 60.'));
+ box.appendChild(row('EDNS Client Subnet', inp('dns_ecs', '203.0.113.0/24', 220)));
+ box.appendChild(hint('Подсеть, которую DNS-сервер получит вместе с запросом, чтобы выдать ближайший к ней узел CDN. Обычно оставляют пустым.'));
+ }));
+ }
+
+ function renderNet() {
+ var ni = netInfo(), devs = ni.devs || [];
+ var isWan = function(d) { return /^(wan|wan6|wwan)/.test(d.net || '') || d.name === 'wan' || /^pppoe-/.test(d.name); };
+ netCard.innerHTML = '';
+ netCard.appendChild(E('h3', {}, 'Сеть'));
+
+ netCard.appendChild(E('h4', { 'style': 'margin:4px 0' }, 'Откуда принимать трафик'));
+ netCard.appendChild(hint('Устройства из этих сетей идут через Forkozz. Обычно это br-lan — вся домашняя сеть. Гостевую или отдельную сеть можно добавить сюда же.', 0));
+ var src = withMissing(devs.filter(function(d) { return (d.kind === 'bridge' || d.kind === 'eth') && !isWan(d); }), draft.src_ifs);
+ netCard.appendChild(devTiles(src, function(n) { return draft.src_ifs.indexOf(n) >= 0; }, function(n, on) {
+ if (on && draft.src_ifs.length === 1) { zm.toast('Нужна хотя бы одна сеть', 'warning'); return; }
+ set('src_ifs', on ? draft.src_ifs.filter(function(x) { return x !== n; }) : draft.src_ifs.concat([ n ]));
+ }));
+
+ netCard.appendChild(E('h4', { 'style': 'margin:16px 0 4px' }, 'Выход в интернет'));
+ netCard.appendChild(hint('Через какой интерфейс sing-box выходит к серверам. «Авто» — как сам роутер. Свой нужен, если подключений к интернету несколько (второй провайдер, модем).', 0));
+ var outs = withMissing(devs.filter(function(d) {
+ return (d.kind === 'eth' || d.kind === 'ppp' || d.kind === 'bridge') && draft.src_ifs.indexOf(d.name) < 0 && d.name !== 'br-lan' && !/^(lan|tun|wg|vpn|awg|oc|zm)/.test(d.name);
+ }), [ draft.out_if ]);
+ var outBox = devTiles(outs, function(n) { return draft.out_if === n; }, function(n, on) { set('out_if', on ? '' : n); });
+ outBox.insertBefore(node('Авто', 'как у роутера', !draft.out_if, function() { if (draft.out_if) set('out_if', ''); }), outBox.firstChild);
+ netCard.appendChild(outBox);
+
+ netCard.appendChild(sw(draft.badwan, 'Перезапускать при переподключении интернета', 'Когда выбранное подключение (wan, pppoe, модем) поднимается заново, Forkozz перезапускается сам — помогает при нестабильном провайдере, когда после обрыва VPN не оживает.', function() {
+ if (!draft.badwan && !draft.badwan_ifs.length) draft.badwan_ifs = (ni.nets || []).filter(function(x) { return x.name === 'wan'; }).map(function(x) { return x.name; });
+ set('badwan', !draft.badwan);
+ }));
+ if (draft.badwan) {
+ var nets = (ni.nets || []).filter(function(x) { return x.name !== 'lan' && x.name !== 'loopback' && x.proto && x.proto !== 'none'; });
+ var have = {}; nets.forEach(function(x) { have[x.name] = true; });
+ draft.badwan_ifs.forEach(function(n) { if (!have[n]) nets.push({ name: n, proto: '', dev: '', missing: true }); });
+ netCard.appendChild(E('div', { 'class': 'zm-nodes zm-nodes-3' }, nets.map(function(x) {
+ var on = draft.badwan_ifs.indexOf(x.name) >= 0;
+ return node(x.name, x.missing ? 'не найдена' : [ x.proto, x.dev ].filter(Boolean).join(' · '), on, function() {
+ set('badwan_ifs', on ? draft.badwan_ifs.filter(function(v) { return v !== x.name; }) : draft.badwan_ifs.concat([ x.name ]));
+ });
+ })));
+ netCard.appendChild(row('Пауза перед перезапуском', inp('badwan_delay', '2000', 90, 'мс')));
+ }
+ netCard.appendChild(sw(draft.ipv6, 'Поддержка IPv6', 'Маршрутизация и DNS для IPv6. Включайте, только если провайдер даёт роутеру рабочий IPv6 — иначе сайты могут открываться дольше.', function() { set('ipv6', !draft.ipv6); }));
+ netCard.appendChild(sw(draft.no_dhcp, 'Не менять настройки DNS роутера', '⚠ Для опытных. Forkozz не будет перенаправлять dnsmasq на себя — тогда это нужно сделать вручную (сервер 127.0.0.42), иначе сайты по доменам пойдут мимо Forkozz.', function() {
+ if (!draft.no_dhcp && !confirm('Forkozz перестанет сам настраивать DNS роутера.\n\nБез ручной настройки dnsmasq сервисы по доменам пойдут напрямую. Продолжить?')) return;
+ set('no_dhcp', !draft.no_dhcp);
+ }));
+ }
+
+ function renderPanel() {
+ panelCard.innerHTML = '';
+ var on = draft.yacd, live = cfg && cfg.g && cfg.g.yacd && st.running, url = 'http://' + lanIp() + ':9090/ui/';
+ panelCard.appendChild(E('h3', {}, [ 'Панель sing-box ', live ? badge('zm-ok', 'включена') : E([]) ]));
+ panelCard.appendChild(sw(on, 'Веб-панель (YACD)', 'Соединения, трафик и серверы всех секций в реальном времени, с ручным выбором сервера. При первом запуске панель скачивается с GitHub.', function() { set('yacd', !on); }));
+ if (live) panelCard.appendChild(row('Адрес', E('a', { 'href': url, 'target': '_blank', 'rel': 'noopener' }, url)));
+ else if (on) panelCard.appendChild(hint('Откроется по адресу ' + url + ' после сохранения.'));
+ if (!on) return;
+ panelCard.appendChild(sw(draft.yacd_wan, 'Доступ из интернета', 'Панель будет доступна и с внешнего адреса роутера — нужен ключ, а порт 9090 откройте в файрволе сами. Без этого панель видна только из домашней сети.', function() {
+ if (!draft.yacd_wan && !draft.yacd_secret.trim()) draft.yacd_secret = genKey();
+ set('yacd_wan', !draft.yacd_wan);
+ }));
+ if (draft.yacd_wan) panelCard.appendChild(row('Ключ', E('span', { 'style': 'display:inline-flex; gap:8px; flex-wrap:wrap; align-items:center' }, [
+ inp('yacd_secret', '8–64 символа', 260),
+ E('button', { 'class': 'cbi-button', 'click': function() { set('yacd_secret', genKey()); } }, 'Новый')
+ ])));
+ }
+
+ function genKey() {
+ var a = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789', r = '', b = new Uint8Array(20);
+ try { window.crypto.getRandomValues(b); } catch (e) { for (var i = 0; i < 20; i++) b[i] = Math.floor(Math.random() * 256); }
+ for (var j = 0; j < 20; j++) r += a[b[j] % a.length];
+ return r;
}
function renderSet() {
renderDns();
+ renderNet();
+ renderPanel();
miscCard.innerHTML = '';
miscCard.appendChild(E('h3', {}, 'Прочее'));
miscCard.appendChild(sw(draft.quic_off, 'Отключить QUIC для выбранных сервисов', 'Приложения перейдут на обычный HTTPS — через сервер так стабильнее.', function() { set('quic_off', !draft.quic_off); }));
+ miscCard.appendChild(sw(draft.excl_bt, 'Торренты — напрямую', 'Трафик BitTorrent пойдёт мимо VPN, даже с устройств «Всё через Forkozz». Некоторые сервисы блокируют подписку, заметив торренты.', function() { set('excl_bt', !draft.excl_bt); }));
+ miscCard.appendChild(sw(draft.excl_ntp, 'Синхронизация времени — напрямую', 'Запросы NTP не пойдут через VPN: часы устройств сверяются с ближайшим сервером времени.', function() { set('excl_ntp', !draft.excl_ntp); }));
miscCard.appendChild(E('h4', { 'style': 'margin:18px 0 4px' }, 'Списки сервисов'));
miscCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:0' }, 'Домены и адреса выбранных сервисов (YouTube, Discord и другие) — по ним Forkozz понимает, что вести через VPN.'));
miscCard.appendChild(sw(draft.lists_via, 'Скачивать списки сервисов через VPN', 'Списки будут качаться через ваше подключение (сервер, подписку или туннель' + (secs().length > 1 ? ' секции «' + mainName() + '»' : '') + '). Помогает, если GitHub у провайдера не открывается.', function() { set('lists_via', !draft.lists_via); }));
@@ -24074,6 +24807,8 @@ return view.extend({
sbCard.appendChild(E('div', { 'class': 'zm-kv' }, vars.map(function(x) {
return row(x.label, E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, x.hint));
})));
+ sbCard.appendChild(row('Журнал', seg(LOG_LV, LOG_LV.some(function(x) { return x.id === draft.log_level; }) ? draft.log_level : '', function(v) { set('log_level', v); })));
+ sbCard.appendChild(hint('Сколько sing-box пишет в системный журнал. По умолчанию выключен — меньше нагрузки на память и флеш роутера; ошибки запуска Forkozz всё равно видно. «Подробный» и «Отладка» — чтобы разобраться с проблемой, потом верните «Выключен».'));
}
function runDiag(quiet) {
@@ -24190,8 +24925,10 @@ return view.extend({
panes.dev.appendChild(fullCard);
panes.dev.appendChild(exclCard);
panes.set.appendChild(dnsCard);
+ panes.set.appendChild(netCard);
panes.set.appendChild(miscCard);
panes.set.appendChild(sbCard);
+ panes.set.appendChild(panelCard);
panes.byp.appendChild(bypCard);
[ mainCard, hostsWarn, logEl, secCard, tabBar, panes.conn, panes.svc, panes.byp, panes.dev, panes.set, saveBar ].forEach(function(n) { wrap.appendChild(n); });
@@ -24264,7 +25001,7 @@ function injectCss() {
var l = document.createElement('link');
l.id = 'bt-css';
l.rel = 'stylesheet';
- l.href = L.resource('view/bytetube/style.css');
+ l.href = L.resource('view/bytetube/style.css') + (/^[0-9]{6,}$/.test('__ZM_BUILD__') ? '?v=__ZM_BUILD__' : '');
document.head.appendChild(l);
}
@@ -25596,6 +26333,8 @@ ZM_BUILD_ID="$(date +%s)"
[ -f "$_zm_f" ] && echo "${_zm_f#/www/luci-static/resources/}"
done
} > /www/luci-static/resources/zapret-manager/build.txt.zm-new 2>/dev/null || true
+# номер сборки — в common.js (сверка с build.txt) и в адрес style.css: новая сборка = новый адрес стилей
+sed -i "s/__ZM_BUILD__/$ZM_BUILD_ID/g" /www/luci-static/resources/zapret-manager/common.js /www/luci-static/resources/bytetube/common.js 2>/dev/null || true
mv -f /www/luci-static/resources/zapret-manager/build.txt.zm-new /www/luci-static/resources/zapret-manager/build.txt 2>/dev/null || true
chmod 0644 /www/luci-static/resources/zapret-manager/build.txt 2>/dev/null || true
@@ -26120,7 +26859,7 @@ var ICONS = {
lock: '',
arrow: '',
alert: '',
- fork: '',
+ fork: '',
route: '',
tunnel: '',
anarchy: '',