mirror of
https://github.com/wnlen/clash-for-linux.git
synced 2026-10-10 12:13:04 +08:00
3144 lines
80 KiB
Bash
3144 lines
80 KiB
Bash
#!/usr/bin/env bash
|
||
|
||
# shellcheck source=scripts/core/common.sh
|
||
source "${PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}/scripts/core/common.sh"
|
||
|
||
config_tmp_dir() {
|
||
echo "$RUNTIME_DIR/tmp"
|
||
}
|
||
|
||
compile_error_file() {
|
||
echo "$(config_tmp_dir)/compile-error.txt"
|
||
}
|
||
|
||
clear_compile_error() {
|
||
rm -f "$(compile_error_file)" 2>/dev/null || true
|
||
}
|
||
|
||
write_compile_error() {
|
||
local message="$1"
|
||
printf '%s\n' "$message" > "$(compile_error_file)"
|
||
}
|
||
|
||
append_compile_error() {
|
||
local message="$1"
|
||
printf '%s\n' "$message" >> "$(compile_error_file)"
|
||
}
|
||
|
||
read_compile_error() {
|
||
local file
|
||
file="$(compile_error_file)"
|
||
[ -f "$file" ] || return 1
|
||
cat "$file"
|
||
}
|
||
|
||
single_line_text() {
|
||
printf '%s' "$1" | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g; s/^ //; s/ $//'
|
||
}
|
||
|
||
record_build_error_detail() {
|
||
local stage="$1"
|
||
local detail="${2:-}"
|
||
local summary=""
|
||
|
||
detail="$(printf '%s' "$detail" | sed 's/\r$//')"
|
||
|
||
if [ -n "${detail:-}" ]; then
|
||
summary="$(printf '%s\n' "$detail" | head -n 1)"
|
||
fi
|
||
|
||
write_build_value "BUILD_LAST_ERROR_STAGE" "$stage"
|
||
write_build_value "BUILD_LAST_ERROR_SUMMARY" "$(single_line_text "$summary")"
|
||
write_build_value "BUILD_LAST_ERROR_DETAIL" "$detail"
|
||
}
|
||
|
||
clear_build_error_detail() {
|
||
clear_build_error_meta
|
||
}
|
||
|
||
subscriptions_file() {
|
||
echo "$CONFIG_DIR/subscriptions.yaml"
|
||
}
|
||
|
||
migrate_subscriptions_legacy_fields() {
|
||
local file="$1"
|
||
[ -f "$file" ] || return 0
|
||
|
||
if [ -x "$(yq_bin 2>/dev/null || true)" ]; then
|
||
if "$(yq_bin)" eval '(.mode != null) or (.selected != null) or (.policy != null)' "$file" 2>/dev/null | grep -qx 'true'; then
|
||
"$(yq_bin)" eval -i 'del(.mode) | del(.selected) | del(.policy)' "$file" 2>/dev/null || true
|
||
fi
|
||
return 0
|
||
fi
|
||
|
||
# 无 yq 时做最小兜底:仅移除顶层旧字段,避免旧语义继续扩散
|
||
awk '
|
||
$0 ~ /^[[:space:]]*mode:[[:space:]]*/ { next }
|
||
$0 ~ /^[[:space:]]*selected:[[:space:]]*/ { next }
|
||
$0 ~ /^[[:space:]]*policy:[[:space:]]*/ { next }
|
||
{ print }
|
||
' "$file" > "${file}.tmp" && mv "${file}.tmp" "$file"
|
||
}
|
||
|
||
ensure_subscriptions_file() {
|
||
local file
|
||
file="$(subscriptions_file)"
|
||
|
||
if [ -f "$file" ]; then
|
||
migrate_subscriptions_legacy_fields "$file"
|
||
return 0
|
||
fi
|
||
|
||
mkdir -p "$CONFIG_DIR"
|
||
|
||
cat > "$file" <<'EOF'
|
||
active: default
|
||
|
||
sources:
|
||
default:
|
||
type: clash
|
||
url: ""
|
||
enabled: true
|
||
EOF
|
||
|
||
migrate_subscriptions_legacy_fields "$file"
|
||
}
|
||
|
||
csv_count() {
|
||
local csv="$1"
|
||
|
||
if [ -z "${csv:-}" ]; then
|
||
echo "0"
|
||
return 0
|
||
fi
|
||
|
||
printf '%s' "$csv" | awk -F',' '{print ($1==""?0:NF)}'
|
||
}
|
||
|
||
active_subscription_name() {
|
||
local file
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
|
||
"$(yq_bin)" eval '.active // "default"' "$file" 2>/dev/null | head -n 1
|
||
}
|
||
|
||
first_subscription_name() {
|
||
local file
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
|
||
"$(yq_bin)" eval '.sources | keys | .[0] // ""' "$file" 2>/dev/null | head -n 1
|
||
}
|
||
|
||
active_profile_name() {
|
||
local profiles_file="$CONFIG_DIR/profiles.yaml"
|
||
|
||
[ -f "$profiles_file" ] || return 0
|
||
|
||
"$(yq_bin)" eval '.active // ""' "$profiles_file" 2>/dev/null | head -n 1
|
||
}
|
||
|
||
tun_enabled() {
|
||
local value
|
||
|
||
value="$(read_tun_value "TUN_ENABLED" 2>/dev/null || true)"
|
||
case "${value:-false}" in
|
||
true|1|yes|on) echo "true" ;;
|
||
*) echo "false" ;;
|
||
esac
|
||
}
|
||
|
||
set_tun_enabled() {
|
||
local enabled="$1"
|
||
|
||
case "$enabled" in
|
||
true|false) ;;
|
||
*) die "Tun 开关只允许 true 或 false" ;;
|
||
esac
|
||
|
||
write_tun_value "TUN_ENABLED" "$enabled"
|
||
}
|
||
|
||
tun_stack() {
|
||
local value
|
||
|
||
value="$(read_tun_value "TUN_STACK" 2>/dev/null || true)"
|
||
case "${value:-system}" in
|
||
system|gvisor|mixed)
|
||
echo "${value:-system}"
|
||
;;
|
||
*)
|
||
echo "system"
|
||
;;
|
||
esac
|
||
}
|
||
|
||
ensure_config_files() {
|
||
mkdir -p "$CONFIG_DIR" "$(config_tmp_dir)"
|
||
|
||
[ -f "$CONFIG_DIR/template.yaml" ] || cat > "$CONFIG_DIR/template.yaml" <<'EOF'
|
||
mixed-port: 7890
|
||
allow-lan: true
|
||
mode: rule
|
||
log-level: info
|
||
external-controller: 0.0.0.0:9090
|
||
secret: ""
|
||
|
||
tun:
|
||
enable: false
|
||
stack: system
|
||
auto-route: true
|
||
auto-detect-interface: true
|
||
|
||
dns:
|
||
enable: true
|
||
enhanced-mode: fake-ip
|
||
listen: 0.0.0.0:1053
|
||
|
||
proxies: []
|
||
proxy-groups: []
|
||
rules: []
|
||
EOF
|
||
|
||
[ -f "$CONFIG_DIR/mixin.yaml" ] || cat > "$CONFIG_DIR/mixin.yaml" <<'EOF'
|
||
override: {}
|
||
prepend:
|
||
proxies: []
|
||
proxy-groups: []
|
||
rules: []
|
||
append:
|
||
proxies: []
|
||
proxy-groups: []
|
||
rules: []
|
||
EOF
|
||
|
||
[ -f "$CONFIG_DIR/profiles.yaml" ] || cat > "$CONFIG_DIR/profiles.yaml" <<'EOF'
|
||
active: default
|
||
profiles:
|
||
default: {}
|
||
EOF
|
||
}
|
||
|
||
render_base_config() {
|
||
local template_file="$CONFIG_DIR/template.yaml"
|
||
local profiles_file="$CONFIG_DIR/profiles.yaml"
|
||
local out_file="$1"
|
||
local active_profile
|
||
|
||
ensure_config_files
|
||
active_profile="$(active_profile_name)"
|
||
|
||
if [ -n "${active_profile:-}" ] && [ "$active_profile" != "null" ]; then
|
||
TEMPLATE_FILE="$template_file" \
|
||
PROFILES_FILE="$profiles_file" \
|
||
ACTIVE_PROFILE="$active_profile" \
|
||
"$(yq_bin)" eval-all --null-input '
|
||
(
|
||
load(strenv(TEMPLATE_FILE)) // {}
|
||
) * (
|
||
(load(strenv(PROFILES_FILE)).profiles[strenv(ACTIVE_PROFILE)]) // {}
|
||
)
|
||
' > "$out_file"
|
||
else
|
||
TEMPLATE_FILE="$template_file" \
|
||
PROFILES_FILE="$profiles_file" \
|
||
"$(yq_bin)" eval-all --null-input '
|
||
(
|
||
load(strenv(TEMPLATE_FILE)) // {}
|
||
) * (
|
||
load(strenv(PROFILES_FILE)) // {}
|
||
)
|
||
' > "$out_file"
|
||
fi
|
||
}
|
||
|
||
subscription_url_scheme() {
|
||
local url="$1"
|
||
|
||
case "$url" in
|
||
http://*) echo "http" ;;
|
||
https://*) echo "https" ;;
|
||
file://*) echo "file" ;;
|
||
*) echo "unknown" ;;
|
||
esac
|
||
}
|
||
|
||
subscription_url_is_supported() {
|
||
local url="$1"
|
||
|
||
case "$(subscription_url_scheme "$url")" in
|
||
http|https|file)
|
||
return 0
|
||
;;
|
||
*)
|
||
return 1
|
||
;;
|
||
esac
|
||
}
|
||
|
||
subscription_file_path_from_url() {
|
||
local url="$1"
|
||
local path
|
||
|
||
case "$url" in
|
||
file://*)
|
||
path="${url#file://}"
|
||
;;
|
||
*)
|
||
die "不是 file:// 订阅地址:$url"
|
||
;;
|
||
esac
|
||
|
||
[ -n "${path:-}" ] || die "file:// 订阅地址不能为空"
|
||
|
||
echo "$path"
|
||
}
|
||
|
||
copy_local_subscription_yaml() {
|
||
local url="$1"
|
||
local out_file="$2"
|
||
local _fetch_reason="${3:-auto}"
|
||
local local_path
|
||
|
||
local_path="$(subscription_file_path_from_url "$url")"
|
||
|
||
[ -f "$local_path" ] || die "本地订阅文件不存在:$local_path"
|
||
[ -r "$local_path" ] || die "本地订阅文件不可读:$local_path"
|
||
|
||
info "正在读取本地订阅:$local_path"
|
||
cp -f "$local_path" "$out_file"
|
||
|
||
[ -s "$out_file" ] || die "本地订阅文件为空:$local_path"
|
||
}
|
||
|
||
download_subscription_yaml() {
|
||
local url="$1"
|
||
local out_file="$2"
|
||
local fetch_reason="${3:-auto}"
|
||
local scheme fmt
|
||
|
||
[ -n "${url:-}" ] || return 1
|
||
|
||
scheme="$(subscription_url_scheme "$url")"
|
||
fmt="clash"
|
||
|
||
case "$scheme" in
|
||
http|https)
|
||
if subscription_cache_restore "$url" "$fmt" "$out_file"; then
|
||
return 0
|
||
fi
|
||
|
||
require_subscription_fetch_allowed "$fetch_reason" "$url"
|
||
|
||
download_text_file \
|
||
"$url" \
|
||
"$out_file" \
|
||
"subscription" \
|
||
"$(subscription_user_agent)" \
|
||
"10" \
|
||
"45"
|
||
|
||
subscription_cache_store "$url" "$fmt" "$out_file" "$url"
|
||
;;
|
||
file)
|
||
copy_local_subscription_yaml "$url" "$out_file" "$fetch_reason"
|
||
;;
|
||
*)
|
||
die "不支持的订阅协议:$url"
|
||
;;
|
||
esac
|
||
}
|
||
|
||
subscription_cache_identity() {
|
||
local url="$1"
|
||
local fmt="${2:-clash}"
|
||
printf '%s|%s' "$fmt" "$url"
|
||
}
|
||
|
||
subscription_cache_key() {
|
||
local url="$1"
|
||
local fmt="${2:-clash}"
|
||
download_cache_key "$(subscription_cache_identity "$url" "$fmt")"
|
||
}
|
||
|
||
subscription_cache_file() {
|
||
local url="$1"
|
||
local fmt="${2:-clash}"
|
||
echo "$(download_cache_dir)/$(subscription_cache_key "$url" "$fmt").bin"
|
||
}
|
||
|
||
subscription_cache_meta_file() {
|
||
local url="$1"
|
||
local fmt="${2:-clash}"
|
||
echo "$(download_cache_dir)/$(subscription_cache_key "$url" "$fmt").meta"
|
||
}
|
||
|
||
subscription_cache_restore() {
|
||
local url="$1"
|
||
local fmt="${2:-clash}"
|
||
local out="$3"
|
||
local cache_file
|
||
|
||
cache_file="$(subscription_cache_file "$url" "$fmt")"
|
||
[ -s "$cache_file" ] || return 1
|
||
|
||
mkdir -p "$(dirname "$out")"
|
||
cp -f "$cache_file" "$out"
|
||
return 0
|
||
}
|
||
|
||
subscription_cache_store() {
|
||
local url="$1"
|
||
local fmt="${2:-clash}"
|
||
local src="$3"
|
||
local source_url="${4:-}"
|
||
local cache_file meta_file
|
||
|
||
[ -s "$src" ] || return 0
|
||
|
||
cache_file="$(subscription_cache_file "$url" "$fmt")"
|
||
meta_file="$(subscription_cache_meta_file "$url" "$fmt")"
|
||
|
||
mkdir -p "$(download_cache_dir)"
|
||
cp -f "$src" "$cache_file"
|
||
|
||
cat > "$meta_file" <<EOF
|
||
CACHE_URL="$url"
|
||
CACHE_FORMAT="$fmt"
|
||
CACHE_SOURCE_URL="$source_url"
|
||
CACHE_TIME="$(now_datetime)"
|
||
EOF
|
||
}
|
||
|
||
clear_subscription_cache() {
|
||
local url="$1"
|
||
local fmt="${2:-}"
|
||
|
||
[ -n "${url:-}" ] || return 0
|
||
|
||
if [ -n "${fmt:-}" ]; then
|
||
rm -f "$(subscription_cache_file "$url" "$fmt")" 2>/dev/null || true
|
||
rm -f "$(subscription_cache_meta_file "$url" "$fmt")" 2>/dev/null || true
|
||
return 0
|
||
fi
|
||
|
||
rm -f "$(subscription_cache_file "$url" "clash")" 2>/dev/null || true
|
||
rm -f "$(subscription_cache_meta_file "$url" "clash")" 2>/dev/null || true
|
||
rm -f "$(subscription_cache_file "$url" "convert")" 2>/dev/null || true
|
||
rm -f "$(subscription_cache_meta_file "$url" "convert")" 2>/dev/null || true
|
||
}
|
||
|
||
normalize_runtime_config() {
|
||
local file="$1"
|
||
local mixed_port controller tun_enable_value tun_stack_value dns_port_value controller_secret_value
|
||
local dashboard_dir_value
|
||
local resolved_ports
|
||
|
||
[ -s "$file" ] || die "待规范化的配置文件不存在:$file"
|
||
|
||
resolved_ports="$(resolve_runtime_ports)"
|
||
eval "$resolved_ports"
|
||
|
||
mixed_port="$MIXED_PORT_RESOLVED"
|
||
controller="$EXTERNAL_CONTROLLER_RESOLVED"
|
||
tun_enable_value="$(tun_enabled)"
|
||
tun_stack_value="$(tun_stack)"
|
||
dns_port_value="$CLASH_DNS_PORT_RESOLVED"
|
||
controller_secret_value="$(ensure_controller_secret)"
|
||
dashboard_dir_value="$(runtime_dashboard_dir)"
|
||
|
||
mixed_port="$mixed_port" \
|
||
controller="$controller" \
|
||
tun_enable_value="$tun_enable_value" \
|
||
tun_stack_value="$tun_stack_value" \
|
||
controller_secret_value="$controller_secret_value" \
|
||
dashboard_dir_value="$dashboard_dir_value" \
|
||
dns_listen_value="0.0.0.0:${dns_port_value}" \
|
||
"$(yq_bin)" eval -i '
|
||
.["mixed-port"] = (env(mixed_port) | tonumber) |
|
||
.["external-controller"] = env(controller) |
|
||
.secret = env(controller_secret_value) |
|
||
.["external-ui"] = env(dashboard_dir_value) |
|
||
.["external-ui-url"] = "/ui" |
|
||
.["allow-lan"] = (.["allow-lan"] // true) |
|
||
.mode = (.mode // "rule") |
|
||
.["log-level"] = (.["log-level"] // "info") |
|
||
|
||
.tun.enable = (env(tun_enable_value) == "true") |
|
||
.tun.stack = env(tun_stack_value) |
|
||
.tun["auto-route"] = (.tun["auto-route"] // true) |
|
||
.tun["auto-detect-interface"] = (.tun["auto-detect-interface"] // true) |
|
||
|
||
.dns.enable = (.dns.enable // true) |
|
||
.dns["enhanced-mode"] = (.dns["enhanced-mode"] // "fake-ip") |
|
||
.dns.listen = env(dns_listen_value) |
|
||
|
||
.proxies = (.proxies // []) |
|
||
.["proxy-groups"] = (.["proxy-groups"] // []) |
|
||
.rules = (.rules // [])
|
||
' "$file"
|
||
}
|
||
|
||
generate_secure_secret() {
|
||
if command -v openssl >/dev/null 2>&1; then
|
||
openssl rand -hex 24 2>/dev/null | head -n 1
|
||
return 0
|
||
fi
|
||
|
||
head -c 32 /dev/urandom 2>/dev/null | od -An -tx1 | tr -d ' \n'
|
||
}
|
||
|
||
is_valid_controller_secret() {
|
||
local value="${1:-}"
|
||
case "${value}" in
|
||
""|null|NULL|undefined|UNDEFINED|\"\"|\'\')
|
||
return 1
|
||
;;
|
||
*)
|
||
return 0
|
||
;;
|
||
esac
|
||
}
|
||
|
||
ensure_controller_secret() {
|
||
local value
|
||
|
||
value="${CLASH_CONTROLLER_SECRET:-}"
|
||
if ! is_valid_controller_secret "$value"; then
|
||
value="$(read_env_value "CLASH_CONTROLLER_SECRET" 2>/dev/null || true)"
|
||
fi
|
||
|
||
if ! is_valid_controller_secret "$value"; then
|
||
value="$(generate_secure_secret)"
|
||
[ -n "${value:-}" ] || die "无法生成控制器密钥"
|
||
write_env_value "CLASH_CONTROLLER_SECRET" "$value"
|
||
fi
|
||
|
||
echo "$value"
|
||
}
|
||
|
||
clear_controller_secret() {
|
||
unset_env_value "CLASH_CONTROLLER_SECRET" || true
|
||
unset CLASH_CONTROLLER_SECRET || true
|
||
}
|
||
|
||
first_available_proxy_name() {
|
||
local file="$1"
|
||
|
||
[ -s "$file" ] || return 1
|
||
|
||
"$(yq_bin)" eval '.proxies[0].name // ""' "$file" 2>/dev/null | head -n 1
|
||
}
|
||
|
||
group_type_requires_members() {
|
||
case "$1" in
|
||
select|url-test|fallback|load-balance)
|
||
return 0
|
||
;;
|
||
*)
|
||
return 1
|
||
;;
|
||
esac
|
||
}
|
||
|
||
builtin_policy_name() {
|
||
case "$1" in
|
||
DIRECT|REJECT|REJECT-DROP|PASS)
|
||
return 0
|
||
;;
|
||
*)
|
||
return 1
|
||
;;
|
||
esac
|
||
}
|
||
|
||
record_build_failure() {
|
||
local mode="$1"
|
||
local policy="$2"
|
||
local active="$3"
|
||
local selected="$4"
|
||
local included="$5"
|
||
local failed="$6"
|
||
|
||
# active-only 元数据:只记录当前实际参与编译的 active 订阅集合
|
||
# 为避免旧状态文件混淆,同时清空旧 key
|
||
write_build_value "BUILD_ACTIVE_SOURCE" "$active"
|
||
write_build_value "BUILD_ACTIVE_SOURCES" "$included"
|
||
write_build_value "BUILD_FAILED_ACTIVE_SOURCES" "$failed"
|
||
write_build_value "BUILD_SELECTED_SOURCES" ""
|
||
write_build_value "BUILD_INCLUDED_SOURCES" ""
|
||
write_build_value "BUILD_FAILED_SOURCES" ""
|
||
write_build_value "BUILD_LAST_STATUS" "failed"
|
||
write_build_value "BUILD_LAST_TIME" "$(now_datetime)"
|
||
}
|
||
|
||
record_build_success() {
|
||
local mode="$1"
|
||
local policy="$2"
|
||
local active="$3"
|
||
local selected="$4"
|
||
local included="$5"
|
||
local failed="$6"
|
||
|
||
write_build_value "BUILD_ACTIVE_SOURCE" "$active"
|
||
write_build_value "BUILD_ACTIVE_SOURCES" "$included"
|
||
write_build_value "BUILD_FAILED_ACTIVE_SOURCES" "$failed"
|
||
write_build_value "BUILD_SELECTED_SOURCES" ""
|
||
write_build_value "BUILD_INCLUDED_SOURCES" ""
|
||
write_build_value "BUILD_FAILED_SOURCES" ""
|
||
write_build_value "BUILD_LAST_STATUS" "success"
|
||
write_build_value "BUILD_LAST_TIME" "$(now_datetime)"
|
||
clear_build_error_detail
|
||
write_runtime_event_value "RUNTIME_LAST_BUILD_BLOCK_REASON" ""
|
||
write_runtime_event_value "RUNTIME_LAST_BUILD_BLOCK_TIME" ""
|
||
mark_runtime_build_applied "build-success"
|
||
}
|
||
|
||
fail_build_with_detail() {
|
||
local stage="$1"
|
||
local mode="$2"
|
||
local policy="$3"
|
||
local active="$4"
|
||
local selected="$5"
|
||
local included="$6"
|
||
local failed="$7"
|
||
local fallback_message="$8"
|
||
local detail
|
||
|
||
detail="$(read_compile_error 2>/dev/null || true)"
|
||
[ -n "${detail:-}" ] || detail="$fallback_message"
|
||
|
||
record_build_error_detail "$stage" "$detail"
|
||
record_build_failure "$mode" "$policy" "$active" "$selected" "$included" "$failed"
|
||
mark_runtime_build_not_applied "$stage"
|
||
die "$detail"
|
||
}
|
||
|
||
health_key_prefix() {
|
||
local name="$1"
|
||
printf '%s' "$name" | tr '[:lower:]-.' '[:upper:]__'
|
||
}
|
||
|
||
read_subscription_health_value() {
|
||
local name="$1"
|
||
local field="$2"
|
||
local file key prefix
|
||
|
||
file="$(subscription_health_file)"
|
||
[ -f "$file" ] || return 1
|
||
|
||
prefix="$(health_key_prefix "$name")"
|
||
key="SUB_HEALTH_${prefix}_${field}"
|
||
|
||
sed -nE "s/^[[:space:]]*${key}=['\"]?([^'\"]*)['\"]?$/\1/p" "$file" | head -n 1
|
||
}
|
||
|
||
write_subscription_health_value() {
|
||
local name="$1"
|
||
local field="$2"
|
||
local value="$3"
|
||
local file key prefix
|
||
|
||
file="$(subscription_health_file)"
|
||
mkdir -p "$(dirname "$file")"
|
||
touch "$file"
|
||
|
||
prefix="$(health_key_prefix "$name")"
|
||
key="SUB_HEALTH_${prefix}_${field}"
|
||
|
||
if grep -qE "^[[:space:]]*${key}=" "$file"; then
|
||
awk -v k="$key" -v v="$value" '
|
||
$0 ~ "^[[:space:]]*" k "=" {
|
||
print k "=\"" v "\""
|
||
next
|
||
}
|
||
{ print }
|
||
' "$file" > "${file}.tmp" && mv "${file}.tmp" "$file"
|
||
else
|
||
printf '%s="%s"\n' "$key" "$value" >> "$file"
|
||
fi
|
||
}
|
||
|
||
clear_subscription_health_all_fields() {
|
||
local name="$1"
|
||
local fields field
|
||
|
||
fields="STATUS LAST_SUCCESS LAST_FAILURE LAST_ERROR FAIL_COUNT AUTO_DISABLED AUTO_DISABLED_AT"
|
||
|
||
for field in $fields; do
|
||
write_subscription_health_value "$name" "$field" ""
|
||
done
|
||
}
|
||
|
||
mark_subscription_health_success() {
|
||
local name="$1"
|
||
local now
|
||
|
||
now="$(now_datetime)"
|
||
|
||
write_subscription_health_value "$name" "STATUS" "success"
|
||
write_subscription_health_value "$name" "LAST_SUCCESS" "$now"
|
||
write_subscription_health_value "$name" "LAST_ERROR" ""
|
||
write_subscription_health_value "$name" "FAIL_COUNT" "0"
|
||
|
||
clear_subscription_auto_disabled_mark "$name"
|
||
}
|
||
|
||
mark_subscription_health_failure() {
|
||
local name="$1"
|
||
local reason="$2"
|
||
local now fail_count
|
||
|
||
now="$(now_datetime)"
|
||
fail_count="$(read_subscription_health_value "$name" "FAIL_COUNT" 2>/dev/null || echo "0")"
|
||
fail_count="${fail_count:-0}"
|
||
|
||
case "$fail_count" in
|
||
''|*[!0-9]*)
|
||
fail_count="0"
|
||
;;
|
||
esac
|
||
|
||
fail_count=$((fail_count + 1))
|
||
|
||
write_subscription_health_value "$name" "STATUS" "failed"
|
||
write_subscription_health_value "$name" "LAST_FAILURE" "$now"
|
||
write_subscription_health_value "$name" "LAST_ERROR" "$reason"
|
||
write_subscription_health_value "$name" "FAIL_COUNT" "$fail_count"
|
||
|
||
maybe_auto_disable_subscription "$name"
|
||
}
|
||
|
||
print_subscription_health_summary() {
|
||
local file name status fail_count last_success last_failure risk_marker active enabled_text found="false"
|
||
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
active="$(active_subscription_name 2>/dev/null || true)"
|
||
|
||
echo " 名称 启用状态 健康状态 失败次数 最近成功 最近失败"
|
||
while IFS= read -r name; do
|
||
[ -n "${name:-}" ] || continue
|
||
found="true"
|
||
|
||
status="$(read_subscription_health_value "$name" "STATUS" 2>/dev/null || echo "unknown")"
|
||
fail_count="$(read_subscription_health_value "$name" "FAIL_COUNT" 2>/dev/null || echo "0")"
|
||
last_success="$(read_subscription_health_value "$name" "LAST_SUCCESS" 2>/dev/null || true)"
|
||
last_failure="$(read_subscription_health_value "$name" "LAST_FAILURE" 2>/dev/null || true)"
|
||
|
||
if subscription_enabled "$name"; then
|
||
enabled_text="enabled"
|
||
else
|
||
enabled_text="disabled"
|
||
fi
|
||
|
||
risk_marker=""
|
||
if subscription_auto_disabled "$name"; then
|
||
risk_marker="⚠️"
|
||
fi
|
||
|
||
if [ "$name" = "$active" ]; then
|
||
printf ' * %-16s %-9s %-8s fail=%-3s %-20s %-20s %s\n' \
|
||
"$name" \
|
||
"$enabled_text" \
|
||
"$status" \
|
||
"$fail_count" \
|
||
"${last_success:--}" \
|
||
"${last_failure:--}" \
|
||
"$risk_marker"
|
||
else
|
||
printf ' %-16s %-9s %-8s fail=%-3s %-20s %-20s %s\n' \
|
||
"$name" \
|
||
"$enabled_text" \
|
||
"$status" \
|
||
"$fail_count" \
|
||
"${last_success:--}" \
|
||
"${last_failure:--}" \
|
||
"$risk_marker"
|
||
fi
|
||
done < <("$(yq_bin)" eval '.sources | keys | .[]' "$file")
|
||
|
||
[ "$found" = "true" ] || echo " 📭 暂无订阅健康数据"
|
||
}
|
||
|
||
print_subscription_health_one() {
|
||
local name="$1"
|
||
local status fail_count last_success last_failure last_error auto_disabled auto_disabled_at enabled_text type_text url_text
|
||
|
||
[ -n "${name:-}" ] || die_usage "订阅名称不能为空" "clashctl health <订阅名称>"
|
||
subscription_exists "$name" || die_missing "订阅 $name" "clashctl ls"
|
||
|
||
status="$(read_subscription_health_value "$name" "STATUS" 2>/dev/null || echo "unknown")"
|
||
fail_count="$(read_subscription_health_value "$name" "FAIL_COUNT" 2>/dev/null || echo "0")"
|
||
last_success="$(read_subscription_health_value "$name" "LAST_SUCCESS" 2>/dev/null || true)"
|
||
last_failure="$(read_subscription_health_value "$name" "LAST_FAILURE" 2>/dev/null || true)"
|
||
last_error="$(read_subscription_health_value "$name" "LAST_ERROR" 2>/dev/null || true)"
|
||
auto_disabled="$(read_subscription_health_value "$name" "AUTO_DISABLED" 2>/dev/null || true)"
|
||
auto_disabled_at="$(read_subscription_health_value "$name" "AUTO_DISABLED_AT" 2>/dev/null || true)"
|
||
|
||
if subscription_enabled "$name"; then
|
||
enabled_text="enabled"
|
||
else
|
||
enabled_text="disabled"
|
||
fi
|
||
|
||
type_text="$(subscription_format_by_name "$name" 2>/dev/null || echo "clash")"
|
||
url_text="$(subscription_url_by_name "$name" 2>/dev/null || true)"
|
||
|
||
echo "📡 订阅名称:$name"
|
||
echo "⚙️ 订阅类型:$type_text"
|
||
echo "🟢 启用状态:$enabled_text"
|
||
echo "❤️ 健康状态:${status:-unknown}"
|
||
echo "⚠️ 失败次数:${fail_count:-0}"
|
||
[ -n "${last_success:-}" ] && echo "🕒 最近成功:$last_success"
|
||
[ -n "${last_failure:-}" ] && echo "🕒 最近失败:$last_failure"
|
||
[ -n "${last_error:-}" ] && echo "❌ 最近错误:$last_error"
|
||
[ -n "${auto_disabled:-}" ] && echo "⚠️ 风险标记:$auto_disabled"
|
||
[ -n "${auto_disabled_at:-}" ] && echo "🕒 风险时间:$auto_disabled_at"
|
||
[ -n "${url_text:-}" ] && echo "🔗 订阅地址:$url_text"
|
||
}
|
||
|
||
print_subscription_health_verbose() {
|
||
local file active name enabled fmt url
|
||
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
active="$(active_subscription_name 2>/dev/null || true)"
|
||
|
||
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"
|
||
echo
|
||
done < <("$(yq_bin)" eval '.sources | keys | .[]' "$file")
|
||
}
|
||
|
||
health_verbose_explanation_lines() {
|
||
local file name total_count failed_count risky_count active active_health
|
||
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
active="$(active_subscription_name 2>/dev/null || true)"
|
||
active_health="$(subscription_health_status "$active" 2>/dev/null || echo "unknown")"
|
||
|
||
total_count=0
|
||
failed_count=0
|
||
risky_count=0
|
||
|
||
while IFS= read -r name; do
|
||
[ -n "${name:-}" ] || continue
|
||
total_count=$((total_count + 1))
|
||
|
||
if [ "$(subscription_health_status "$name" 2>/dev/null || echo "unknown")" = "failed" ]; then
|
||
failed_count=$((failed_count + 1))
|
||
fi
|
||
|
||
if subscription_auto_disabled "$name"; then
|
||
risky_count=$((risky_count + 1))
|
||
fi
|
||
done < <("$(yq_bin)" eval '.sources | keys | .[]' "$file")
|
||
|
||
if [ -n "${active:-}" ]; then
|
||
if [ "${active_health:-unknown}" = "success" ]; then
|
||
echo "- 当前主订阅健康正常"
|
||
else
|
||
echo "- 当前主订阅健康异常:${active_health:-unknown}"
|
||
fi
|
||
else
|
||
echo "- 当前还没有主订阅"
|
||
fi
|
||
|
||
echo "- 当前共有 ${total_count} 个订阅"
|
||
|
||
if [ "$failed_count" -gt 0 ]; then
|
||
echo "- 发现 ${failed_count} 个失败订阅"
|
||
else
|
||
echo "- 当前没有失败订阅"
|
||
fi
|
||
|
||
if [ "$risky_count" -gt 0 ]; then
|
||
echo "- 发现 ${risky_count} 个达到风险阈值的订阅"
|
||
else
|
||
echo "- 当前没有达到风险阈值的订阅"
|
||
fi
|
||
}
|
||
|
||
subscription_auto_disable_threshold() {
|
||
echo "${SUBSCRIPTION_AUTO_DISABLE_THRESHOLD:-3}"
|
||
}
|
||
|
||
subscription_is_auto_disable_enabled() {
|
||
case "${SUBSCRIPTION_AUTO_DISABLE_ENABLED:-true}" in
|
||
true|1|yes|on) return 0 ;;
|
||
false|0|no|off) return 1 ;;
|
||
*) return 0 ;;
|
||
esac
|
||
}
|
||
|
||
set_subscription_enabled() {
|
||
local name="$1"
|
||
local enabled="$2"
|
||
local file
|
||
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
subscription_exists "$name" || die_missing "订阅 $name" "clashctl ls"
|
||
|
||
case "$enabled" in
|
||
true|false) ;;
|
||
*) die "enabled 只允许 true 或 false" ;;
|
||
esac
|
||
|
||
NAME="$name" ENABLED="$enabled" "$(yq_bin)" eval -i '
|
||
.sources[env(NAME)].enabled = (env(ENABLED) == "true")
|
||
' "$file"
|
||
}
|
||
|
||
mark_subscription_auto_disabled() {
|
||
local name="$1"
|
||
local now="$2"
|
||
|
||
write_subscription_health_value "$name" "AUTO_DISABLED" "true"
|
||
write_subscription_health_value "$name" "AUTO_DISABLED_AT" "$now"
|
||
}
|
||
|
||
clear_subscription_auto_disabled_mark() {
|
||
local name="$1"
|
||
|
||
write_subscription_health_value "$name" "AUTO_DISABLED" ""
|
||
write_subscription_health_value "$name" "AUTO_DISABLED_AT" ""
|
||
|
||
if [ "$(read_runtime_event_value "RUNTIME_LAST_SUBSCRIPTION_RISK_NAME" 2>/dev/null || true)" = "$name" ]; then
|
||
write_runtime_event_value "RUNTIME_LAST_SUBSCRIPTION_RISK_NAME" ""
|
||
write_runtime_event_value "RUNTIME_LAST_SUBSCRIPTION_RISK_TIME" ""
|
||
write_runtime_event_value "RUNTIME_LAST_SUBSCRIPTION_RISK_REASON" ""
|
||
write_runtime_event_value "RUNTIME_LAST_SUBSCRIPTION_RISK_FAIL_COUNT" ""
|
||
write_runtime_event_value "RUNTIME_LAST_SUBSCRIPTION_RISK_THRESHOLD" ""
|
||
fi
|
||
}
|
||
|
||
maybe_auto_disable_subscription() {
|
||
local name="$1"
|
||
local fail_count threshold now
|
||
|
||
subscription_is_auto_disable_enabled || return 0
|
||
|
||
fail_count="$(read_subscription_health_value "$name" "FAIL_COUNT" 2>/dev/null || echo "0")"
|
||
threshold="$(subscription_auto_disable_threshold)"
|
||
|
||
case "$fail_count" in
|
||
''|*[!0-9]*) fail_count="0" ;;
|
||
esac
|
||
|
||
case "$threshold" in
|
||
''|*[!0-9]*) threshold="3" ;;
|
||
esac
|
||
|
||
[ "$threshold" -gt 0 ] || threshold="3"
|
||
|
||
if [ "$fail_count" -lt "$threshold" ]; then
|
||
return 0
|
||
fi
|
||
|
||
now="$(now_datetime)"
|
||
|
||
# 只记录风险,不自动禁用
|
||
write_subscription_health_value "$name" "AUTO_DISABLED" "threshold-reached"
|
||
write_subscription_health_value "$name" "AUTO_DISABLED_AT" "$now"
|
||
|
||
write_runtime_event_value "RUNTIME_LAST_SUBSCRIPTION_RISK_NAME" "$name"
|
||
write_runtime_event_value "RUNTIME_LAST_SUBSCRIPTION_RISK_TIME" "$now"
|
||
write_runtime_event_value "RUNTIME_LAST_SUBSCRIPTION_RISK_REASON" "fail-threshold-reached"
|
||
write_runtime_event_value "RUNTIME_LAST_SUBSCRIPTION_RISK_FAIL_COUNT" "$fail_count"
|
||
write_runtime_event_value "RUNTIME_LAST_SUBSCRIPTION_RISK_THRESHOLD" "$threshold"
|
||
|
||
warn "订阅源连续失败达到阈值,但不会自动禁用:$name"
|
||
}
|
||
|
||
print_build_explain() {
|
||
local active last_status last_time error_stage error_summary error_detail
|
||
|
||
prepare
|
||
|
||
active="$(active_subscription_name 2>/dev/null || true)"
|
||
last_status="$(status_build_last_status 2>/dev/null || true)"
|
||
last_time="$(status_build_last_time 2>/dev/null || true)"
|
||
error_stage="$(status_build_last_error_stage 2>/dev/null || true)"
|
||
error_summary="$(status_build_last_error_summary 2>/dev/null || true)"
|
||
error_detail="$(status_build_last_error_detail 2>/dev/null || true)"
|
||
|
||
ui_title "🧩 编译说明"
|
||
echo "- 当前编译链只处理 active 主订阅"
|
||
echo "- 当前主订阅:${active:-未设置}"
|
||
|
||
case "${last_status:-unknown}" in
|
||
success)
|
||
echo "- 最近一次构建成功${last_time:+:${last_time}}"
|
||
echo "- 处理顺序:下载 / 校验 / 必要时 convert / 运行校验 / 输出 runtime/config.yaml"
|
||
;;
|
||
failed)
|
||
echo "- 最近一次构建失败${last_time:+:${last_time}}"
|
||
[ -n "${error_stage:-}" ] && echo "- 失败阶段:${error_stage}"
|
||
[ -n "${error_summary:-}" ] && echo "- 失败摘要:${error_summary}"
|
||
[ -n "${error_detail:-}" ] && echo
|
||
[ -n "${error_detail:-}" ] && printf '%s\n' "$error_detail"
|
||
;;
|
||
*)
|
||
echo "- 当前还没有可解释的构建记录"
|
||
;;
|
||
esac
|
||
|
||
echo
|
||
ui_next "clashctl status --verbose"
|
||
ui_blank
|
||
}
|
||
|
||
build_active_sources_csv() {
|
||
local value
|
||
value="$(read_build_value "BUILD_ACTIVE_SOURCES" 2>/dev/null || true)"
|
||
if [ -n "${value:-}" ]; then
|
||
echo "$value"
|
||
return 0
|
||
fi
|
||
|
||
# 兼容历史 build.env
|
||
value="$(read_build_value "BUILD_INCLUDED_SOURCES" 2>/dev/null || true)"
|
||
[ -n "${value:-}" ] && echo "$value"
|
||
}
|
||
|
||
build_failed_active_sources_csv() {
|
||
local value
|
||
value="$(read_build_value "BUILD_FAILED_ACTIVE_SOURCES" 2>/dev/null || true)"
|
||
if [ -n "${value:-}" ]; then
|
||
echo "$value"
|
||
return 0
|
||
fi
|
||
|
||
# 兼容历史 build.env
|
||
value="$(read_build_value "BUILD_FAILED_SOURCES" 2>/dev/null || true)"
|
||
[ -n "${value:-}" ] && echo "$value"
|
||
}
|
||
|
||
build_last_status() {
|
||
read_build_value "BUILD_LAST_STATUS" 2>/dev/null || true
|
||
}
|
||
|
||
build_last_time() {
|
||
read_build_value "BUILD_LAST_TIME" 2>/dev/null || true
|
||
}
|
||
|
||
build_last_error_stage() {
|
||
read_build_value "BUILD_LAST_ERROR_STAGE" 2>/dev/null || true
|
||
}
|
||
|
||
build_last_error_summary() {
|
||
read_build_value "BUILD_LAST_ERROR_SUMMARY" 2>/dev/null || true
|
||
}
|
||
|
||
build_last_error_detail() {
|
||
read_build_value "BUILD_LAST_ERROR_DETAIL" 2>/dev/null || true
|
||
}
|
||
|
||
build_success_explanation_lines() {
|
||
local included_csv included_count
|
||
|
||
included_csv="$(build_active_sources_csv)"
|
||
included_count="$(csv_count "$included_csv")"
|
||
|
||
echo "- active-only 编译链仅处理当前主订阅"
|
||
echo "- 当前成功源数量:${included_count}"
|
||
}
|
||
|
||
build_failure_explanation_lines() {
|
||
local included_csv included_count error_stage
|
||
|
||
included_csv="$(build_active_sources_csv)"
|
||
failed_csv="$(build_failed_active_sources_csv)"
|
||
included_count="$(csv_count "$included_csv")"
|
||
error_stage="$(build_last_error_stage)"
|
||
|
||
echo "- active-only 编译链仅处理当前主订阅"
|
||
if [ "$error_stage" = "resolve-active-source" ]; then
|
||
echo "- 当前未找到可用的 active 主订阅"
|
||
elif [ -n "${failed_csv:-}" ]; then
|
||
echo "- 当前 active 主订阅拉取或校验失败"
|
||
fi
|
||
echo "- 当前成功源数量:${included_count}"
|
||
}
|
||
|
||
print_build_failed_source_lines() {
|
||
local failed_csv name last_error
|
||
|
||
failed_csv="$(build_failed_active_sources_csv)"
|
||
[ -n "${failed_csv:-}" ] || return 0
|
||
|
||
IFS=',' read -r -a _failed_names <<< "$failed_csv"
|
||
for name in "${_failed_names[@]}"; do
|
||
[ -n "${name:-}" ] || continue
|
||
last_error="$(subscription_last_error "$name" 2>/dev/null || true)"
|
||
if [ -n "${last_error:-}" ]; then
|
||
echo "- ${name}:${last_error}"
|
||
else
|
||
echo "- ${name}:未知错误"
|
||
fi
|
||
done
|
||
}
|
||
|
||
build_explain_next_steps() {
|
||
local last_status failed_csv
|
||
|
||
last_status="$(build_last_status)"
|
||
failed_csv="$(build_failed_active_sources_csv)"
|
||
|
||
if [ "${last_status:-}" = "success" ]; then
|
||
echo "👉 clashctl health"
|
||
echo "👉 clashctl select"
|
||
return 0
|
||
fi
|
||
|
||
if [ -n "${failed_csv:-}" ]; then
|
||
echo "👉 clashctl health"
|
||
fi
|
||
|
||
echo "👉 clashctl doctor"
|
||
}
|
||
|
||
runtime_config_file() {
|
||
echo "$RUNTIME_DIR/config.yaml"
|
||
}
|
||
|
||
runtime_last_good_config_file() {
|
||
echo "$RUNTIME_DIR/config.last.yaml"
|
||
}
|
||
|
||
save_last_known_good_config() {
|
||
local source_file="${1:-}"
|
||
local last_file
|
||
|
||
last_file="$(runtime_last_good_config_file)"
|
||
|
||
if [ -z "${source_file:-}" ]; then
|
||
source_file="$(runtime_config_file)"
|
||
fi
|
||
|
||
[ -s "$source_file" ] || return 0
|
||
|
||
cp -f "$source_file" "$last_file"
|
||
}
|
||
|
||
restore_last_known_good_config() {
|
||
local current_file last_file
|
||
|
||
current_file="$(runtime_config_file)"
|
||
last_file="$(runtime_last_good_config_file)"
|
||
|
||
[ -s "$last_file" ] || return 1
|
||
|
||
cp -f "$last_file" "$current_file"
|
||
}
|
||
|
||
test_runtime_config() {
|
||
local config_file="${1:-$RUNTIME_DIR/config.yaml}"
|
||
|
||
[ -s "$config_file" ] || die "配置文件不存在:$config_file"
|
||
|
||
"$(runtime_kernel_bin)" -t -f "$config_file" -d "$RUNTIME_DIR" >/dev/null
|
||
success "配置校验通过"
|
||
}
|
||
|
||
list_profiles() {
|
||
local profiles_file="$CONFIG_DIR/profiles.yaml"
|
||
|
||
ensure_config_files
|
||
|
||
"$(yq_bin)" eval '.profiles | keys | .[]' "$profiles_file" 2>/dev/null
|
||
}
|
||
|
||
profile_exists() {
|
||
local profile_name="$1"
|
||
local profiles_file="$CONFIG_DIR/profiles.yaml"
|
||
|
||
ensure_config_files
|
||
|
||
[ -n "${profile_name:-}" ] || return 1
|
||
|
||
[ "$(
|
||
"$(yq_bin)" eval ".profiles | has(\"$profile_name\")" "$profiles_file" 2>/dev/null
|
||
)" = "true" ]
|
||
}
|
||
|
||
set_active_profile() {
|
||
local profile_name="$1"
|
||
local profiles_file="$CONFIG_DIR/profiles.yaml"
|
||
|
||
[ -n "${profile_name:-}" ] || die "Profile 名称不能为空"
|
||
|
||
ensure_config_files
|
||
|
||
profile_exists "$profile_name" || die "Profile 不存在:$profile_name"
|
||
|
||
PROFILE_NAME="$profile_name" "$(yq_bin)" eval -i '
|
||
.active = env(PROFILE_NAME)
|
||
' "$profiles_file"
|
||
|
||
success "当前 Profile 已切换为:$profile_name"
|
||
}
|
||
|
||
show_active_profile() {
|
||
local name
|
||
name="$(active_profile_name)"
|
||
|
||
if [ -n "${name:-}" ] && [ "$name" != "null" ]; then
|
||
echo "$name"
|
||
else
|
||
echo "default"
|
||
fi
|
||
}
|
||
|
||
print_profile_list() {
|
||
local active name found="false"
|
||
|
||
active="$(show_active_profile)"
|
||
|
||
while IFS= read -r name; do
|
||
[ -n "${name:-}" ] || continue
|
||
found="true"
|
||
if [ "$name" = "$active" ]; then
|
||
echo "* $name"
|
||
else
|
||
echo " $name"
|
||
fi
|
||
done < <(list_profiles)
|
||
|
||
[ "$found" = "true" ] || echo "未找到任何 Profile"
|
||
}
|
||
|
||
subscription_url() {
|
||
local file active
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
|
||
active="$(active_subscription_name)"
|
||
"$(yq_bin)" eval ".sources.${active}.url // \"\"" "$file" 2>/dev/null | head -n 1
|
||
}
|
||
|
||
subscription_format() {
|
||
local file active
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
|
||
active="$(active_subscription_name)"
|
||
"$(yq_bin)" eval ".sources.${active}.type // \"clash\"" "$file" 2>/dev/null | head -n 1
|
||
}
|
||
|
||
subscription_exists() {
|
||
local name="$1"
|
||
local file
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
|
||
[ "$("$(yq_bin)" eval ".sources | has(\"$name\")" "$file" 2>/dev/null)" = "true" ]
|
||
}
|
||
|
||
subscription_enabled() {
|
||
local name="$1"
|
||
local file
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
|
||
[ "$("$(yq_bin)" eval ".sources.${name}.enabled // false" "$file" 2>/dev/null)" = "true" ]
|
||
}
|
||
|
||
subscription_auto_disabled() {
|
||
local name="$1"
|
||
local value
|
||
|
||
value="$(read_subscription_health_value "$name" "AUTO_DISABLED" 2>/dev/null || true)"
|
||
[ "${value:-}" = "true" ]
|
||
}
|
||
|
||
subscription_fail_count() {
|
||
local name="$1"
|
||
read_subscription_health_value "$name" "FAIL_COUNT" 2>/dev/null || echo "0"
|
||
}
|
||
|
||
subscription_last_error() {
|
||
local name="$1"
|
||
read_subscription_health_value "$name" "LAST_ERROR" 2>/dev/null || true
|
||
}
|
||
|
||
subscription_last_success() {
|
||
local name="$1"
|
||
read_subscription_health_value "$name" "LAST_SUCCESS" 2>/dev/null || true
|
||
}
|
||
|
||
subscription_last_failure() {
|
||
local name="$1"
|
||
read_subscription_health_value "$name" "LAST_FAILURE" 2>/dev/null || true
|
||
}
|
||
|
||
subscription_health_status() {
|
||
local name="$1"
|
||
read_subscription_health_value "$name" "STATUS" 2>/dev/null || echo "unknown"
|
||
}
|
||
|
||
subscription_url_by_name() {
|
||
local name="$1"
|
||
local file
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
|
||
"$(yq_bin)" eval ".sources.${name}.url // \"\"" "$file" 2>/dev/null | head -n 1
|
||
}
|
||
|
||
subscription_format_by_name() {
|
||
local name="$1"
|
||
local file
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
|
||
"$(yq_bin)" eval ".sources.${name}.type // \"clash\"" "$file" 2>/dev/null | head -n 1
|
||
}
|
||
|
||
is_valid_port_number() {
|
||
local port="$1"
|
||
|
||
printf '%s' "$port" | grep -Eq '^[0-9]+$' || return 1
|
||
[ "$port" -ge 1 ] && [ "$port" -le 65535 ]
|
||
}
|
||
|
||
csv_has_value() {
|
||
local csv="$1"
|
||
local value="$2"
|
||
|
||
[ -n "${csv:-}" ] || return 1
|
||
printf ',%s,\n' "$csv" | grep -Fq ",$value,"
|
||
}
|
||
|
||
csv_append_value() {
|
||
local csv="$1"
|
||
local value="$2"
|
||
|
||
[ -n "${value:-}" ] || {
|
||
echo "$csv"
|
||
return 0
|
||
}
|
||
|
||
if csv_has_value "$csv" "$value"; then
|
||
echo "$csv"
|
||
return 0
|
||
fi
|
||
|
||
if [ -n "${csv:-}" ]; then
|
||
echo "${csv},${value}"
|
||
else
|
||
echo "$value"
|
||
fi
|
||
}
|
||
|
||
current_runtime_mixed_port() {
|
||
local file="${1:-$RUNTIME_DIR/config.yaml}"
|
||
|
||
[ -s "$file" ] || return 1
|
||
"$(yq_bin)" eval '.["mixed-port"] // .port // ""' "$file" 2>/dev/null | head -n 1
|
||
}
|
||
|
||
current_runtime_controller_port() {
|
||
local file="${1:-$RUNTIME_DIR/config.yaml}"
|
||
local addr
|
||
|
||
[ -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##*:}"
|
||
}
|
||
|
||
current_runtime_dns_port() {
|
||
local file="${1:-$RUNTIME_DIR/config.yaml}"
|
||
local listen
|
||
|
||
[ -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_reserved_by_current_runtime() {
|
||
local port="$1"
|
||
local current_port
|
||
|
||
for current_port in \
|
||
"$(current_runtime_mixed_port 2>/dev/null || true)" \
|
||
"$(current_runtime_controller_port 2>/dev/null || true)" \
|
||
"$(current_runtime_dns_port 2>/dev/null || true)"; do
|
||
[ -n "${current_port:-}" ] || continue
|
||
[ "$port" = "$current_port" ] && return 0
|
||
done
|
||
|
||
return 1
|
||
}
|
||
|
||
port_available_for_runtime() {
|
||
local port="$1"
|
||
local excludes_csv="${2:-}"
|
||
|
||
is_valid_port_number "$port" || return 1
|
||
|
||
if csv_has_value "$excludes_csv" "$port"; then
|
||
return 1
|
||
fi
|
||
|
||
if port_reserved_by_current_runtime "$port"; then
|
||
return 0
|
||
fi
|
||
|
||
if is_port_in_use "$port"; then
|
||
return 1
|
||
fi
|
||
|
||
return 0
|
||
}
|
||
|
||
resolve_free_port_excluding() {
|
||
local start="${1:-7890}"
|
||
local end="${2:-7999}"
|
||
local excludes_csv="${3:-}"
|
||
local p
|
||
|
||
for p in $(seq "$start" "$end"); do
|
||
if port_available_for_runtime "$p" "$excludes_csv"; then
|
||
echo "$p"
|
||
return 0
|
||
fi
|
||
done
|
||
|
||
die "指定端口范围内没有可用端口:${start}-${end}"
|
||
}
|
||
|
||
resolve_runtime_port() {
|
||
local preferred="$1"
|
||
local start="$2"
|
||
local end="$3"
|
||
local excludes_csv="${4:-}"
|
||
local label="${5:-端口}"
|
||
local resolved
|
||
|
||
if port_available_for_runtime "$preferred" "$excludes_csv"; then
|
||
echo "$preferred"
|
||
return 0
|
||
fi
|
||
|
||
resolved="$(resolve_free_port_excluding "$start" "$end" "$excludes_csv")"
|
||
|
||
if [ "$resolved" != "$preferred" ]; then
|
||
ui_target "端口冲突:[${label}] ${preferred} 🎲 随机分配:${resolved}"
|
||
fi
|
||
|
||
echo "$resolved"
|
||
}
|
||
|
||
parse_controller_host() {
|
||
local controller="$1"
|
||
|
||
[ -n "${controller:-}" ] || die "external-controller 不能为空"
|
||
printf '%s' "$controller" | grep -q ':' || die "external-controller 格式不合法:$controller"
|
||
|
||
echo "${controller%:*}"
|
||
}
|
||
|
||
parse_controller_port() {
|
||
local controller="$1"
|
||
local port
|
||
|
||
[ -n "${controller:-}" ] || die "external-controller 不能为空"
|
||
printf '%s' "$controller" | grep -q ':' || die "external-controller 格式不合法:$controller"
|
||
|
||
port="${controller##*:}"
|
||
is_valid_port_number "$port" || die "external-controller 端口不合法:$controller"
|
||
|
||
echo "$port"
|
||
}
|
||
|
||
resolve_runtime_ports() {
|
||
local preferred_mixed preferred_controller preferred_dns
|
||
local controller_host preferred_controller_port
|
||
local mixed_port controller_port dns_port
|
||
local used_ports=""
|
||
|
||
preferred_mixed="${MIXED_PORT:-7890}"
|
||
preferred_controller="${EXTERNAL_CONTROLLER:-0.0.0.0:9090}"
|
||
preferred_dns="${CLASH_DNS_PORT:-1053}"
|
||
|
||
is_valid_port_number "$preferred_mixed" || die "MIXED_PORT 不合法:$preferred_mixed"
|
||
is_valid_port_number "$preferred_dns" || die "CLASH_DNS_PORT 不合法:$preferred_dns"
|
||
|
||
controller_host="$(parse_controller_host "$preferred_controller")"
|
||
preferred_controller_port="$(parse_controller_port "$preferred_controller")"
|
||
|
||
mixed_port="$(resolve_runtime_port "$preferred_mixed" 7890 7999 "$used_ports" "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")"
|
||
used_ports="$(csv_append_value "$used_ports" "$controller_port")"
|
||
|
||
dns_port="$(resolve_runtime_port "$preferred_dns" 1053 1199 "$used_ports" "dns.listen")"
|
||
used_ports="$(csv_append_value "$used_ports" "$dns_port")"
|
||
|
||
printf 'MIXED_PORT_RESOLVED=%s\n' "$mixed_port"
|
||
printf 'EXTERNAL_CONTROLLER_RESOLVED=%s:%s\n' "$controller_host" "$controller_port"
|
||
printf 'CLASH_DNS_PORT_RESOLVED=%s\n' "$dns_port"
|
||
}
|
||
|
||
mark_install_port_plan() {
|
||
local resolved
|
||
local preferred_mixed preferred_controller preferred_dns
|
||
local changed_mixed changed_controller changed_dns
|
||
|
||
resolved="$(resolve_runtime_ports)"
|
||
eval "$resolved"
|
||
|
||
preferred_mixed="${MIXED_PORT:-7890}"
|
||
preferred_controller="${EXTERNAL_CONTROLLER:-0.0.0.0:9090}"
|
||
preferred_dns="${CLASH_DNS_PORT:-1053}"
|
||
|
||
if [ "$MIXED_PORT_RESOLVED" = "$preferred_mixed" ]; then
|
||
changed_mixed="false"
|
||
else
|
||
changed_mixed="true"
|
||
fi
|
||
|
||
if [ "$EXTERNAL_CONTROLLER_RESOLVED" = "$preferred_controller" ]; then
|
||
changed_controller="false"
|
||
else
|
||
changed_controller="true"
|
||
fi
|
||
|
||
if [ "$CLASH_DNS_PORT_RESOLVED" = "$preferred_dns" ]; then
|
||
changed_dns="false"
|
||
else
|
||
changed_dns="true"
|
||
fi
|
||
|
||
if [ "$changed_mixed" = "true" ]; then
|
||
ui_target "端口冲突:[mixed-port] ${preferred_mixed} 🎲 随机分配:${MIXED_PORT_RESOLVED}"
|
||
fi
|
||
|
||
if [ "$changed_controller" = "true" ]; then
|
||
ui_target "端口冲突:[external-controller] ${preferred_controller} 🎲 随机分配:${EXTERNAL_CONTROLLER_RESOLVED}"
|
||
fi
|
||
|
||
if [ "$changed_dns" = "true" ]; then
|
||
ui_target "端口冲突:[dns.listen] ${preferred_dns} 🎲 随机分配:${CLASH_DNS_PORT_RESOLVED}"
|
||
fi
|
||
|
||
write_runtime_value "INSTALL_PLAN_MIXED_PORT" "$MIXED_PORT_RESOLVED"
|
||
write_runtime_value "INSTALL_PLAN_CONTROLLER" "$EXTERNAL_CONTROLLER_RESOLVED"
|
||
write_runtime_value "INSTALL_PLAN_DNS_PORT" "$CLASH_DNS_PORT_RESOLVED"
|
||
|
||
write_runtime_value "INSTALL_PLAN_MIXED_PORT_AUTO_CHANGED" "$changed_mixed"
|
||
write_runtime_value "INSTALL_PLAN_CONTROLLER_AUTO_CHANGED" "$changed_controller"
|
||
write_runtime_value "INSTALL_PLAN_DNS_PORT_AUTO_CHANGED" "$changed_dns"
|
||
}
|
||
|
||
mixin_file() {
|
||
echo "$CONFIG_DIR/mixin.yaml"
|
||
}
|
||
|
||
ensure_mixin_file() {
|
||
ensure_config_files
|
||
}
|
||
|
||
apply_mixin_override() {
|
||
local runtime_file="$1"
|
||
local mixin_file_path
|
||
mixin_file_path="$(mixin_file)"
|
||
|
||
[ -s "$runtime_file" ] || die "运行配置不存在:$runtime_file"
|
||
[ -f "$mixin_file_path" ] || return 0
|
||
|
||
RUNTIME_FILE="$runtime_file" MIXIN_FILE="$mixin_file_path" "$(yq_bin)" eval-all -i '
|
||
select(fileIndex == 0)
|
||
* ((select(fileIndex == 1).override // {}))
|
||
' "$runtime_file" "$mixin_file_path"
|
||
}
|
||
|
||
apply_mixin_prepend_arrays() {
|
||
local runtime_file="$1"
|
||
local mixin_file_path
|
||
mixin_file_path="$(mixin_file)"
|
||
|
||
[ -s "$runtime_file" ] || die "运行配置不存在:$runtime_file"
|
||
[ -f "$mixin_file_path" ] || return 0
|
||
|
||
RUNTIME_FILE="$runtime_file" MIXIN_FILE="$mixin_file_path" "$(yq_bin)" eval-all -i '
|
||
select(fileIndex == 0)
|
||
| .proxies = (((select(fileIndex == 1).prepend.proxies // []) + (.proxies // [])) | unique_by(.name))
|
||
| .["proxy-groups"] = (((select(fileIndex == 1).prepend["proxy-groups"] // []) + (.["proxy-groups"] // [])) | unique_by(.name))
|
||
| .rules = (((select(fileIndex == 1).prepend.rules // []) + (.rules // [])) | unique)
|
||
' "$runtime_file" "$mixin_file_path"
|
||
}
|
||
|
||
apply_mixin_append_arrays() {
|
||
local runtime_file="$1"
|
||
local mixin_file_path
|
||
mixin_file_path="$(mixin_file)"
|
||
|
||
[ -s "$runtime_file" ] || die "运行配置不存在:$runtime_file"
|
||
[ -f "$mixin_file_path" ] || return 0
|
||
|
||
RUNTIME_FILE="$runtime_file" MIXIN_FILE="$mixin_file_path" "$(yq_bin)" eval-all -i '
|
||
select(fileIndex == 0)
|
||
| .proxies = (((.proxies // []) + (select(fileIndex == 1).append.proxies // [])) | unique_by(.name))
|
||
| .["proxy-groups"] = (((.["proxy-groups"] // []) + (select(fileIndex == 1).append["proxy-groups"] // [])) | unique_by(.name))
|
||
| .rules = (((.rules // []) + (select(fileIndex == 1).append.rules // [])) | unique)
|
||
' "$runtime_file" "$mixin_file_path"
|
||
}
|
||
|
||
apply_runtime_mixin() {
|
||
local runtime_file="$1"
|
||
|
||
[ -s "$runtime_file" ] || die "待应用 mixin 的配置文件不存在:$runtime_file"
|
||
|
||
ensure_mixin_file
|
||
|
||
build_debug "mixin: override"
|
||
apply_mixin_override "$runtime_file"
|
||
|
||
build_debug "mixin: prepend"
|
||
apply_mixin_prepend_arrays "$runtime_file"
|
||
|
||
build_debug "mixin: append"
|
||
apply_mixin_append_arrays "$runtime_file"
|
||
}
|
||
|
||
show_subscription() {
|
||
local url fmt
|
||
|
||
url="$(subscription_url 2>/dev/null || true)"
|
||
fmt="$(subscription_format)"
|
||
|
||
if [ -n "${url:-}" ]; then
|
||
echo "订阅地址:$url"
|
||
else
|
||
echo "订阅地址:未设置"
|
||
fi
|
||
|
||
echo "订阅格式:$fmt"
|
||
}
|
||
|
||
bootstrap_subscription_from_install_input() {
|
||
local url="$1"
|
||
local fmt="${2:-clash}"
|
||
local name="${3:-default}"
|
||
local file
|
||
|
||
[ -n "${url:-}" ] || return 1
|
||
|
||
subscription_url_is_supported "$url" || die "订阅地址格式不合法"
|
||
|
||
case "$fmt" in
|
||
clash|convert) ;;
|
||
*) die "不支持的订阅格式:$fmt" ;;
|
||
esac
|
||
|
||
if [ "$fmt" = "convert" ] && [ "$(subscription_url_scheme "$url")" = "file" ]; then
|
||
die "convert 格式暂不支持 file:// 本地订阅,请改用 clash 格式"
|
||
fi
|
||
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
|
||
NAME="$name" URL="$url" TYPE="$fmt" \
|
||
"$(yq_bin)" eval -i '
|
||
.sources[env(NAME)] = {
|
||
"type": env(TYPE),
|
||
"url": env(URL),
|
||
"enabled": true
|
||
} |
|
||
.active = env(NAME)
|
||
' "$file"
|
||
}
|
||
|
||
subscriptions_has_any_usable_source() {
|
||
local file count
|
||
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
|
||
count="$("$(yq_bin)" eval '.sources | keys | length' "$file" 2>/dev/null || echo 0)"
|
||
case "$count" in
|
||
''|*[!0-9]*) count=0 ;;
|
||
esac
|
||
|
||
[ "$count" -gt 0 ]
|
||
}
|
||
|
||
subscription_name_exists() {
|
||
local name="$1"
|
||
local file exists
|
||
|
||
[ -n "${name:-}" ] || return 1
|
||
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
|
||
exists="$("$(yq_bin)" eval ".sources | has(\"$name\")" "$file" 2>/dev/null || echo false)"
|
||
[ "$exists" = "true" ]
|
||
}
|
||
|
||
subscription_name_has_url() {
|
||
local name="$1"
|
||
local file url
|
||
|
||
[ -n "${name:-}" ] || return 1
|
||
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
|
||
url="$("$(yq_bin)" eval ".sources.\"$name\".url // \"\"" "$file" 2>/dev/null | head -n 1)"
|
||
[ -n "${url:-}" ]
|
||
}
|
||
|
||
ensure_subscription_bootstrap_for_install() {
|
||
local env_url env_fmt active_name
|
||
|
||
env_url="${CLASH_SUBSCRIPTION_URL:-}"
|
||
env_fmt="${CLASH_SUBSCRIPTION_FORMAT:-clash}"
|
||
active_name="${1:-default}"
|
||
|
||
[ -n "${env_url:-}" ] || return 0
|
||
|
||
case "$env_fmt" in
|
||
clash|convert) ;;
|
||
*) env_fmt="clash" ;;
|
||
esac
|
||
|
||
ensure_subscriptions_file
|
||
|
||
# 只在“当前完全没有订阅”时,才允许用 .env 初始化
|
||
if subscriptions_has_any_usable_source; then
|
||
return 0
|
||
fi
|
||
|
||
bootstrap_subscription_from_install_input "$env_url" "$env_fmt" "$active_name"
|
||
}
|
||
|
||
set_subscription() {
|
||
local url="$1"
|
||
local fmt="${2:-clash}"
|
||
local name="${3:-default}"
|
||
local file
|
||
|
||
[ -n "$url" ] || die "订阅地址不能为空"
|
||
|
||
subscription_url_is_supported "$url" || die "订阅地址格式不合法"
|
||
|
||
case "$fmt" in
|
||
clash|convert) ;;
|
||
*) die "不支持的订阅格式:$fmt" ;;
|
||
esac
|
||
|
||
if [ "$fmt" = "convert" ] && [ "$(subscription_url_scheme "$url")" = "file" ]; then
|
||
die "convert 格式暂不支持 file:// 本地订阅,请改用 clash 格式"
|
||
fi
|
||
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
|
||
NAME="$name" URL="$url" TYPE="$fmt" \
|
||
"$(yq_bin)" eval -i '
|
||
.sources[env(NAME)] = {
|
||
"type": env(TYPE),
|
||
"url": env(URL),
|
||
"enabled": true
|
||
}
|
||
' "$file"
|
||
|
||
regenerate_config
|
||
}
|
||
|
||
set_active_subscription() {
|
||
local name="$1"
|
||
local file
|
||
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
|
||
exists="$("$(yq_bin)" eval ".sources | has(\"$name\")" "$file")"
|
||
|
||
[ "$exists" = "true" ] || die_missing "订阅 $name" "clashctl ls"
|
||
|
||
NAME="$name" "$(yq_bin)" eval -i '
|
||
.active = env(NAME)
|
||
' "$file"
|
||
|
||
regenerate_config
|
||
}
|
||
|
||
remove_subscription() {
|
||
local name="$1"
|
||
local file active fallback
|
||
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
|
||
[ -n "${name:-}" ] || die_usage "订阅名称不能为空" "clashctl health <订阅名称>"
|
||
subscription_exists "$name" || die_missing "订阅 $name" "clashctl ls"
|
||
|
||
if [ "$name" = "default" ]; then
|
||
die "默认订阅不允许删除:default"
|
||
fi
|
||
|
||
active="$(active_subscription_name)"
|
||
|
||
NAME="$name" "$(yq_bin)" eval -i '
|
||
del(.sources[env(NAME)])
|
||
' "$file"
|
||
|
||
if [ "$active" = "$name" ]; then
|
||
if subscription_exists "default"; then
|
||
fallback="default"
|
||
else
|
||
fallback="$(first_subscription_name)"
|
||
fi
|
||
|
||
if [ -n "${fallback:-}" ]; then
|
||
NAME="$fallback" "$(yq_bin)" eval -i '.active = env(NAME)' "$file"
|
||
fi
|
||
fi
|
||
|
||
clear_subscription_health_all_fields "$name"
|
||
success "订阅已删除:$name"
|
||
}
|
||
|
||
rename_subscription() {
|
||
local old_name="$1"
|
||
local new_name="$2"
|
||
local file active
|
||
local old_prefix new_prefix fields value
|
||
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
|
||
[ -n "${old_name:-}" ] || die "原订阅名称不能为空"
|
||
[ -n "${new_name:-}" ] || die "新订阅名称不能为空"
|
||
|
||
subscription_exists "$old_name" || die_missing "订阅 $old_name" "clashctl ls"
|
||
subscription_exists "$new_name" && die_state "目标订阅已存在:$new_name" "clashctl ls"
|
||
|
||
case "$new_name" in
|
||
*[!A-Za-z0-9._-]*)
|
||
die "订阅名称只允许字母、数字、点、下划线、中划线"
|
||
;;
|
||
esac
|
||
|
||
OLD_NAME="$old_name" NEW_NAME="$new_name" "$(yq_bin)" eval -i '
|
||
.sources[env(NEW_NAME)] = .sources[env(OLD_NAME)] |
|
||
del(.sources[env(OLD_NAME)]) |
|
||
.active = (if .active == env(OLD_NAME) then env(NEW_NAME) else .active end)
|
||
' "$file"
|
||
|
||
old_prefix="$(health_key_prefix "$old_name")"
|
||
new_prefix="$(health_key_prefix "$new_name")"
|
||
|
||
fields="STATUS LAST_SUCCESS LAST_FAILURE LAST_ERROR FAIL_COUNT AUTO_DISABLED AUTO_DISABLED_AT"
|
||
|
||
for field in $fields; do
|
||
value="$(read_subscription_health_value "$old_name" "$field" 2>/dev/null || true)"
|
||
if [ -n "${value:-}" ]; then
|
||
write_subscription_health_value "$new_name" "$field" "$value"
|
||
write_subscription_health_value "$old_name" "$field" ""
|
||
fi
|
||
done
|
||
|
||
success "订阅已重命名:$old_name -> $new_name"
|
||
}
|
||
|
||
active_subscription_enabled() {
|
||
local active
|
||
active="$(active_subscription_name)"
|
||
subscription_exists "$active" && subscription_enabled "$active"
|
||
}
|
||
|
||
first_enabled_subscription_name() {
|
||
local file name
|
||
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
|
||
while IFS= read -r name; do
|
||
[ -n "${name:-}" ] || continue
|
||
if subscription_enabled "$name"; then
|
||
echo "$name"
|
||
return 0
|
||
fi
|
||
done < <("$(yq_bin)" eval '.sources | keys | .[]' "$file")
|
||
|
||
return 1
|
||
}
|
||
|
||
subscription_health_rank() {
|
||
local name="$1"
|
||
local status
|
||
|
||
status="$(subscription_health_status "$name" 2>/dev/null || echo "unknown")"
|
||
|
||
case "$status" in
|
||
success) echo "0" ;;
|
||
unknown) echo "1" ;;
|
||
failed) echo "2" ;;
|
||
*) echo "3" ;;
|
||
esac
|
||
}
|
||
|
||
subscription_risk_rank() {
|
||
local name="$1"
|
||
|
||
if subscription_auto_disabled "$name"; then
|
||
echo "1"
|
||
else
|
||
echo "0"
|
||
fi
|
||
}
|
||
|
||
recommended_subscription_name() {
|
||
local active name
|
||
local best_name=""
|
||
local best_health_rank="" best_risk_rank="" best_fail=""
|
||
local health_rank risk_rank fail_count
|
||
|
||
active="$(active_subscription_name 2>/dev/null || true)"
|
||
|
||
while IFS= read -r name; do
|
||
[ -n "${name:-}" ] || continue
|
||
|
||
subscription_enabled "$name" || continue
|
||
[ "$name" = "$active" ] && continue
|
||
|
||
health_rank="$(subscription_health_rank "$name" 2>/dev/null || echo "9")"
|
||
risk_rank="$(subscription_risk_rank "$name" 2>/dev/null || echo "9")"
|
||
fail_count="$(subscription_fail_count "$name" 2>/dev/null || echo "999")"
|
||
|
||
case "$health_rank" in
|
||
''|*[!0-9]*) health_rank="9" ;;
|
||
esac
|
||
|
||
case "$risk_rank" in
|
||
''|*[!0-9]*) risk_rank="9" ;;
|
||
esac
|
||
|
||
case "$fail_count" in
|
||
''|*[!0-9]*) fail_count="999" ;;
|
||
esac
|
||
|
||
if [ -z "${best_name:-}" ]; then
|
||
best_name="$name"
|
||
best_health_rank="$health_rank"
|
||
best_risk_rank="$risk_rank"
|
||
best_fail="$fail_count"
|
||
continue
|
||
fi
|
||
|
||
if [ "$health_rank" -lt "$best_health_rank" ]; then
|
||
best_name="$name"
|
||
best_health_rank="$health_rank"
|
||
best_risk_rank="$risk_rank"
|
||
best_fail="$fail_count"
|
||
continue
|
||
fi
|
||
|
||
if [ "$health_rank" -gt "$best_health_rank" ]; then
|
||
continue
|
||
fi
|
||
|
||
if [ "$risk_rank" -lt "$best_risk_rank" ]; then
|
||
best_name="$name"
|
||
best_health_rank="$health_rank"
|
||
best_risk_rank="$risk_rank"
|
||
best_fail="$fail_count"
|
||
continue
|
||
fi
|
||
|
||
if [ "$risk_rank" -gt "$best_risk_rank" ]; then
|
||
continue
|
||
fi
|
||
|
||
if [ "$fail_count" -lt "$best_fail" ]; then
|
||
best_name="$name"
|
||
best_health_rank="$health_rank"
|
||
best_risk_rank="$risk_rank"
|
||
best_fail="$fail_count"
|
||
continue
|
||
fi
|
||
|
||
if [ "$fail_count" -gt "$best_fail" ]; then
|
||
continue
|
||
fi
|
||
|
||
if [ "$name" \< "$best_name" ]; then
|
||
best_name="$name"
|
||
best_health_rank="$health_rank"
|
||
best_risk_rank="$risk_rank"
|
||
best_fail="$fail_count"
|
||
fi
|
||
done < <("$(yq_bin)" eval '.sources | keys | .[]' "$(subscriptions_file)" 2>/dev/null)
|
||
|
||
[ -n "${best_name:-}" ] && echo "$best_name"
|
||
}
|
||
|
||
recommended_subscription_reason_lines() {
|
||
local recommended active
|
||
local rec_health rec_fail rec_risk
|
||
local active_health active_fail active_risk
|
||
|
||
recommended="$(recommended_subscription_name 2>/dev/null || true)"
|
||
active="$(active_subscription_name 2>/dev/null || true)"
|
||
|
||
[ -n "${recommended:-}" ] || return 0
|
||
|
||
rec_health="$(subscription_health_status "$recommended" 2>/dev/null || echo "unknown")"
|
||
rec_fail="$(subscription_fail_count "$recommended" 2>/dev/null || echo "0")"
|
||
|
||
if subscription_auto_disabled "$recommended"; then
|
||
rec_risk="yes"
|
||
else
|
||
rec_risk="no"
|
||
fi
|
||
|
||
if [ -n "${active:-}" ] && [ "${active:-}" != "${recommended:-}" ]; then
|
||
active_health="$(subscription_health_status "$active" 2>/dev/null || echo "unknown")"
|
||
active_fail="$(subscription_fail_count "$active" 2>/dev/null || echo "0")"
|
||
|
||
if subscription_auto_disabled "$active"; then
|
||
active_risk="yes"
|
||
else
|
||
active_risk="no"
|
||
fi
|
||
|
||
if [ "$rec_health" != "$active_health" ]; then
|
||
echo "- 健康状态更优:${rec_health}(当前为 ${active_health})"
|
||
fi
|
||
|
||
if [ "$rec_risk" = "no" ] && [ "$active_risk" = "yes" ]; then
|
||
echo "- 未命中风险阈值(当前订阅已命中)"
|
||
elif [ "$rec_risk" = "no" ]; then
|
||
echo "- 未命中风险阈值"
|
||
fi
|
||
|
||
if [ "$rec_fail" != "$active_fail" ]; then
|
||
echo "- 失败次数更少:${rec_fail}(当前为 ${active_fail})"
|
||
else
|
||
echo "- 当前失败次数:${rec_fail}"
|
||
fi
|
||
|
||
return 0
|
||
fi
|
||
|
||
if [ "$rec_risk" = "no" ]; then
|
||
echo "- 未命中风险阈值"
|
||
fi
|
||
echo "- 当前健康状态:${rec_health}"
|
||
echo "- 当前失败次数:${rec_fail}"
|
||
}
|
||
|
||
next_enabled_subscription_name_excluding() {
|
||
local exclude_name="$1"
|
||
local file name
|
||
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
|
||
while IFS= read -r name; do
|
||
[ -n "${name:-}" ] || continue
|
||
[ "$name" = "$exclude_name" ] && continue
|
||
|
||
if subscription_enabled "$name"; then
|
||
echo "$name"
|
||
return 0
|
||
fi
|
||
done < <("$(yq_bin)" eval '.sources | keys | .[]' "$file")
|
||
|
||
return 1
|
||
}
|
||
|
||
switch_active_subscription_silently() {
|
||
local name="$1"
|
||
local file
|
||
|
||
[ -n "${name:-}" ] || return 1
|
||
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
subscription_exists "$name" || return 1
|
||
|
||
NAME="$name" "$(yq_bin)" eval -i '
|
||
.active = env(NAME)
|
||
' "$file"
|
||
}
|
||
|
||
clear_active_switch_runtime_event() {
|
||
write_runtime_event_value "RUNTIME_LAST_ACTIVE_SWITCH_FROM" ""
|
||
write_runtime_event_value "RUNTIME_LAST_ACTIVE_SWITCH_TO" ""
|
||
write_runtime_event_value "RUNTIME_LAST_ACTIVE_SWITCH_TIME" ""
|
||
write_runtime_event_value "RUNTIME_LAST_ACTIVE_SWITCH_REASON" ""
|
||
}
|
||
|
||
ensure_active_subscription_usable() {
|
||
local active recommended reason
|
||
|
||
active="$(active_subscription_name 2>/dev/null || true)"
|
||
|
||
[ -n "${active:-}" ] || {
|
||
clear_active_switch_runtime_event
|
||
return 0
|
||
}
|
||
|
||
if subscription_exists "$active" && subscription_enabled "$active"; then
|
||
clear_active_switch_runtime_event
|
||
return 0
|
||
fi
|
||
|
||
reason="active subscription unavailable"
|
||
|
||
recommended="$(next_enabled_subscription_name_excluding "$active" 2>/dev/null || true)"
|
||
if [ -z "${recommended:-}" ]; then
|
||
recommended="$(first_enabled_subscription_name 2>/dev/null || true)"
|
||
fi
|
||
|
||
write_runtime_event_value "RUNTIME_LAST_ACTIVE_SWITCH_FROM" "$active"
|
||
write_runtime_event_value "RUNTIME_LAST_ACTIVE_SWITCH_TO" ""
|
||
write_runtime_event_value "RUNTIME_LAST_ACTIVE_SWITCH_TIME" "$(now_datetime)"
|
||
write_runtime_event_value "RUNTIME_LAST_ACTIVE_SWITCH_REASON" "$reason"
|
||
write_runtime_event_value "RUNTIME_LAST_ACTIVE_SWITCH_RECOMMENDATION" "${recommended:-}"
|
||
|
||
warn "当前主订阅不可用,已记录风险,不会自动切换:$active"
|
||
return 1
|
||
}
|
||
|
||
active_subscription_auto_disabled() {
|
||
local active
|
||
active="$(active_subscription_name)"
|
||
subscription_auto_disabled "$active"
|
||
}
|
||
|
||
has_any_enabled_subscription() {
|
||
local file count
|
||
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
|
||
count="$("$(yq_bin)" eval '[.sources[] | select(.enabled == true)] | length' "$file" 2>/dev/null || echo 0)"
|
||
[ "${count:-0}" -gt 0 ]
|
||
}
|
||
|
||
has_any_auto_disabled_subscription() {
|
||
local file name
|
||
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
|
||
while IFS= read -r name; do
|
||
[ -n "${name:-}" ] || continue
|
||
if subscription_auto_disabled "$name"; then
|
||
return 0
|
||
fi
|
||
done < <("$(yq_bin)" eval '.sources | keys | .[]' "$file")
|
||
|
||
return 1
|
||
}
|
||
|
||
runtime_last_fallback_used() {
|
||
read_runtime_event_value "RUNTIME_LAST_FALLBACK_USED" 2>/dev/null || true
|
||
}
|
||
|
||
runtime_last_fallback_time() {
|
||
read_runtime_event_value "RUNTIME_LAST_FALLBACK_TIME" 2>/dev/null || true
|
||
}
|
||
|
||
runtime_last_fallback_reason() {
|
||
read_runtime_event_value "RUNTIME_LAST_FALLBACK_REASON" 2>/dev/null || true
|
||
}
|
||
|
||
runtime_last_risk_level() {
|
||
read_runtime_event_value "RUNTIME_LAST_RISK_LEVEL" 2>/dev/null || true
|
||
}
|
||
|
||
calculate_runtime_risk_level() {
|
||
local build_status active enabled
|
||
|
||
build_status="$(read_build_value "BUILD_LAST_STATUS" 2>/dev/null || true)"
|
||
active="$(active_subscription_name 2>/dev/null || true)"
|
||
|
||
if ! has_any_enabled_subscription; then
|
||
echo "critical"
|
||
return 0
|
||
fi
|
||
|
||
if [ "${build_status:-}" = "failed" ]; then
|
||
echo "high"
|
||
return 0
|
||
fi
|
||
|
||
if [ -n "${active:-}" ] && ! active_subscription_enabled; then
|
||
echo "high"
|
||
return 0
|
||
fi
|
||
|
||
if has_any_auto_disabled_subscription; then
|
||
echo "medium"
|
||
return 0
|
||
fi
|
||
|
||
echo "low"
|
||
}
|
||
|
||
print_subscription_pick_line() {
|
||
local index="$1"
|
||
local name="$2"
|
||
local active enabled_text health_text fail_count type_text marker
|
||
|
||
[ -n "${name:-}" ] || return 0
|
||
|
||
active="$(active_subscription_name 2>/dev/null || true)"
|
||
|
||
if [ "$name" = "$active" ]; then
|
||
marker="[当前]"
|
||
else
|
||
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")"
|
||
|
||
printf ' %-2s) %-16s %-8s %-9s %-8s fail=%-3s %s\n' \
|
||
"$index" "$name" "$type_text" "$enabled_text" "$health_text" "$fail_count" "$marker"
|
||
}
|
||
|
||
print_subscription_summary_line() {
|
||
local name="$1"
|
||
local active enabled_text health_text fail_count marker notes type_text
|
||
|
||
[ -n "${name:-}" ] || return 0
|
||
|
||
active="$(active_subscription_name 2>/dev/null || true)"
|
||
|
||
if [ "$name" = "$active" ]; then
|
||
marker="*"
|
||
else
|
||
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")"
|
||
|
||
notes=""
|
||
if subscription_auto_disabled "$name"; then
|
||
notes="risk"
|
||
fi
|
||
|
||
if [ -n "${notes:-}" ]; then
|
||
printf '%s %-16s %-8s %-9s %-8s fail=%-3s %s\n' \
|
||
"$marker" "$name" "$type_text" "$enabled_text" "$health_text" "$fail_count" "$notes"
|
||
else
|
||
printf '%s %-16s %-8s %-9s %-8s fail=%-3s\n' \
|
||
"$marker" "$name" "$type_text" "$enabled_text" "$health_text" "$fail_count"
|
||
fi
|
||
}
|
||
|
||
list_subscriptions() {
|
||
local file name found="false"
|
||
|
||
file="$(subscriptions_file)"
|
||
ensure_subscriptions_file
|
||
|
||
echo " 名称 类型 启用状态 健康状态 失败次数"
|
||
while IFS= read -r name; do
|
||
[ -n "${name:-}" ] || continue
|
||
found="true"
|
||
print_subscription_summary_line "$name"
|
||
done < <("$(yq_bin)" eval '.sources | keys | .[]' "$file" 2>/dev/null)
|
||
|
||
[ "$found" = "true" ] || echo " 暂无订阅"
|
||
}
|
||
|
||
enable_subscription() {
|
||
local name="$1"
|
||
set_subscription_enabled "$name" "true"
|
||
}
|
||
|
||
disable_subscription() {
|
||
local name="$1"
|
||
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
|
||
|
||
active="$(active_subscription_name 2>/dev/null || true)"
|
||
recommended="$(recommended_subscription_name 2>/dev/null || true)"
|
||
|
||
if [ -n "${active:-}" ]; then
|
||
echo "📡 当前主订阅:$active"
|
||
else
|
||
echo "📡 当前主订阅:未设置"
|
||
fi
|
||
|
||
if [ -n "${recommended:-}" ] && [ "${recommended:-}" != "${active:-}" ]; then
|
||
echo "💡 推荐订阅:$recommended"
|
||
elif [ -n "${active:-}" ]; then
|
||
echo "💡 推荐订阅:保持当前"
|
||
else
|
||
echo "💡 推荐订阅:暂无"
|
||
fi
|
||
|
||
echo "🧩 编译模式:active-only"
|
||
}
|
||
|
||
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"
|
||
}
|
||
|
||
detect_subscription_format() {
|
||
local url="$1"
|
||
|
||
case "$url" in
|
||
*.yaml|*.yml|*".yaml?"*|*".yml?"*)
|
||
echo "clash"
|
||
;;
|
||
*)
|
||
echo "clash"
|
||
;;
|
||
esac
|
||
}
|
||
|
||
prompt_subscription_if_needed() {
|
||
local current_url input_url input_format
|
||
|
||
current_url="$(subscription_url 2>/dev/null || true)"
|
||
if [ -n "${current_url:-}" ]; then
|
||
return 0
|
||
fi
|
||
|
||
echo
|
||
echo "📡 订阅"
|
||
echo "请输入订阅链接"
|
||
echo "直接回车可跳过,稍后执行:clashctl add <订阅链接>"
|
||
printf "> "
|
||
IFS= read -r input_url || true
|
||
|
||
input_url="$(printf '%s' "${input_url:-}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
||
|
||
if [ -z "${input_url:-}" ]; then
|
||
return 0
|
||
fi
|
||
|
||
if ! subscription_url_is_supported "$input_url"; then
|
||
ui_warn "订阅链接格式不合法,已跳过"
|
||
ui_next "稍后执行:clashctl add <订阅链接>"
|
||
return 0
|
||
fi
|
||
|
||
input_format="${CLASH_SUBSCRIPTION_FORMAT:-$(detect_subscription_format "$input_url")}"
|
||
|
||
case "$input_format" in
|
||
clash|convert) ;;
|
||
*) input_format="clash" ;;
|
||
esac
|
||
|
||
write_env_value "CLASH_SUBSCRIPTION_URL" "$input_url"
|
||
write_env_value "CLASH_SUBSCRIPTION_FORMAT" "$input_format"
|
||
bootstrap_subscription_from_install_input "$input_url" "$input_format" "default"
|
||
}
|
||
|
||
clear_subscription() {
|
||
local file="$PROJECT_DIR/.env"
|
||
|
||
[ -f "$file" ] || {
|
||
warn ".env 不存在,无需清理订阅地址"
|
||
return 0
|
||
}
|
||
|
||
awk '
|
||
$0 ~ /^[[:space:]]*(export[[:space:]]+)?CLASH_SUBSCRIPTION_URL=/ { next }
|
||
$0 ~ /^[[:space:]]*(export[[:space:]]+)?CLASH_URL=/ { next }
|
||
{ print }
|
||
' "$file" > "${file}.tmp" && mv "${file}.tmp" "$file"
|
||
|
||
success "订阅地址已清理"
|
||
}
|
||
|
||
subconverter_pid_file() {
|
||
echo "$RUNTIME_DIR/subconverter.pid"
|
||
}
|
||
|
||
subconverter_log_file() {
|
||
echo "$LOG_DIR/subconverter.log"
|
||
}
|
||
|
||
subconverter_port() {
|
||
echo "${SUBCONVERTER_PORT:-25500}"
|
||
}
|
||
|
||
subconverter_url() {
|
||
echo "http://127.0.0.1:$(subconverter_port)"
|
||
}
|
||
|
||
subconverter_running() {
|
||
local pid_file pid
|
||
|
||
pid_file="$(subconverter_pid_file)"
|
||
[ -f "$pid_file" ] || return 1
|
||
|
||
pid="$(cat "$pid_file" 2>/dev/null || true)"
|
||
[ -n "${pid:-}" ] || return 1
|
||
|
||
kill -0 "$pid" 2>/dev/null
|
||
}
|
||
|
||
start_subconverter() {
|
||
local home bin log_file
|
||
|
||
home="$(subconverter_home)"
|
||
bin="$(subconverter_bin)"
|
||
log_file="$(subconverter_log_file)"
|
||
|
||
[ -x "$bin" ] || die "subconverter 未安装:$bin"
|
||
mkdir -p "$LOG_DIR"
|
||
|
||
if subconverter_running; then
|
||
return 0
|
||
fi
|
||
|
||
(
|
||
cd "$home"
|
||
nohup "$bin" > "$log_file" 2>&1 &
|
||
echo $! > "$(subconverter_pid_file)"
|
||
)
|
||
|
||
sleep 1
|
||
|
||
subconverter_running || die "subconverter 启动失败"
|
||
}
|
||
|
||
stop_subconverter() {
|
||
local pid_file pid
|
||
|
||
pid_file="$(subconverter_pid_file)"
|
||
[ -f "$pid_file" ] || return 0
|
||
|
||
pid="$(cat "$pid_file" 2>/dev/null || true)"
|
||
if [ -n "${pid:-}" ] && kill -0 "$pid" 2>/dev/null; then
|
||
kill "$pid" 2>/dev/null || true
|
||
sleep 1
|
||
kill -9 "$pid" 2>/dev/null || true
|
||
fi
|
||
|
||
rm -f "$pid_file"
|
||
}
|
||
|
||
subscription_yaml_validate() {
|
||
local file="$1"
|
||
|
||
[ -s "$file" ] || return 1
|
||
|
||
# 先做一次最小 YAML 解析校验
|
||
if ! "$(yq_bin)" eval '.' "$file" >/dev/null 2>&1; then
|
||
return 1
|
||
fi
|
||
|
||
# 再做一次 Clash/Mihomo 订阅的基本结构校验
|
||
# 允许是完整配置,也允许是仅包含 proxies / proxy-groups / rules / rule-providers 的订阅
|
||
if ! "$(yq_bin)" eval '
|
||
(
|
||
(.proxies != null) or
|
||
(.["proxy-groups"] != null) or
|
||
(.rules != null) or
|
||
(.["rule-providers"] != null) or
|
||
(.["mixed-port"] != null) or
|
||
(.port != null) or
|
||
(.mode != null)
|
||
)
|
||
' "$file" 2>/dev/null | grep -qx 'true'; then
|
||
return 1
|
||
fi
|
||
|
||
return 0
|
||
}
|
||
|
||
write_subscription_invalid_debug_snapshot() {
|
||
local bad_file="$1"
|
||
local snapshot_file
|
||
local max_lines="30"
|
||
|
||
snapshot_file="$(config_tmp_dir)/subscription-invalid-preview.txt"
|
||
mkdir -p "$(config_tmp_dir)"
|
||
|
||
{
|
||
echo "===== invalid subscription preview ====="
|
||
echo "time: $(now_datetime)"
|
||
echo "file: $bad_file"
|
||
echo
|
||
sed -n "1,${max_lines}p" "$bad_file" 2>/dev/null || true
|
||
echo
|
||
echo "===== end ====="
|
||
} > "$snapshot_file"
|
||
}
|
||
|
||
save_build_stage_snapshot() {
|
||
local stage="$1"
|
||
local file="$2"
|
||
local snapshot_file
|
||
|
||
[ -n "${stage:-}" ] || return 0
|
||
[ -s "${file:-}" ] || return 0
|
||
|
||
snapshot_file="$(config_tmp_dir)/snapshot-${stage}.yaml"
|
||
cp -f "$file" "$snapshot_file" 2>/dev/null || true
|
||
}
|
||
|
||
fail_if_yaml_invalid() {
|
||
local stage="$1"
|
||
local file="$2"
|
||
local mode="$3"
|
||
local policy="$4"
|
||
local active="$5"
|
||
local selected="$6"
|
||
local included="$7"
|
||
local failed="$8"
|
||
|
||
if subscription_yaml_validate "$file"; then
|
||
save_build_stage_snapshot "$stage" "$file"
|
||
return 0
|
||
fi
|
||
|
||
save_build_stage_snapshot "$stage" "$file"
|
||
write_subscription_invalid_debug_snapshot "$file"
|
||
|
||
write_compile_error "配置在阶段 ${stage} 后变成了非法 YAML"
|
||
append_compile_error "stage : $stage"
|
||
append_compile_error "file : $file"
|
||
append_compile_error "snapshot : $(config_tmp_dir)/snapshot-${stage}.yaml"
|
||
append_compile_error "preview_file : $(config_tmp_dir)/subscription-invalid-preview.txt"
|
||
|
||
fail_build_with_detail \
|
||
"invalid-yaml-${stage}" \
|
||
"$mode" \
|
||
"$policy" \
|
||
"$active" \
|
||
"$selected" \
|
||
"$included" \
|
||
"$failed" \
|
||
"配置在阶段 ${stage} 后变成了非法 YAML"
|
||
}
|
||
|
||
build_stage_guard() {
|
||
local stage="$1"
|
||
local file="$2"
|
||
local mode="$3"
|
||
local policy="$4"
|
||
local active="$5"
|
||
local selected="$6"
|
||
local included="$7"
|
||
local failed="$8"
|
||
|
||
fail_if_yaml_invalid "$stage" "$file" "$mode" "$policy" "$active" "$selected" "$included" "$failed"
|
||
}
|
||
|
||
build_debug_enabled() {
|
||
case "${CLASH_BUILD_DEBUG:-false}" in
|
||
true|1|yes|on) return 0 ;;
|
||
*) return 1 ;;
|
||
esac
|
||
}
|
||
|
||
build_debug() {
|
||
build_debug_enabled || return 0
|
||
printf '🧩 [build] %s\n' "$*"
|
||
}
|
||
|
||
build_stage_run() {
|
||
local stage="$1"
|
||
local file="$2"
|
||
local mode="$3"
|
||
local policy="$4"
|
||
local active="$5"
|
||
local selected="$6"
|
||
local included="$7"
|
||
local failed="$8"
|
||
shift 8
|
||
|
||
build_debug "enter stage=${stage}"
|
||
|
||
"$@"
|
||
|
||
build_stage_guard "$stage" "$file" "$mode" "$policy" "$active" "$selected" "$included" "$failed"
|
||
build_debug "ok stage=${stage}"
|
||
}
|
||
|
||
build_stage_run_with_snapshot() {
|
||
local stage="$1"
|
||
local file="$2"
|
||
local mode="$3"
|
||
local policy="$4"
|
||
local active="$5"
|
||
local selected="$6"
|
||
local included="$7"
|
||
local failed="$8"
|
||
shift 8
|
||
|
||
build_stage_run \
|
||
"$stage" \
|
||
"$file" \
|
||
"$mode" \
|
||
"$policy" \
|
||
"$active" \
|
||
"$selected" \
|
||
"$included" \
|
||
"$failed" \
|
||
"$@"
|
||
|
||
save_build_stage_snapshot "$stage" "$file"
|
||
}
|
||
|
||
convert_subscription_via_subconverter() {
|
||
local url="$1"
|
||
local out_file="$2"
|
||
local fetch_reason="${3:-auto}"
|
||
local api tmp_file
|
||
local log_file
|
||
local validate_ok="false"
|
||
|
||
[ -n "${url:-}" ] || return 1
|
||
|
||
case "$(subscription_url_scheme "$url")" in
|
||
http|https)
|
||
;;
|
||
file)
|
||
die "convert 格式暂不支持 file:// 本地订阅,请改用 clash 格式"
|
||
;;
|
||
*)
|
||
die "不支持的订阅协议:$url"
|
||
;;
|
||
esac
|
||
|
||
case "$fetch_reason" in
|
||
auto|install|bootstrap|"")
|
||
if subscription_cache_restore "$url" "convert" "$out_file"; then
|
||
if subscription_yaml_validate "$out_file"; then
|
||
return 0
|
||
fi
|
||
clear_subscription_cache "$url" "convert"
|
||
rm -f "$out_file" 2>/dev/null || true
|
||
fi
|
||
;;
|
||
esac
|
||
|
||
start_subconverter || return 1
|
||
api="$(subconverter_url)/sub"
|
||
log_file="$(subconverter_log_file)"
|
||
tmp_file="$(mktemp)"
|
||
rm -f "$tmp_file" 2>/dev/null || true
|
||
|
||
info "正在通过 subconverter 转换订阅"
|
||
|
||
if ! curl -fL -G "$api" \
|
||
--data-urlencode "target=clash" \
|
||
--data-urlencode "url=$url" \
|
||
-o "$tmp_file"; then
|
||
rm -f "$tmp_file" 2>/dev/null || true
|
||
return 1
|
||
fi
|
||
|
||
[ -s "$tmp_file" ] || {
|
||
rm -f "$tmp_file" 2>/dev/null || true
|
||
return 1
|
||
}
|
||
|
||
if subscription_yaml_validate "$tmp_file"; then
|
||
validate_ok="true"
|
||
fi
|
||
|
||
if [ "$validate_ok" != "true" ]; then
|
||
write_subscription_invalid_debug_snapshot "$tmp_file"
|
||
|
||
warn "subconverter 返回内容不是合法 Clash YAML"
|
||
warn "调试预览已写入:$(config_tmp_dir)/subscription-invalid-preview.txt"
|
||
[ -f "$log_file" ] && warn "subconverter 日志:$log_file"
|
||
|
||
rm -f "$tmp_file" 2>/dev/null || true
|
||
return 1
|
||
fi
|
||
|
||
mv -f "$tmp_file" "$out_file"
|
||
subscription_cache_store "$url" "convert" "$out_file" "$api"
|
||
return 0
|
||
}
|
||
|
||
set_subscription_format() {
|
||
local fmt="$1"
|
||
|
||
case "$fmt" in
|
||
clash|convert)
|
||
;;
|
||
*)
|
||
die "不支持的订阅格式:$fmt,可选值:clash / convert"
|
||
;;
|
||
esac
|
||
|
||
write_env_value "CLASH_SUBSCRIPTION_FORMAT" "$fmt"
|
||
success "订阅格式已写入 .env:$fmt"
|
||
}
|
||
|
||
show_subscription_format_help() {
|
||
echo "可用订阅格式:"
|
||
echo " clash 先直接下载 Clash YAML;若返回内容不是合法 YAML,则自动尝试转换"
|
||
echo " convert 直接通过 subconverter 转换通用订阅"
|
||
}
|
||
|
||
add_profile() {
|
||
local profile_name="$1"
|
||
local profiles_file="$CONFIG_DIR/profiles.yaml"
|
||
|
||
[ -n "${profile_name:-}" ] || die "Profile 名称不能为空"
|
||
|
||
ensure_config_files
|
||
|
||
if profile_exists "$profile_name"; then
|
||
die "Profile 已存在:$profile_name"
|
||
fi
|
||
|
||
PROFILE_NAME="$profile_name" "$(yq_bin)" eval -i '
|
||
.profiles[env(PROFILE_NAME)] = {}
|
||
' "$profiles_file"
|
||
|
||
success "Profile 已添加:$profile_name"
|
||
}
|
||
|
||
delete_profile() {
|
||
local profile_name="$1"
|
||
local profiles_file="$CONFIG_DIR/profiles.yaml"
|
||
local active_name
|
||
|
||
[ -n "${profile_name:-}" ] || die "Profile 名称不能为空"
|
||
|
||
ensure_config_files
|
||
|
||
profile_exists "$profile_name" || die "Profile 不存在:$profile_name"
|
||
|
||
active_name="$(show_active_profile)"
|
||
|
||
if [ "$profile_name" = "default" ]; then
|
||
die "默认 Profile 不允许删除:default"
|
||
fi
|
||
|
||
if [ "$profile_name" = "$active_name" ]; then
|
||
die "当前激活的 Profile 不允许直接删除,请先切换到其他 Profile"
|
||
fi
|
||
|
||
PROFILE_NAME="$profile_name" "$(yq_bin)" eval -i '
|
||
del(.profiles[env(PROFILE_NAME)])
|
||
' "$profiles_file"
|
||
|
||
success "Profile 已删除:$profile_name"
|
||
}
|
||
|
||
profile_set_allowed_key() {
|
||
case "$1" in
|
||
mixed-port|allow-lan|external-controller|mode|log-level)
|
||
return 0
|
||
;;
|
||
*)
|
||
return 1
|
||
;;
|
||
esac
|
||
}
|
||
|
||
profile_normalize_value() {
|
||
local key="$1"
|
||
local value="$2"
|
||
|
||
case "$key" in
|
||
mixed-port)
|
||
echo "$value" | grep -Eq '^[0-9]+$' || die "mixed-port 必须是数字"
|
||
echo "$value"
|
||
;;
|
||
allow-lan)
|
||
case "$value" in
|
||
true|false)
|
||
echo "$value"
|
||
;;
|
||
*)
|
||
die "allow-lan 只允许 true 或 false"
|
||
;;
|
||
esac
|
||
;;
|
||
external-controller)
|
||
[ -n "${value:-}" ] || die "external-controller 不能为空"
|
||
echo "$value"
|
||
;;
|
||
mode)
|
||
case "$value" in
|
||
rule|global|direct)
|
||
echo "$value"
|
||
;;
|
||
*)
|
||
die "mode 只允许 rule / global / direct"
|
||
;;
|
||
esac
|
||
;;
|
||
log-level)
|
||
case "$value" in
|
||
trace|debug|info|warning|error|silent)
|
||
echo "$value"
|
||
;;
|
||
*)
|
||
die "log-level 只允许 trace / debug / info / warning / error / silent"
|
||
;;
|
||
esac
|
||
;;
|
||
*)
|
||
die "不支持的 Profile 键:$key"
|
||
;;
|
||
esac
|
||
}
|
||
|
||
set_profile_value() {
|
||
local profile_name="$1"
|
||
local key="$2"
|
||
local raw_value="$3"
|
||
local value="$4"
|
||
local profiles_file="$CONFIG_DIR/profiles.yaml"
|
||
|
||
[ -n "${profile_name:-}" ] || die "Profile 名称不能为空"
|
||
[ -n "${key:-}" ] || die "配置键不能为空"
|
||
[ -n "${raw_value:-}" ] || die "配置值不能为空"
|
||
|
||
ensure_config_files
|
||
profile_exists "$profile_name" || die "Profile 不存在:$profile_name"
|
||
profile_set_allowed_key "$key" || die "不支持的 Profile 键:$key"
|
||
|
||
case "$key" in
|
||
mixed-port|allow-lan)
|
||
PROFILE_NAME="$profile_name" PROFILE_KEY="$key" PROFILE_VALUE="$value" "$(yq_bin)" eval -i '
|
||
.profiles[env(PROFILE_NAME)][env(PROFILE_KEY)] = env(PROFILE_VALUE)
|
||
' "$profiles_file"
|
||
;;
|
||
external-controller|mode|log-level)
|
||
PROFILE_NAME="$profile_name" PROFILE_KEY="$key" PROFILE_VALUE="$value" "$(yq_bin)" eval -i '
|
||
.profiles[env(PROFILE_NAME)][env(PROFILE_KEY)] = strenv(PROFILE_VALUE)
|
||
' "$profiles_file"
|
||
;;
|
||
*)
|
||
die "不支持的 Profile 键:$key"
|
||
;;
|
||
esac
|
||
|
||
success "Profile 配置已更新:$profile_name.$key = $value"
|
||
}
|
||
|
||
profile_set_value() {
|
||
local profile_name="$1"
|
||
local key="$2"
|
||
local raw_value="$3"
|
||
local value
|
||
|
||
value="$(profile_normalize_value "$key" "$raw_value")"
|
||
set_profile_value "$profile_name" "$key" "$raw_value" "$value"
|
||
}
|
||
|
||
# ===== FINAL OVERRIDE: active-only runtime pipeline =====
|
||
|
||
resolve_build_sources() {
|
||
local active
|
||
|
||
active="$(active_subscription_name 2>/dev/null || true)"
|
||
|
||
if [ -n "${active:-}" ] && subscription_exists "$active" && subscription_enabled "$active"; then
|
||
echo "$active"
|
||
return 0
|
||
fi
|
||
|
||
return 1
|
||
}
|
||
|
||
build_runtime_candidate_from_payload() {
|
||
local payload_file="$1"
|
||
local out_file="$2"
|
||
|
||
[ -s "$payload_file" ] || return 1
|
||
subscription_yaml_validate "$payload_file" || return 1
|
||
|
||
cp -f "$payload_file" "$out_file"
|
||
normalize_runtime_config "$out_file"
|
||
test_runtime_config "$out_file" >/dev/null 2>&1
|
||
}
|
||
|
||
fetch_subscription_source() {
|
||
local name="$1"
|
||
local out_file="$2"
|
||
local fetch_reason="${3:-auto}"
|
||
local url fmt reason
|
||
local raw_file candidate_file
|
||
|
||
url="$(subscription_url_by_name "$name")"
|
||
fmt="$(subscription_format_by_name "$name")"
|
||
|
||
raw_file="$(mktemp)"
|
||
candidate_file="$(mktemp)"
|
||
rm -f "$raw_file" "$candidate_file" 2>/dev/null || true
|
||
|
||
if [ -z "${url:-}" ]; then
|
||
mark_subscription_health_failure "$name" "订阅源地址为空"
|
||
rm -f "$raw_file" "$candidate_file" 2>/dev/null || true
|
||
return 1
|
||
fi
|
||
|
||
case "$fmt" in
|
||
""|clash)
|
||
case "$fetch_reason" in
|
||
explicit-add|manual-add|manual-use|manual-refresh)
|
||
clear_subscription_cache "$url" "clash"
|
||
clear_subscription_cache "$url" "convert"
|
||
;;
|
||
esac
|
||
|
||
if download_subscription_yaml "$url" "$raw_file" "$fetch_reason"; 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
|
||
mark_subscription_health_success "$name"
|
||
return 0
|
||
fi
|
||
|
||
write_subscription_invalid_debug_snapshot "$raw_file"
|
||
|
||
rm -f "$candidate_file" 2>/dev/null || true
|
||
candidate_file="$(mktemp)"
|
||
rm -f "$candidate_file" 2>/dev/null || true
|
||
|
||
if convert_subscription_via_subconverter "$url" "$raw_file" "$fetch_reason"; 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
|
||
mark_subscription_health_success "$name"
|
||
return 0
|
||
fi
|
||
|
||
write_subscription_invalid_debug_snapshot "$raw_file"
|
||
reason="订阅下载成功,但原始配置与转换结果都不能直接运行"
|
||
else
|
||
reason="订阅下载成功,但原始配置不能直接运行,且转换失败"
|
||
fi
|
||
else
|
||
reason="订阅下载失败"
|
||
fi
|
||
;;
|
||
convert)
|
||
case "$fetch_reason" in
|
||
explicit-add|manual-add|manual-use|manual-refresh)
|
||
clear_subscription_cache "$url" "convert"
|
||
;;
|
||
esac
|
||
|
||
if convert_subscription_via_subconverter "$url" "$raw_file" "$fetch_reason"; 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
|
||
mark_subscription_health_success "$name"
|
||
return 0
|
||
fi
|
||
|
||
write_subscription_invalid_debug_snapshot "$raw_file"
|
||
reason="订阅转换成功,但转换结果不能直接运行"
|
||
else
|
||
reason="订阅转换失败"
|
||
fi
|
||
;;
|
||
*)
|
||
reason="不支持的订阅格式:$fmt"
|
||
;;
|
||
esac
|
||
|
||
rm -f "$raw_file" "$candidate_file" 2>/dev/null || true
|
||
mark_subscription_health_failure "$name" "$reason"
|
||
return 1
|
||
}
|
||
|
||
generate_config() {
|
||
local active_source
|
||
local out_file="$RUNTIME_DIR/config.yaml"
|
||
local tmp_dir source_file
|
||
local selected_csv="" included_csv="" failed_csv=""
|
||
|
||
ensure_active_subscription_usable || true
|
||
|
||
active_source="$(active_subscription_name 2>/dev/null || true)"
|
||
|
||
mkdir -p "$RUNTIME_DIR" "$(config_tmp_dir)"
|
||
tmp_dir="$(config_tmp_dir)"
|
||
source_file="$tmp_dir/source-${active_source}.yaml"
|
||
|
||
if [ -z "${active_source:-}" ] || ! subscription_exists "$active_source" || ! subscription_enabled "$active_source"; then
|
||
write_compile_error "当前没有可用主订阅"
|
||
fail_build_with_detail \
|
||
"resolve-active-source" \
|
||
"single" \
|
||
"active-only" \
|
||
"${active_source:-}" \
|
||
"" \
|
||
"" \
|
||
"" \
|
||
"当前没有可用主订阅"
|
||
fi
|
||
|
||
selected_csv="$active_source"
|
||
|
||
if ! fetch_subscription_source "$active_source" "$source_file" "auto"; then
|
||
failed_csv="$active_source"
|
||
write_compile_error "当前主订阅不可用"
|
||
append_compile_error "source : $active_source"
|
||
fail_build_with_detail \
|
||
"fetch-source" \
|
||
"single" \
|
||
"active-only" \
|
||
"$active_source" \
|
||
"$selected_csv" \
|
||
"$included_csv" \
|
||
"$failed_csv" \
|
||
"当前主订阅不可用:$active_source"
|
||
fi
|
||
|
||
included_csv="$active_source"
|
||
|
||
cp -f "$source_file" "$out_file"
|
||
cp -f "$source_file" "$RUNTIME_DIR/config.last.yaml"
|
||
save_build_stage_snapshot "active-source-ready" "$source_file"
|
||
|
||
record_build_success \
|
||
"single" \
|
||
"active-only" \
|
||
"$active_source" \
|
||
"$selected_csv" \
|
||
"$included_csv" \
|
||
"$failed_csv"
|
||
|
||
mark_runtime_config_source "build-active"
|
||
success "配置文件已生成:$out_file"
|
||
return 0
|
||
}
|
||
|
||
regenerate_config() {
|
||
generate_config
|
||
}
|