Fix: Converge the clashctl CLI and address issues with subscriptions, UI, and the proxy main link.

This commit is contained in:
Arvin
2026-04-12 09:37:59 +08:00
parent f3f98ac0bf
commit 9025dfb7ff
6 changed files with 1188 additions and 691 deletions
+119 -38
View File
@@ -9,6 +9,33 @@ _clashctl_real() {
command clashctl "$@"
}
_clashctl_real_on() {
local project_dir clashctl_script
project_dir="$(_clash_alias_project_dir)"
clashctl_script="$project_dir/scripts/core/clashctl.sh"
if [ -f "$clashctl_script" ]; then
bash "$clashctl_script" on "$@"
return $?
fi
_clashctl_real on "$@"
}
_clashctl_real_on_target() {
local project_dir clashctl_script
project_dir="$(_clash_alias_project_dir)"
clashctl_script="$project_dir/scripts/core/clashctl.sh"
if [ -f "$clashctl_script" ]; then
echo "bash $clashctl_script on"
else
echo "_clashctl_real on"
fi
}
_clash_alias_project_dir() {
local self_dir
self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
@@ -46,6 +73,7 @@ _clash_alias_print_sep() {
_clash_alias_proxy_on() {
_clashctl_real proxy on >/dev/null || return $?
_clash_alias_export_system_proxy || return $?
}
_clash_alias_proxy_off() {
@@ -56,6 +84,34 @@ _clash_alias_proxy_show() {
_clashctl_real proxy show 2>/dev/null || true
}
_clash_alias_export_system_proxy() {
local proxy_file http_url https_url all_url no_proxy
proxy_file="${SYSTEM_PROXY_ENV_FILE:-/etc/environment}"
[ -f "$proxy_file" ] || return 1
grep -Fq "# >>> clash-for-linux system proxy >>>" "$proxy_file" 2>/dev/null || return 1
http_url="$(sed -nE 's/^http_proxy="?([^"\r\n]+)"?$/\1/p' "$proxy_file" | tail -n 1)"
https_url="$(sed -nE 's/^https_proxy="?([^"\r\n]+)"?$/\1/p' "$proxy_file" | tail -n 1)"
all_url="$(sed -nE 's/^all_proxy="?([^"\r\n]+)"?$/\1/p' "$proxy_file" | tail -n 1)"
no_proxy="$(sed -nE 's/^NO_PROXY="?([^"\r\n]+)"?$/\1/p' "$proxy_file" | tail -n 1)"
[ -n "${no_proxy:-}" ] || no_proxy="$(sed -nE 's/^no_proxy="?([^"\r\n]+)"?$/\1/p' "$proxy_file" | tail -n 1)"
[ -n "${http_url:-}" ] || return 1
[ -n "${https_url:-}" ] || https_url="$http_url"
[ -n "${all_url:-}" ] || all_url="${http_url/http:\/\//socks5://}"
[ -n "${no_proxy:-}" ] || no_proxy="127.0.0.1,localhost,::1"
export http_proxy="$http_url"
export https_proxy="$https_url"
export HTTP_PROXY="$http_url"
export HTTPS_PROXY="$https_url"
export all_proxy="$all_url"
export ALL_PROXY="$all_url"
export no_proxy="$no_proxy"
export NO_PROXY="$no_proxy"
}
_clash_alias_unset_shell_proxy() {
unset \
http_proxy https_proxy HTTP_PROXY HTTPS_PROXY \
@@ -75,69 +131,94 @@ _clash_alias_prepare_on() {
_clash_alias_after_on() {
_clash_alias_set_persist_enabled "true"
_clash_alias_proxy_on || return $?
_clash_alias_export_system_proxy || return $?
_clash_alias_print_sep
if [ "${CLASH_WRAPPER_EXEC:-0}" = "1" ]; then
echo "✅ 系统代理已开启"
else
echo "✅ 当前 Shell 代理环境已同步"
fi
_clash_alias_proxy_show
if [ "${CLASH_WRAPPER_EXEC:-0}" = "1" ]; then
echo "⚠️ 当前通过独立命令执行,Shell 变量不会自动回写到父终端"
echo '💡 请在当前终端执行:eval "$(clashctl proxy on)"'
echo "💡 如需当前终端立即生效,请重新打开终端或 source shell 入口后执行 clashon"
fi
echo "👉 下一步:$(_clash_alias_status_next)"
}
_clash_alias_after_off() {
_clash_alias_set_persist_enabled "false"
_clash_alias_print_sep
echo "🧹 系统代理已关闭"
}
_clash_alias_run_on() {
local on_output on_rc had_errexit
_clash_alias_prepare_on || return $?
_clashctl_real on "$@" || return $?
echo "🟢 正在开启代理..."
_clash_alias_after_on || return $?
on_output="$(mktemp "${TMPDIR:-/tmp}/clashon.XXXXXX")" || {
echo "🔴 开启代理失败:无法创建临时输出文件" >&2
return 1
}
had_errexit="false"
case "$-" in
*e*)
had_errexit="true"
set +e
;;
esac
CLASH_ALIAS_CALL=1 _clashctl_real_on "$@" >"$on_output" 2>&1
on_rc=$?
if [ "$had_errexit" = "true" ]; then
set -e
fi
if [ "$on_rc" -ne 0 ]; then
if _clash_alias_export_system_proxy; then
echo "⚠️ clashctl on 返回非 0,但系统代理已写入,继续同步当前 Shell(底层返回码:$on_rc)" >&2
if [ -s "$on_output" ]; then
sed 's/^/ /' "$on_output" >&2
fi
elif _clash_alias_proxy_on; then
echo "⚠️ clashctl on 返回非 0,已通过 proxy on 继续同步当前 Shell(底层返回码:$on_rc)" >&2
if [ -s "$on_output" ]; then
sed 's/^/ /' "$on_output" >&2
fi
else
echo "🔴 开启代理失败(底层返回码:$on_rc)" >&2
if [ -s "$on_output" ]; then
sed 's/^/ /' "$on_output" >&2
else
echo " 底层命令没有输出错误详情:$(_clashctl_real_on_target)" >&2
fi
rm -f "$on_output" 2>/dev/null || true
return "$on_rc"
fi
else
cat "$on_output"
fi
rm -f "$on_output" 2>/dev/null || true
_clash_alias_after_on || {
on_rc=$?
echo "🔴 开启代理失败:当前 Shell 代理环境同步失败(返回码:$on_rc)" >&2
return "$on_rc"
}
}
_clash_alias_run_off() {
_clash_alias_unset_shell_proxy
_clash_alias_proxy_off
_clash_alias_set_persist_enabled "false"
_clashctl_real off "$@" || return $?
_clash_alias_after_off
}
_clash_alias_auto_restore_proxy() {
local proxy_file http_url https_url all_url no_proxy
_clash_alias_persist_enabled || return 0
proxy_file="${SYSTEM_PROXY_ENV_FILE:-/etc/environment}"
[ -f "$proxy_file" ] || return 0
grep -Fq "# >>> clash-for-linux system proxy >>>" "$proxy_file" 2>/dev/null || return 0
http_url="$(sed -nE 's/^http_proxy="?([^"\r\n]+)"?$/\1/p' "$proxy_file" | tail -n 1)"
https_url="$(sed -nE 's/^https_proxy="?([^"\r\n]+)"?$/\1/p' "$proxy_file" | tail -n 1)"
all_url="$(sed -nE 's/^all_proxy="?([^"\r\n]+)"?$/\1/p' "$proxy_file" | tail -n 1)"
no_proxy="$(sed -nE 's/^NO_PROXY="?([^"\r\n]+)"?$/\1/p' "$proxy_file" | tail -n 1)"
[ -n "${no_proxy:-}" ] || no_proxy="$(sed -nE 's/^no_proxy="?([^"\r\n]+)"?$/\1/p' "$proxy_file" | tail -n 1)"
[ -n "${http_url:-}" ] || return 0
[ -n "${https_url:-}" ] || https_url="$http_url"
[ -n "${all_url:-}" ] || all_url="${http_url/http:\/\//socks5://}"
[ -n "${no_proxy:-}" ] || no_proxy="127.0.0.1,localhost,::1"
export http_proxy="$http_url"
export https_proxy="$https_url"
export HTTP_PROXY="$http_url"
export HTTPS_PROXY="$https_url"
export all_proxy="$all_url"
export ALL_PROXY="$all_url"
export no_proxy="$no_proxy"
export NO_PROXY="$no_proxy"
_clash_alias_export_system_proxy || return 0
echo "♻️ 已恢复当前 shell 代理环境(来自持久化状态)"
return 0
+579 -452
View File
File diff suppressed because it is too large Load Diff
+44 -58
View File
@@ -109,7 +109,7 @@ ui_download() {
}
ui_target() {
ui_color "#fd79a8" "🎯 $*"
ui_color "#fd79a8" "🎯 $*" >&2
}
ui_kv() {
@@ -1517,10 +1517,13 @@ runtime_config_file() {
runtime_config_mixed_port() {
local file
local port
file="$(runtime_config_file)"
[ -s "$file" ] || return 1
"$(yq_bin)" eval '.["mixed-port"] // .port // ""' "$file" 2>/dev/null | head -n 1
port="$("$(yq_bin)" eval '.["mixed-port"] // .port // ""' "$file" 2>/dev/null | head -n 1)"
runtime_port_value_is_valid "$port" || return 1
echo "$port"
}
runtime_config_controller_addr() {
@@ -1533,11 +1536,33 @@ runtime_config_controller_addr() {
runtime_config_controller_port() {
local addr
local port
addr="$(runtime_config_controller_addr 2>/dev/null || true)"
[ -n "${addr:-}" ] || return 1
[ "$addr" != "null" ] || return 1
echo "${addr##*:}"
port="${addr##*:}"
runtime_port_value_is_valid "$port" || return 1
echo "$port"
}
runtime_port_value_is_valid() {
local port="${1:-}"
printf '%s' "$port" | grep -Eq '^[0-9]+$' || return 1
[ "$port" -ge 1 ] && [ "$port" -le 65535 ]
}
runtime_controller_value_is_valid() {
local controller="${1:-}"
local port
[ -n "${controller:-}" ] || return 1
[ "$controller" != "null" ] || return 1
printf '%s' "$controller" | grep -Eq '^[^[:space:][:cntrl:]]+:[0-9]+$' || return 1
port="${controller##*:}"
runtime_port_value_is_valid "$port"
}
display_controller_local_addr() {
@@ -1550,6 +1575,8 @@ display_controller_local_addr() {
host="${controller%:*}"
port="${controller##*:}"
printf '%s' "$host" | grep -Eq '^[^[:space:][:cntrl:]]+$' || return 1
runtime_port_value_is_valid "$port" || return 1
if [ "$host" = "0.0.0.0" ]; then
host="127.0.0.1"
@@ -1568,11 +1595,14 @@ runtime_config_dns_listen() {
runtime_config_dns_port() {
local listen
local port
listen="$(runtime_config_dns_listen 2>/dev/null || true)"
[ -n "${listen:-}" ] || return 1
[ "$listen" != "null" ] || return 1
echo "${listen##*:}"
port="${listen##*:}"
runtime_port_value_is_valid "$port" || return 1
echo "$port"
}
mark_runtime_port_repair_result() {
@@ -2533,11 +2563,15 @@ install_runtime_brief_line() {
[ -n "${controller:-}" ] || controller="$(read_env_value "EXTERNAL_CONTROLLER" 2>/dev/null || echo "127.0.0.1:9090")"
controller_display="$(display_controller_local_addr "$controller" 2>/dev/null || true)"
if ! runtime_port_value_is_valid "$mixed_port" || [ -z "${controller_display:-}" ]; then
status_text="broken"
fi
case "$status_text" in
ready)
echo "🟢 当前状态:ready"
echo "🌐 本地代理:http://127.0.0.1:${mixed_port}"
echo "🖥️ 控制台:http://${controller_display:-$controller}/ui"
echo "💻 控制台:http://${controller_display:-$controller}/ui"
;;
stopped)
echo "🔴 当前状态:stopped"
@@ -2549,7 +2583,7 @@ install_runtime_brief_line() {
echo "🌐 本地代理:http://127.0.0.1:${mixed_port}"
fi
if [ -n "${controller:-}" ]; then
echo "🖥️ 控制台:http://${controller_display:-$controller}/ui"
echo "💻 控制台:http://${controller_display:-$controller}/ui"
fi
;;
broken)
@@ -2562,59 +2596,11 @@ install_runtime_brief_line() {
}
print_install_summary() {
local backend mixed_port controller controller_display
local has_subscription final_state next_action
local clashctl_file
backend="$(install_plan_backend 2>/dev/null || true)"
[ -n "${backend:-}" ] || backend="$(runtime_backend 2>/dev/null || true)"
clashctl_file="$(clashctl_source)"
mixed_port="$(install_plan_mixed_port 2>/dev/null || true)"
[ -n "${mixed_port:-}" ] || mixed_port="$(read_env_value "MIXED_PORT" 2>/dev/null || echo "7890")"
controller="$(install_plan_controller 2>/dev/null || true)"
[ -n "${controller:-}" ] || controller="$(read_env_value "EXTERNAL_CONTROLLER" 2>/dev/null || echo "127.0.0.1:9090")"
controller_display="$(display_controller_local_addr "$controller" 2>/dev/null || true)"
if install_has_subscription; then
has_subscription="true"
else
has_subscription="false"
if [ -f "$clashctl_file" ]; then
CLASH_UI_BOX_ONLY=1 bash "$clashctl_file" ui || true
fi
final_state="$(install_status_text)"
next_action="$(install_default_next_action)"
echo
echo "😼 安装完成"
echo
echo "🧩 运行后端:${backend:-unknown}"
echo "📡 订阅:$( [ "${has_subscription:-false}" = "true" ] && echo "已配置" || echo "未配置" )"
case "$final_state" in
ready)
echo "🚦 当前状态:🟢 ready"
echo "🌐 本地代理:http://127.0.0.1:${mixed_port}"
echo "🖥️ 控制台:http://${controller_display:-$controller}/ui"
;;
stopped)
echo "🚦 当前状态:⚪ stopped"
;;
verifying)
if [ -n "${mixed_port:-}" ]; then
echo "🌐 本地代理:http://127.0.0.1:${mixed_port}"
fi
if [ -n "${controller:-}" ]; then
echo "🖥️ 控制台:http://${controller_display:-$controller}/ui"
fi
;;
broken)
echo "🚦 当前状态:🔴 broken"
;;
*)
echo "🚦 当前状态:⚪ unknown"
;;
esac
echo "👉 下一步:${next_action}"
echo
}
+223 -115
View File
@@ -438,7 +438,7 @@ normalize_runtime_config() {
[ -s "$file" ] || die "待规范化的配置文件不存在:$file"
resolved_ports="$(resolve_runtime_ports)"
eval "$resolved_ports"
load_resolved_runtime_ports "$resolved_ports"
mixed_port="$MIXED_PORT_RESOLVED"
controller="$EXTERNAL_CONTROLLER_RESOLVED"
@@ -472,6 +472,7 @@ normalize_runtime_config() {
.dns.enable = (.dns.enable // true) |
.dns["enhanced-mode"] = (.dns["enhanced-mode"] // "fake-ip") |
.dns.ipv6 = false |
.dns.listen = env(dns_listen_value) |
.proxies = (.proxies // []) |
@@ -482,11 +483,11 @@ normalize_runtime_config() {
generate_secure_secret() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex 24 2>/dev/null | head -n 1
openssl rand -hex 8 2>/dev/null | head -n 1
return 0
fi
head -c 32 /dev/urandom 2>/dev/null | od -An -tx1 | tr -d ' \n'
head -c 8 /dev/urandom 2>/dev/null | od -An -tx1 | tr -d ' \n'
}
is_valid_controller_secret() {
@@ -1367,33 +1368,42 @@ csv_append_value() {
current_runtime_mixed_port() {
local file="${1:-$RUNTIME_DIR/config.yaml}"
local port
[ -s "$file" ] || return 1
"$(yq_bin)" eval '.["mixed-port"] // .port // ""' "$file" 2>/dev/null | head -n 1
port="$("$(yq_bin)" eval '.["mixed-port"] // .port // ""' "$file" 2>/dev/null | head -n 1)"
is_valid_port_number "$port" || return 1
echo "$port"
}
current_runtime_controller_port() {
local file="${1:-$RUNTIME_DIR/config.yaml}"
local addr
local port
[ -s "$file" ] || return 1
addr="$("$(yq_bin)" eval '.["external-controller"] // ""' "$file" 2>/dev/null | head -n 1)"
[ -n "${addr:-}" ] || return 1
[ "$addr" != "null" ] || return 1
echo "${addr##*:}"
port="${addr##*:}"
is_valid_port_number "$port" || return 1
echo "$port"
}
current_runtime_dns_port() {
local file="${1:-$RUNTIME_DIR/config.yaml}"
local listen
local port
[ -s "$file" ] || return 1
listen="$("$(yq_bin)" eval '.dns.listen // ""' "$file" 2>/dev/null | head -n 1)"
[ -n "${listen:-}" ] || return 1
[ "$listen" != "null" ] || return 1
echo "${listen##*:}"
port="${listen##*:}"
is_valid_port_number "$port" || return 1
echo "$port"
}
port_reserved_by_current_runtime() {
@@ -1463,10 +1473,6 @@ resolve_runtime_port() {
resolved="$(resolve_free_port_excluding "$start" "$end" "$excludes_csv")"
if [ "$resolved" != "$preferred" ]; then
ui_target "端口冲突:[${label}] ${preferred} 🎲 随机分配:${resolved}"
fi
echo "$resolved"
}
@@ -1492,6 +1498,43 @@ parse_controller_port() {
echo "$port"
}
is_clean_controller_addr() {
local controller="$1"
local port
[ -n "${controller:-}" ] || return 1
[ "$controller" != "null" ] || return 1
printf '%s' "$controller" | grep -Eq '^[^[:space:][:cntrl:]]+:[0-9]+$' || return 1
port="${controller##*:}"
is_valid_port_number "$port"
}
load_resolved_runtime_ports() {
local resolved="$1"
local unexpected_lines
local mixed controller dns
unexpected_lines="$(
printf '%s\n' "$resolved" \
| grep -Ev '^(MIXED_PORT_RESOLVED=[0-9]+|EXTERNAL_CONTROLLER_RESOLVED=[^[:space:][:cntrl:]]+:[0-9]+|CLASH_DNS_PORT_RESOLVED=[0-9]+)$' \
| grep -v '^$' || true
)"
[ -z "${unexpected_lines:-}" ] || die "runtime port resolution output is polluted"
mixed="$(printf '%s\n' "$resolved" | sed -n 's/^MIXED_PORT_RESOLVED=//p' | head -n 1)"
controller="$(printf '%s\n' "$resolved" | sed -n 's/^EXTERNAL_CONTROLLER_RESOLVED=//p' | head -n 1)"
dns="$(printf '%s\n' "$resolved" | sed -n 's/^CLASH_DNS_PORT_RESOLVED=//p' | head -n 1)"
is_valid_port_number "$mixed" || die "invalid MIXED_PORT_RESOLVED: $mixed"
is_clean_controller_addr "$controller" || die "invalid EXTERNAL_CONTROLLER_RESOLVED: $controller"
is_valid_port_number "$dns" || die "invalid CLASH_DNS_PORT_RESOLVED: $dns"
MIXED_PORT_RESOLVED="$mixed"
EXTERNAL_CONTROLLER_RESOLVED="$controller"
CLASH_DNS_PORT_RESOLVED="$dns"
}
resolve_runtime_ports() {
local preferred_mixed preferred_controller preferred_dns
local controller_host preferred_controller_port
@@ -1509,12 +1552,15 @@ resolve_runtime_ports() {
preferred_controller_port="$(parse_controller_port "$preferred_controller")"
mixed_port="$(resolve_runtime_port "$preferred_mixed" 7890 7999 "$used_ports" "mixed-port")"
is_valid_port_number "$mixed_port" || die "invalid mixed-port resolution: $mixed_port"
used_ports="$(csv_append_value "$used_ports" "$mixed_port")"
controller_port="$(resolve_runtime_port "$preferred_controller_port" 9090 9199 "$used_ports" "external-controller")"
is_valid_port_number "$controller_port" || die "invalid external-controller resolution: $controller_port"
used_ports="$(csv_append_value "$used_ports" "$controller_port")"
dns_port="$(resolve_runtime_port "$preferred_dns" 1053 1199 "$used_ports" "dns.listen")"
is_valid_port_number "$dns_port" || die "invalid dns.listen resolution: $dns_port"
used_ports="$(csv_append_value "$used_ports" "$dns_port")"
printf 'MIXED_PORT_RESOLVED=%s\n' "$mixed_port"
@@ -1528,7 +1574,7 @@ mark_install_port_plan() {
local changed_mixed changed_controller changed_dns
resolved="$(resolve_runtime_ports)"
eval "$resolved"
load_resolved_runtime_ports "$resolved"
preferred_mixed="${MIXED_PORT:-7890}"
preferred_controller="${EXTERNAL_CONTROLLER:-0.0.0.0:9090}"
@@ -1661,13 +1707,14 @@ show_subscription() {
bootstrap_subscription_from_install_input() {
local url="$1"
local fmt="${2:-convert}"
local fmt="${2:-}"
local name="${3:-default}"
local file
[ -n "${url:-}" ] || return 1
subscription_url_is_supported "$url" || die "订阅地址格式不合法"
[ -n "${fmt:-}" ] || fmt="$(detect_subscription_format "$url")"
case "$fmt" in
clash|convert) ;;
@@ -1733,32 +1780,54 @@ subscription_name_has_url() {
}
ensure_subscription_bootstrap_for_install() {
local env_url active_name
local env_url active_name input_fmt current_url current_fmt
env_url="${CLASH_SUBSCRIPTION_URL:-}"
active_name="${1:-default}"
[ -n "${env_url:-}" ] || return 0
ensure_subscriptions_file
# 只在“当前完全没有订阅”时,才允许用 .env 初始化
# Only initialize from .env when there is no subscription yet.
if subscriptions_has_any_usable_source; then
current_url="$(subscription_url_by_name "$active_name" 2>/dev/null || true)"
current_fmt="$(subscription_format_by_name "$active_name" 2>/dev/null || true)"
if [ -n "${current_url:-}" ]; then
input_fmt="$(detect_subscription_format "$current_url")"
if [ "$current_fmt" = "convert" ] && [ "$input_fmt" = "clash" ]; then
info "安装订阅格式判定:$active_name -> $input_fmt"
bootstrap_subscription_from_install_input "$current_url" "$input_fmt" "$active_name"
fi
return 0
fi
if [ -n "${env_url:-}" ]; then
input_fmt="$(detect_subscription_format "$env_url")"
info "安装订阅格式判定:$active_name -> $input_fmt"
bootstrap_subscription_from_install_input "$env_url" "$input_fmt" "$active_name"
fi
return 0
fi
bootstrap_subscription_from_install_input "$env_url" "convert" "$active_name"
[ -n "${env_url:-}" ] || return 0
input_fmt="$(detect_subscription_format "$env_url")"
info "安装订阅格式判定:$active_name -> $input_fmt"
bootstrap_subscription_from_install_input "$env_url" "$input_fmt" "$active_name"
}
set_subscription() {
local url="$1"
local fmt="${2:-convert}"
local fmt="${2:-}"
local name="${3:-default}"
local file
[ -n "$url" ] || die "订阅地址不能为空"
subscription_url_is_supported "$url" || die "订阅地址格式不合法"
[ -n "${fmt:-}" ] || fmt="$(detect_subscription_format "$url")"
case "$fmt" in
clash|convert) ;;
@@ -2219,33 +2288,44 @@ calculate_runtime_risk_level() {
echo "low"
}
print_subscription_table_header() {
local show_index="${1:-false}"
if [ "$show_index" = "true" ]; then
echo " 编号 名称 类型 URL"
else
echo " 名称 类型 URL"
fi
}
print_subscription_pick_line() {
local index="$1"
local name="$2"
local active enabled_text health_text fail_count type_text marker
local _url_mode="${3:-full}"
local show_index="${4:-true}"
local active type_text marker url_text
[ -n "${name:-}" ] || return 0
active="$(active_subscription_name 2>/dev/null || true)"
if [ "$name" = "$active" ]; then
marker="[当前]"
marker="$(printf '\033[32m*\033[0m')"
else
marker=""
marker=" "
fi
if subscription_enabled "$name"; then
enabled_text="enabled"
else
enabled_text="disabled"
fi
health_text="$(subscription_health_status "$name" 2>/dev/null || echo "unknown")"
fail_count="$(subscription_fail_count "$name" 2>/dev/null || echo "0")"
type_text="$(subscription_format_by_name "$name" 2>/dev/null || echo "clash")"
url_text="$(subscription_url_by_name "$name" 2>/dev/null || true)"
[ -n "${url_text:-}" ] || url_text="-"
printf ' %-2s) %-16s %-8s %-9s %-8s fail=%-3s %s\n' \
"$index" "$name" "$type_text" "$enabled_text" "$health_text" "$fail_count" "$marker"
if [ "$show_index" = "true" ]; then
printf '%b %-2s) %-16s %-8s %s\n' \
"$marker" "$index" "$name" "$type_text" "$url_text"
else
printf '%b %-16s %-8s %s\n' \
"$marker" "$name" "$type_text" "$url_text"
fi
}
print_subscription_summary_line() {
@@ -2287,16 +2367,17 @@ print_subscription_summary_line() {
}
list_subscriptions() {
local file name found="false"
local file name found="false" idx=1
file="$(subscriptions_file)"
ensure_subscriptions_file
echo " 名称 类型 启用状态 健康状态 失败次数"
print_subscription_table_header "false"
while IFS= read -r name; do
[ -n "${name:-}" ] || continue
found="true"
print_subscription_summary_line "$name"
print_subscription_pick_line "$idx" "$name" "full" "false"
idx=$((idx + 1))
done < <("$(yq_bin)" eval '.sources | keys | .[]' "$file" 2>/dev/null)
[ "$found" = "true" ] || echo " 暂无订阅"
@@ -2312,49 +2393,6 @@ disable_subscription() {
set_subscription_enabled "$name" "false"
}
list_subscriptions_verbose() {
local file active name enabled fmt url
file="$(subscriptions_file)"
ensure_subscriptions_file
active="$(active_subscription_name)"
while IFS= read -r name; do
[ -n "${name:-}" ] || continue
enabled="$("$(yq_bin)" eval ".sources.${name}.enabled // false" "$file" 2>/dev/null)"
fmt="$("$(yq_bin)" eval ".sources.${name}.type // \"clash\"" "$file" 2>/dev/null)"
url="$("$(yq_bin)" eval ".sources.${name}.url // \"\"" "$file" 2>/dev/null)"
if [ "$name" = "$active" ]; then
echo "* $name"
else
echo " $name"
fi
echo " enabled : $enabled"
echo " type : $fmt"
echo " health : $(subscription_health_status "$name" 2>/dev/null || echo "unknown")"
echo " fail_count : $(subscription_fail_count "$name" 2>/dev/null || echo "0")"
echo " auto_disabled : $(if subscription_auto_disabled "$name"; then echo true; else echo false; fi)"
if [ -n "$(subscription_last_success "$name" 2>/dev/null || true)" ]; then
echo " last_success : $(subscription_last_success "$name" 2>/dev/null || true)"
fi
if [ -n "$(subscription_last_failure "$name" 2>/dev/null || true)" ]; then
echo " last_failure : $(subscription_last_failure "$name" 2>/dev/null || true)"
fi
if [ -n "$(subscription_last_error "$name" 2>/dev/null || true)" ]; then
echo " last_error : $(subscription_last_error "$name" 2>/dev/null || true)"
fi
[ -n "${url:-}" ] && echo " url : $url"
done < <("$(yq_bin)" eval '.sources | keys | .[]' "$file")
}
subscription_list_overview_lines() {
local active recommended
@@ -2379,28 +2417,7 @@ subscription_list_overview_lines() {
}
subscription_list_recommendation_lines() {
local active recommended
active="$(active_subscription_name 2>/dev/null || true)"
recommended="$(recommended_subscription_name 2>/dev/null || true)"
if [ -n "${active:-}" ] && ! active_subscription_enabled 2>/dev/null; then
echo "👉 clashctl use"
echo "👉 clashctl health"
echo "👉 clashctl ls --verbose"
return 0
fi
if [ -n "${recommended:-}" ] && [ "${recommended:-}" != "${active:-}" ]; then
echo "👉 clashctl use ${recommended}"
echo "👉 clashctl health ${recommended}"
echo "👉 clashctl ls --verbose"
return 0
fi
echo "👉 clashctl use"
echo "👉 clashctl health"
echo "👉 clashctl ls --verbose"
echo "👉 clashctl use 切换当前使用的订阅"
}
detect_subscription_format() {
@@ -2494,28 +2511,70 @@ subconverter_running() {
}
start_subconverter() {
local home bin log_file
local home bin log_file pid_file pid i old_pwd exit_status
home="$(subconverter_home)"
bin="$(subconverter_bin)"
log_file="$(subconverter_log_file)"
pid_file="$(subconverter_pid_file)"
exit_status="unknown"
[ -x "$bin" ] || die "subconverter 未安装:$bin"
[ -f "$bin" ] || die "subconverter 未安装:$bin"
if [ ! -x "$bin" ]; then
chmod +x "$bin" 2>/dev/null || die "subconverter 无法修正执行权限:$bin"
fi
[ -x "$bin" ] || die "subconverter 文件不可执行:$bin"
mkdir -p "$LOG_DIR"
if subconverter_running; then
return 0
if is_port_in_use "$(subconverter_port)"; then
return 0
fi
warn "检测到旧 subconverter 进程存在但端口未监听,正在重启"
stop_subconverter || true
fi
(
cd "$home"
nohup "$bin" > "$log_file" 2>&1 &
echo $! > "$(subconverter_pid_file)"
)
old_pwd="$(pwd)"
rm -f "$pid_file" 2>/dev/null || true
cd "$home" || die "subconverter 工作目录不可用:$home"
nohup "$bin" > "$log_file" 2>&1 &
pid=$!
cd "$old_pwd" || true
echo "$pid" > "$pid_file"
sleep 1
for i in 1 2 3 4 5; do
if subconverter_running && is_port_in_use "$(subconverter_port)"; then
return 0
fi
sleep 1
done
subconverter_running || die "subconverter 启动失败"
pid="$(cat "$pid_file" 2>/dev/null || true)"
if [ -n "${pid:-}" ] && ! kill -0 "$pid" 2>/dev/null; then
if wait "$pid" 2>/dev/null; then
exit_status="0"
else
exit_status="$?"
fi
fi
{
error "subconverter 启动失败"
warn "启动命令:cd \"$home\" && \"$bin\""
warn "监听端口:$(subconverter_port)"
warn "pid 文件:$pid_file"
warn "pid:${pid:-unknown}"
if [ -n "${pid:-}" ] && kill -0 "$pid" 2>/dev/null; then
warn "进程状态:仍在运行,但未监听预期端口"
else
warn "进程状态:已退出或未成功启动"
warn "退出状态:$exit_status"
fi
warn "日志文件:$log_file"
if [ -f "$log_file" ]; then
tail -n 20 "$log_file" 2>/dev/null | sed 's/^/ /' >&2 || true
fi
} >&2
return 1
}
stop_subconverter() {
@@ -2702,7 +2761,9 @@ convert_subscription_via_subconverter() {
local url="$1"
local out_file="$2"
local fetch_reason="${3:-auto}"
local api tmp_file
local convert_reason="${4:-subscription-type-convert}"
local api tmp_file curl_error_file
local curl_meta curl_rc http_code effective_url errexit_was_set
local log_file
local validate_ok="false"
@@ -2735,18 +2796,65 @@ convert_subscription_via_subconverter() {
api="$(subconverter_url)/sub"
log_file="$(subconverter_log_file)"
tmp_file="$(mktemp)"
curl_error_file="$(mktemp)"
rm -f "$tmp_file" 2>/dev/null || true
info "正在通过 subconverter 转换订阅"
case "$convert_reason" in
direct-clash-invalid)
info "转换原因:直连订阅已下载,但不是可直接运行的 Clash YAML"
;;
subscription-type-convert)
info "转换原因:订阅类型为 convert"
;;
*)
info "转换原因:$convert_reason"
;;
esac
info "subconverter 请求:GET $api"
info "subconverter 参数:target=clash"
info "subconverter 参数:url=$url"
info "subconverter 未发送参数:insert/config/emoji/list(使用 subconverter 默认值)"
if ! curl -fL -G "$api" \
errexit_was_set="false"
case "$-" in
*e*)
errexit_was_set="true"
set +e
;;
esac
curl_meta="$(curl -sS -L -G "$api" \
--data-urlencode "target=clash" \
--data-urlencode "url=$url" \
-o "$tmp_file"; then
rm -f "$tmp_file" 2>/dev/null || true
-o "$tmp_file" \
-w '%{http_code}\n%{url_effective}' 2>"$curl_error_file")"
curl_rc=$?
[ "$errexit_was_set" = "true" ] && set -e
http_code="$(printf '%s\n' "$curl_meta" | head -n 1)"
effective_url="$(printf '%s\n' "$curl_meta" | sed -n '2p')"
[ -n "${effective_url:-}" ] && info "subconverter 实际请求 URL:$effective_url"
[ -n "${http_code:-}" ] && info "subconverter HTTP 状态码:$http_code"
if [ "$curl_rc" -ne 0 ] || [ -z "${http_code:-}" ] || [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
warn "subconverter 转换请求失败:curl_rc=$curl_rc http_code=${http_code:-unknown}"
[ -s "$curl_error_file" ] && {
warn "curl 错误输出:"
head -n 20 "$curl_error_file" >&2
}
[ -s "$tmp_file" ] && {
warn "subconverter 响应体预览:"
head -n 20 "$tmp_file" >&2
}
rm -f "$tmp_file" "$curl_error_file" 2>/dev/null || true
return 1
fi
rm -f "$curl_error_file" 2>/dev/null || true
[ -s "$tmp_file" ] || {
rm -f "$tmp_file" 2>/dev/null || true
return 1
@@ -3004,7 +3112,7 @@ fetch_subscription_source() {
candidate_file="$(mktemp)"
rm -f "$candidate_file" 2>/dev/null || true
if convert_subscription_via_subconverter "$url" "$raw_file" "$fetch_reason"; then
if convert_subscription_via_subconverter "$url" "$raw_file" "$fetch_reason" "direct-clash-invalid"; then
if build_runtime_candidate_from_payload "$raw_file" "$candidate_file"; then
mv -f "$candidate_file" "$out_file"
rm -f "$raw_file" 2>/dev/null || true
@@ -3028,7 +3136,7 @@ fetch_subscription_source() {
;;
esac
if convert_subscription_via_subconverter "$url" "$raw_file" "$fetch_reason"; then
if convert_subscription_via_subconverter "$url" "$raw_file" "$fetch_reason" "subscription-type-convert"; then
if build_runtime_candidate_from_payload "$raw_file" "$candidate_file"; then
mv -f "$candidate_file" "$out_file"
rm -f "$raw_file" 2>/dev/null || true
+9 -1
View File
@@ -176,12 +176,20 @@ controller_secret() {
}
controller_api_base() {
local addr
local addr host port
addr="$(controller_addr)"
[ -n "${addr:-}" ] || die "未找到 external-controller"
[ "$addr" != "null" ] || die "未找到 external-controller"
host="${addr%:*}"
port="${addr##*:}"
case "$host" in
0.0.0.0)
addr="127.0.0.1:${port}"
;;
esac
echo "http://$addr"
}
+214 -27
View File
@@ -166,16 +166,68 @@ mark_subconverter_version_installed() {
write_runtime_value "SUBCONVERTER_VERSION_INSTALLED" "$version"
}
detect_subconverter_package_type() {
local file="$1"
local desc magic
if tar -tzf "$file" >/dev/null 2>&1; then
echo "tar.gz"
return 0
fi
magic="$(od -An -tx1 -N4 "$file" 2>/dev/null | tr -d '[:space:]' || true)"
case "$magic" in
504b0304|504b0506|504b0708)
echo "zip"
return 0
;;
7f454c46)
echo "binary"
return 0
;;
esac
if command -v unzip >/dev/null 2>&1 && unzip -tq "$file" >/dev/null 2>&1; then
echo "zip"
return 0
fi
desc="$(file -b "$file" 2>/dev/null || true)"
case "$desc" in
*ELF*|*executable*)
echo "binary"
return 0
;;
esac
echo "unknown"
}
find_subconverter_binary() {
local search_dir="$1"
local found
found="$(find "$search_dir" \( -type f -o -type l \) -name subconverter -print 2>/dev/null | head -n 1)"
[ -n "${found:-}" ] || return 1
echo "$found"
}
resolve_subconverter() {
local arch version file url tmp_dir tmp_file target_dir installed_version
local extract_dir package_type found_bin target_bin
arch="$(get_arch)"
version="${SUBCONVERTER_VERSION:-$DEFAULT_SUBCONVERTER_VERSION}"
target_dir="$(subconverter_home)"
target_bin="$(subconverter_bin)"
installed_version="$(subconverter_version_file_value)"
if [ -x "$(subconverter_bin)" ] && [ "$installed_version" = "$version" ]; then
return 0
if [ -f "$target_bin" ] && [ "$installed_version" = "$version" ]; then
chmod +x "$target_bin" 2>/dev/null || true
if [ -x "$target_bin" ]; then
return 0
fi
fi
case "$arch" in
@@ -188,20 +240,56 @@ resolve_subconverter() {
url="https://github.com/tindy2013/subconverter/releases/download/${version}/${file}"
tmp_dir="$(mktemp -d)"
tmp_file="$tmp_dir/$file"
extract_dir="$tmp_dir/extract"
download_file "$url" "$tmp_file" "subconverter"
info "subconverter 下载文件:$tmp_file"
package_type="$(detect_subconverter_package_type "$tmp_file")"
info "subconverter 下载文件类型:$package_type"
rm -rf "$target_dir"
mkdir -p "$target_dir"
mkdir -p "$target_dir" "$extract_dir"
tar -xzf "$tmp_file" -C "$target_dir"
case "$package_type" in
tar.gz)
info "subconverter 解压目录:$extract_dir"
tar -xzf "$tmp_file" -C "$extract_dir"
found_bin="$(find_subconverter_binary "$extract_dir" 2>/dev/null || true)"
;;
zip)
command -v unzip >/dev/null 2>&1 || die "解压 subconverter zip 失败:系统缺少 unzip"
info "subconverter 解压目录:$extract_dir"
unzip -oq "$tmp_file" -d "$extract_dir"
found_bin="$(find_subconverter_binary "$extract_dir" 2>/dev/null || true)"
;;
binary)
info "subconverter 解压目录:无需解压"
found_bin="$tmp_file"
;;
*)
if command -v file >/dev/null 2>&1; then
file "$tmp_file" >&2 || true
fi
die "subconverter 下载内容异常:无法识别下载包类型(文件:$tmp_file)"
;;
esac
[ -x "$(subconverter_bin)" ] || {
rm -rf "$tmp_dir"
die "解压后未找到 subconverter 可执行文件"
[ -n "${found_bin:-}" ] && [ -f "$found_bin" ] || {
find "$extract_dir" -maxdepth 3 \( -type f -o -type l \) 2>/dev/null | sed 's/^/ /' >&2 || true
die "解压后未找到 subconverter 文件"
}
info "subconverter 实际文件:$found_bin"
cp -f "$found_bin" "$target_bin"
chmod +x "$target_bin"
info "subconverter 最终落盘:$target_bin"
[ -x "$target_bin" ] || {
rm -rf "$tmp_dir"
die "subconverter 文件不可执行:$target_bin"
}
chmod +x "$(subconverter_bin)"
mark_subconverter_version_installed "$version"
rm -rf "$tmp_dir"
}
@@ -241,8 +329,85 @@ ensure_runtime_config_ready() {
die "最后成功配置也不可用:$last_file"
}
tun_public_ip_without_proxy_env() {
local ip route_dev
route_dev="$(default_route_dev 2>/dev/null || true)"
if [ -n "${route_dev:-}" ] && ! printf '%s\n' "$route_dev" | grep -Eiq '(tun|utun|mihomo|clash|meta)'; then
ip="$(
env \
-u http_proxy \
-u https_proxy \
-u HTTP_PROXY \
-u HTTPS_PROXY \
-u all_proxy \
-u ALL_PROXY \
curl --interface "$route_dev" -fsSL --connect-timeout 3 --max-time 6 https://ip.sb 2>/dev/null \
| head -n 1 \
| tr -d '\r' \
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
)"
if [ -n "${ip:-}" ]; then
echo "$ip"
return 0
fi
fi
env \
-u http_proxy \
-u https_proxy \
-u HTTP_PROXY \
-u HTTPS_PROXY \
-u all_proxy \
-u ALL_PROXY \
curl -fsSL --connect-timeout 3 --max-time 6 https://ip.sb 2>/dev/null \
| head -n 1 \
| tr -d '\r' \
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
}
tun_public_ip_with_current_route() {
env \
-u http_proxy \
-u https_proxy \
-u HTTP_PROXY \
-u HTTPS_PROXY \
-u all_proxy \
-u ALL_PROXY \
curl -fsSL --connect-timeout 3 --max-time 6 https://ip.sb 2>/dev/null \
| head -n 1 \
| tr -d '\r' \
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
}
tun_traffic_effective_check() {
local host_ip current_ip
host_ip="$(tun_public_ip_without_proxy_env 2>/dev/null || true)"
current_ip="$(tun_public_ip_with_current_route 2>/dev/null || true)"
if [ -z "${host_ip:-}" ]; then
echo "host-ip-unavailable"
return 1
fi
if [ -z "${current_ip:-}" ]; then
echo "current-ip-unavailable"
return 1
fi
if [ "$current_ip" != "$host_ip" ]; then
echo "ok"
return 0
fi
echo "traffic-same-as-host"
return 1
}
tun_effective_check() {
local config_tun_enabled auto_route controller_ok route_ok
local config_tun_enabled controller_ok traffic_result
config_tun_enabled="$(runtime_config_tun_enabled 2>/dev/null || true)"
@@ -271,24 +436,16 @@ tun_effective_check() {
return 1
fi
auto_route="$(runtime_config_tun_auto_route 2>/dev/null || true)"
route_ok="unknown"
traffic_result="$(tun_traffic_effective_check 2>/dev/null || true)"
[ -n "${traffic_result:-}" ] || traffic_result="traffic-unknown"
if [ "${auto_route:-false}" = "true" ]; then
if default_route_is_tun_like; then
route_ok="true"
else
route_ok="false"
fi
if [ "$route_ok" != "true" ]; then
echo "default-route-not-tun"
return 1
fi
if [ "$traffic_result" = "ok" ]; then
echo "ok"
return 0
fi
echo "ok"
return 0
echo "$traffic_result"
return 1
}
tun_disable_check() {
@@ -317,6 +474,22 @@ tun_disable_check() {
return 0
}
wait_runtime_controller_ready() {
local max_try="${1:-8}"
local i=0
while [ "$i" -lt "$max_try" ]; do
if proxy_controller_reachable 2>/dev/null; then
return 0
fi
sleep 1
i=$((i + 1))
done
return 1
}
start_runtime() {
local config_file="$RUNTIME_DIR/config.yaml"
@@ -327,16 +500,30 @@ start_runtime() {
local old_pid
old_pid="$(cat "$RUNTIME_DIR/mihomo.pid" 2>/dev/null || true)"
if [ -n "${old_pid:-}" ] && kill -0 "$old_pid" 2>/dev/null; then
warn "Mihomo 已在运行:pid=$old_pid"
return 0
if proxy_controller_reachable 2>/dev/null; then
warn "Mihomo 已在运行:pid=$old_pid"
return 0
fi
warn "Mihomo 进程仍在运行但控制器不可访问,正在重启以加载当前配置"
stop_runtime || true
else
rm -f "$RUNTIME_DIR/mihomo.pid"
fi
rm -f "$RUNTIME_DIR/mihomo.pid"
fi
nohup "$(runtime_kernel_bin)" -f "$config_file" -d "$RUNTIME_DIR" \
> "$LOG_DIR/mihomo.out.log" 2>&1 &
echo $! > "$RUNTIME_DIR/mihomo.pid"
if ! wait_runtime_controller_ready 8; then
warn "$(runtime_kernel_name) 已启动,但控制器未在预期时间内可访问"
local new_pid
new_pid="$(cat "$RUNTIME_DIR/mihomo.pid" 2>/dev/null || true)"
[ -n "${new_pid:-}" ] && kill -0 "$new_pid" 2>/dev/null || return 1
fi
success "$(runtime_kernel_name) 已启动:pid=$(cat "$RUNTIME_DIR/mihomo.pid")"
}