Fix direct share-link classification and conversion (#315)

This commit is contained in:
Arvin
2026-08-25 13:53:06 +08:00
parent a83d1325d1
commit b1214cfe3d
4 changed files with 312 additions and 35 deletions
+10
View File
@@ -206,6 +206,16 @@ clash sub remove <名称>
WSL / 普通用户如果无权写入 `/etc/environment`,`clashon` 会自动降级:运行时照常启动,当前 Shell 代理变量生效;系统代理持久接管和开机代理保持不可用。 WSL / 普通用户如果无权写入 `/etc/environment`,`clashon` 会自动降级:运行时照常启动,当前 Shell 代理变量生效;系统代理持久接管和开机代理保持不可用。
### 直接分享链接
`clash add` 可以直接导入 `vmess://`、`vless://`、`trojan://`、`tuic://`、`hysteria2://`、`hy2://` 和 `anytls://` 分享链接:
```bash
clash add "vless://..." jp
```
分享链接通常包含 `&` 等 Shell 特殊字符,请始终使用引号包住完整链接。链接中的凭据会保存在本机订阅配置中,但命令反馈只显示脱敏后的协议类型。
### 本地配置导入 ### 本地配置导入
推荐使用交互导入,放置目录为:`$PROJECT_DIR/runtime/subscriptions/` 推荐使用交互导入,放置目录为:`$PROJECT_DIR/runtime/subscriptions/`
+1 -1
View File
@@ -1423,7 +1423,7 @@ print_add_feedback() {
local url="${2:-}" local url="${2:-}"
echo "✔ 已添加订阅并设为当前主订阅:$name" echo "✔ 已添加订阅并设为当前主订阅:$name"
[ -n "${url:-}" ] && echo "📡 URL:$url" [ -n "${url:-}" ] && echo "📡 URL:$(subscription_url_for_display "$url")"
echo "📋 最新订阅列表(clash ls):" echo "📋 最新订阅列表(clash ls):"
} }
+95 -34
View File
@@ -458,18 +458,23 @@ subscription_url_scheme() {
local url="$1" local url="$1"
case "$url" in case "$url" in
http://*) echo "http" ;; http://*) echo "http" ;;
https://*) echo "https" ;; https://*) echo "https" ;;
file://*) echo "file" ;; file://*) echo "file" ;;
*) echo "unknown" ;; vmess://*) echo "vmess" ;;
vless://*) echo "vless" ;;
trojan://*) echo "trojan" ;;
tuic://*) echo "tuic" ;;
hysteria2://*) echo "hysteria2" ;;
hy2://*) echo "hy2" ;;
anytls://*) echo "anytls" ;;
*) echo "unknown" ;;
esac esac
} }
subscription_url_is_supported() { subscription_scheme_is_share_link() {
local url="$1" case "$1" in
vmess|vless|trojan|tuic|hysteria2|hy2|anytls)
case "$(subscription_url_scheme "$url")" in
http|https|file)
return 0 return 0
;; ;;
*) *)
@@ -478,6 +483,37 @@ subscription_url_is_supported() {
esac esac
} }
subscription_url_is_share_link() {
subscription_scheme_is_share_link "$(subscription_url_scheme "$1")"
}
subscription_url_is_supported() {
local url="$1" scheme
scheme="$(subscription_url_scheme "$url")"
case "$scheme" in
http|https|file)
return 0
;;
*)
subscription_scheme_is_share_link "$scheme"
;;
esac
}
subscription_url_for_display() {
local url="$1" scheme
scheme="$(subscription_url_scheme "$url")"
if subscription_scheme_is_share_link "$scheme"; then
printf '%s://<redacted>\n' "$scheme"
return 0
fi
printf '%s\n' "$url"
}
subscription_file_path_from_url() { subscription_file_path_from_url() {
local url="$1" local url="$1"
local path local path
@@ -801,20 +837,22 @@ subscription_cache_store() {
local fmt="${2:-clash}" local fmt="${2:-clash}"
local src="$3" local src="$3"
local source_url="${4:-}" local source_url="${4:-}"
local cache_file meta_file local cache_file meta_file display_url display_source_url
[ -s "$src" ] || return 0 [ -s "$src" ] || return 0
cache_file="$(subscription_cache_file "$url" "$fmt")" cache_file="$(subscription_cache_file "$url" "$fmt")"
meta_file="$(subscription_cache_meta_file "$url" "$fmt")" meta_file="$(subscription_cache_meta_file "$url" "$fmt")"
display_url="$(subscription_url_for_display "$url")"
display_source_url="$(subscription_url_for_display "$source_url")"
mkdir -p "$(download_cache_dir)" mkdir -p "$(download_cache_dir)"
cp -f "$src" "$cache_file" cp -f "$src" "$cache_file"
cat > "$meta_file" <<EOF cat > "$meta_file" <<EOF
CACHE_URL="$url" CACHE_URL="$display_url"
CACHE_FORMAT="$fmt" CACHE_FORMAT="$fmt"
CACHE_SOURCE_URL="$source_url" CACHE_SOURCE_URL="$display_source_url"
CACHE_TIME="$(now_datetime)" CACHE_TIME="$(now_datetime)"
EOF EOF
} }
@@ -1242,6 +1280,7 @@ print_subscription_health_one() {
type_text="$(subscription_format_by_name "$name" 2>/dev/null || echo "clash")" type_text="$(subscription_format_by_name "$name" 2>/dev/null || echo "clash")"
url_text="$(subscription_url_by_name "$name" 2>/dev/null || true)" url_text="$(subscription_url_by_name "$name" 2>/dev/null || true)"
url_text="$(subscription_url_for_display "$url_text")"
echo "📡 订阅名称:$name" echo "📡 订阅名称:$name"
echo "🔧 订阅类型:$type_text" echo "🔧 订阅类型:$type_text"
@@ -1269,6 +1308,7 @@ print_subscription_health_verbose() {
enabled="$("$(yq_bin)" eval ".sources.${name}.enabled // false" "$file" 2>/dev/null)" enabled="$("$(yq_bin)" eval ".sources.${name}.enabled // false" "$file" 2>/dev/null)"
fmt="$("$(yq_bin)" eval ".sources.${name}.type // \"clash\"" "$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)" url="$("$(yq_bin)" eval ".sources.${name}.url // \"\"" "$file" 2>/dev/null)"
url="$(subscription_url_for_display "$url")"
if [ "$name" = "$active" ]; then if [ "$name" = "$active" ]; then
echo "* $name" echo "* $name"
@@ -2372,6 +2412,7 @@ show_subscription() {
url="$(subscription_url 2>/dev/null || true)" url="$(subscription_url 2>/dev/null || true)"
fmt="$(subscription_format)" fmt="$(subscription_format)"
url="$(subscription_url_for_display "$url")"
if [ -n "${url:-}" ]; then if [ -n "${url:-}" ]; then
echo "订阅地址:$url" echo "订阅地址:$url"
@@ -2997,6 +3038,7 @@ print_subscription_pick_line() {
type_text="$(subscription_format_by_name "$name" 2>/dev/null || echo "clash")" type_text="$(subscription_format_by_name "$name" 2>/dev/null || echo "clash")"
url_text="$(subscription_url_by_name "$name" 2>/dev/null || true)" url_text="$(subscription_url_by_name "$name" 2>/dev/null || true)"
url_text="$(subscription_url_for_display "$url_text")"
[ -n "${url_text:-}" ] || url_text="-" [ -n "${url_text:-}" ] || url_text="-"
if [ "$show_index" = "true" ]; then if [ "$show_index" = "true" ]; then
@@ -3103,6 +3145,11 @@ subscription_list_recommendation_lines() {
detect_subscription_format() { detect_subscription_format() {
local url="$1" local url="$1"
if subscription_url_is_share_link "$url"; then
echo "convert"
return 0
fi
case "$url" in case "$url" in
*.yaml|*.yml|*".yaml?"*|*".yml?"*) *.yaml|*.yml|*".yaml?"*|*".yml?"*)
echo "clash" echo "clash"
@@ -3542,7 +3589,7 @@ convert_subscription_via_subconverter() {
local api tmp_file curl_error_file local api tmp_file curl_error_file
local curl_meta curl_rc http_code effective_url errexit_was_set local curl_meta curl_rc http_code effective_url errexit_was_set
local log_file local log_file
local scheme convert_url subscription_ua local scheme convert_url display_convert_url subscription_ua
local validate_ok="false" local validate_ok="false"
local failure_type process_status preview_file local failure_type process_status preview_file
@@ -3561,10 +3608,13 @@ convert_subscription_via_subconverter() {
convert_url="$LOCAL_SUBSCRIPTION_CONVERT_URL" convert_url="$LOCAL_SUBSCRIPTION_CONVERT_URL"
;; ;;
*) *)
die "不支持的订阅协议:$url" subscription_scheme_is_share_link "$scheme" \
|| die "不支持的订阅协议:$url"
;; ;;
esac esac
display_convert_url="$(subscription_url_for_display "$convert_url")"
case "$fetch_reason" in case "$fetch_reason" in
auto|install|bootstrap|"") auto|install|bootstrap|"")
if [ "$scheme" != "file" ] && subscription_cache_restore "$url" "convert" "$out_file"; then if [ "$scheme" != "file" ] && subscription_cache_restore "$url" "convert" "$out_file"; then
@@ -3627,10 +3677,16 @@ convert_subscription_via_subconverter() {
if [ "$scheme" = "file" ]; then if [ "$scheme" = "file" ]; then
echo "param: url=<local subscription share links>" echo "param: url=<local subscription share links>"
else else
echo "param: url=$convert_url" echo "param: url=$display_convert_url"
fi fi
echo "not_sent_params: insert/config/emoji/list (use subconverter defaults)" echo "not_sent_params: insert/config/emoji/list (use subconverter defaults)"
[ -n "${effective_url:-}" ] && echo "effective_url: $effective_url" if [ -n "${effective_url:-}" ]; then
if subscription_scheme_is_share_link "$scheme"; then
echo "effective_url: <redacted local subconverter request>"
else
echo "effective_url: $effective_url"
fi
fi
echo "curl_rc: $curl_rc" echo "curl_rc: $curl_rc"
echo "http_code: ${http_code:-unknown}" echo "http_code: ${http_code:-unknown}"
echo "process_status: $process_status" echo "process_status: $process_status"
@@ -4067,25 +4123,30 @@ fetch_subscription_source() {
else else
reason="订阅转换失败" reason="订阅转换失败"
if [ "${SUBCONVERTER_LAST_ZERO_NODES:-false}" = "true" ]; then if [ "${SUBCONVERTER_LAST_ZERO_NODES:-false}" = "true" ]; then
warn "subconverter 未解析到节点,尝试直接使用原始订阅(Clash YAML fallback)" if subscription_url_is_share_link "$url"; then
warn "subconverter 未从分享链接解析到节点"
rm -f "$raw_file" "$candidate_file" 2>/dev/null || true reason="订阅转换失败:No nodes were found;分享链接无法作为 Clash YAML fallback"
raw_file="$(mktemp)"
candidate_file="$(mktemp)"
rm -f "$raw_file" "$candidate_file" 2>/dev/null || true
if download_subscription_yaml "$url" "$raw_file" "$fetch_reason"; then
if build_runtime_candidate_from_payload "$raw_file" "$candidate_file" "$name"; 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="订阅转换失败:No nodes were found;已尝试原始订阅 fallback,但原始订阅不是可直接运行的 Clash YAML"
else else
reason="订阅转换失败:No nodes were found;原始订阅 fallback 下载失败" warn "subconverter 未解析到节点,尝试直接使用原始订阅(Clash YAML fallback)"
rm -f "$raw_file" "$candidate_file" 2>/dev/null || true
raw_file="$(mktemp)"
candidate_file="$(mktemp)"
rm -f "$raw_file" "$candidate_file" 2>/dev/null || true
if download_subscription_yaml "$url" "$raw_file" "$fetch_reason"; then
if build_runtime_candidate_from_payload "$raw_file" "$candidate_file" "$name"; 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="订阅转换失败:No nodes were found;已尝试原始订阅 fallback,但原始订阅不是可直接运行的 Clash YAML"
else
reason="订阅转换失败:No nodes were found;原始订阅 fallback 下载失败"
fi
fi fi
fi fi
fi fi
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
source_clashctl_for_tests() {
set -- ""
# Source the real command functions; suppress the no-arg usage printed by the dispatcher.
source "$PROJECT_DIR/scripts/core/clashctl.sh" >/dev/null
}
assert_equal() {
local name="$1"
local expected="$2"
local actual="$3"
if [ "$actual" != "$expected" ]; then
echo "not ok - $name: got '$actual', expected '$expected'" >&2
exit 1
fi
echo "ok - $name"
}
source_clashctl_for_tests
schemes=(vmess vless trojan tuic hysteria2 hy2 anytls)
urls=(
'vmess://test-payload'
'vless://00000000-0000-0000-0000-000000000000@example.com:443?security=tls&alpn=h2&flow=xtls-rprx-vision#jp'
'trojan://test-password@example.com:443#trojan'
'tuic://00000000-0000-0000-0000-000000000000:test-password@example.com:443#tuic'
'hysteria2://test-password@example.com:443#hy2'
'hy2://test-password@example.com:443#hy2-short'
'anytls://test-password@example.com:443#anytls'
)
for index in "${!urls[@]}"; do
scheme="${schemes[$index]}"
url="${urls[$index]}"
assert_equal "$scheme scheme detection" "$scheme" "$(subscription_url_scheme "$url")"
if ! subscription_url_is_supported "$url"; then
echo "not ok - $scheme direct share link should be accepted" >&2
exit 1
fi
echo "ok - $scheme direct share link is accepted"
assert_equal "$scheme format detection" "convert" "$(detect_subscription_format "$url")"
assert_equal "$scheme display redaction" "$scheme://<redacted>" "$(subscription_url_for_display "$url")"
done
assert_equal \
"https subscriptions remain direct Clash inputs" \
"clash" \
"$(detect_subscription_format 'https://example.com/subscription')"
assert_equal \
"file subscriptions remain direct Clash inputs" \
"clash" \
"$(detect_subscription_format 'file:///tmp/subscription.yaml')"
if subscription_url_is_supported 'vlesss://invalid-scheme'; then
echo "not ok - unknown share-link-like schemes should remain rejected" >&2
exit 1
fi
echo "ok - unknown share-link-like schemes remain rejected"
feedback="$(print_add_feedback jp "${urls[1]}")"
if printf '%s\n' "$feedback" | grep -Fq '00000000-0000-0000-0000-000000000000'; then
echo "not ok - add feedback should not expose share-link credentials" >&2
exit 1
fi
if ! printf '%s\n' "$feedback" | grep -Fq 'vless://<redacted>'; then
echo "not ok - add feedback should identify a redacted VLESS link" >&2
exit 1
fi
echo "ok - add feedback redacts share-link credentials"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
calls_file="$tmp_dir/calls"
sample_url="${urls[1]}"
prepare() { :; }
ensure_add_use_prerequisites() { :; }
ui_progress_line() { :; }
ui_progress_done() { :; }
set_subscription() {
printf 'set|%s|%s|%s|%s\n' "$1" "$2" "$3" "$4" >> "$calls_file"
}
set_active_subscription() { printf 'active|%s\n' "$1" >> "$calls_file"; }
apply_runtime_change_after_config_mutation() { printf 'apply\n' >> "$calls_file"; }
print_add_feedback() { printf 'feedback|%s|%s\n' "$1" "$2" >> "$calls_file"; }
cmd_ls() { printf 'list\n' >> "$calls_file"; }
cmd_add "$sample_url" jp
expected_calls="$(printf '%s\n' \
"set|$sample_url|convert|jp|false" \
"active|jp" \
"apply" \
"feedback|jp|$sample_url" \
"list")"
assert_equal "clash add preserves and classifies a direct share link" "$expected_calls" "$(cat "$calls_file")"
(
# Exercise the production converter with only the local subprocess boundary stubbed.
source "$PROJECT_DIR/scripts/core/config.sh"
captured_url_file="$tmp_dir/captured-converter-url"
converter_log_file="$tmp_dir/subconverter.log"
converter_output_file="$tmp_dir/converted.yaml"
start_subconverter() { return 0; }
subconverter_url() { echo "http://127.0.0.1:25500"; }
subconverter_log_file() { echo "$converter_log_file"; }
subconverter_running() { return 0; }
subscription_yaml_validate() { return 0; }
subscription_yaml_has_no_nodes() { return 1; }
subscription_cache_store() { :; }
curl() {
local output_file="" argument
while [ "$#" -gt 0 ]; do
argument="$1"
case "$argument" in
--data-urlencode)
shift
case "${1:-}" in
url=*) printf '%s\n' "${1#url=}" > "$captured_url_file" ;;
esac
;;
-o)
shift
output_file="${1:-}"
;;
esac
shift || true
done
printf 'proxies: []\nproxy-groups: []\nrules: []\n' > "$output_file"
printf '200\nhttp://127.0.0.1:25500/sub?url=credential-bearing-link'
}
if ! convert_subscription_via_subconverter \
"$sample_url" \
"$converter_output_file" \
"manual-add" \
"direct-share-link"; then
echo "not ok - converter should accept a direct share link" >&2
exit 1
fi
assert_equal \
"converter receives the complete direct share link" \
"$sample_url" \
"$(cat "$captured_url_file")"
if grep -Fq '00000000-0000-0000-0000-000000000000' "$converter_log_file"; then
echo "not ok - converter diagnostics should not expose share-link credentials" >&2
exit 1
fi
if ! grep -Fq 'param: url=vless://<redacted>' "$converter_log_file"; then
echo "not ok - converter diagnostics should identify a redacted VLESS link" >&2
exit 1
fi
echo "ok - converter diagnostics redact share-link credentials"
)
(
# Re-source the production functions for the failure-path check.
source "$PROJECT_DIR/scripts/core/config.sh"
fallback_calls="$tmp_dir/fallback-calls"
subscription_url_by_name() { printf '%s\n' "$sample_url"; }
subscription_format_by_name() { echo "convert"; }
clear_subscription_cache() { :; }
convert_subscription_via_subconverter() {
SUBCONVERTER_LAST_ZERO_NODES="true"
return 1
}
download_subscription_yaml() {
echo "unexpected-download" >> "$fallback_calls"
return 1
}
mark_subscription_health_failure() { printf '%s\n' "$2" > "$tmp_dir/failure-reason"; }
warn() { :; }
if fetch_subscription_source jp "$tmp_dir/unused-runtime.yaml" "manual-add"; then
echo "not ok - a zero-node direct share link should report conversion failure" >&2
exit 1
fi
if [ -e "$fallback_calls" ]; then
echo "not ok - a direct share link should not enter the Clash YAML download fallback" >&2
exit 1
fi
expected_reason="订阅转换失败:No nodes were found;分享链接无法作为 Clash YAML fallback"
assert_equal \
"zero-node share links fail without an invalid download fallback" \
"$expected_reason" \
"$(cat "$tmp_dir/failure-reason")"
)