From b1214cfe3dd74a82d66b0aa481f4d3cea17eead0 Mon Sep 17 00:00:00 2001 From: Arvin <62139570+wnlen@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:53:06 +0800 Subject: [PATCH] Fix direct share-link classification and conversion (#315) --- README.md | 10 + scripts/core/clashctl.sh | 2 +- scripts/core/config.sh | 129 +++++++++---- scripts/dev/check-direct-share-link-add.sh | 206 +++++++++++++++++++++ 4 files changed, 312 insertions(+), 35 deletions(-) create mode 100644 scripts/dev/check-direct-share-link-add.sh diff --git a/README.md b/README.md index 82987d8..6d2b2c4 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,16 @@ clash sub remove <名称> WSL / 普通用户如果无权写入 `/etc/environment`,`clashon` 会自动降级:运行时照常启动,当前 Shell 代理变量生效;系统代理持久接管和开机代理保持不可用。 +### 直接分享链接 + +`clash add` 可以直接导入 `vmess://`、`vless://`、`trojan://`、`tuic://`、`hysteria2://`、`hy2://` 和 `anytls://` 分享链接: + +```bash +clash add "vless://..." jp +``` + +分享链接通常包含 `&` 等 Shell 特殊字符,请始终使用引号包住完整链接。链接中的凭据会保存在本机订阅配置中,但命令反馈只显示脱敏后的协议类型。 + ### 本地配置导入 推荐使用交互导入,放置目录为:`$PROJECT_DIR/runtime/subscriptions/` diff --git a/scripts/core/clashctl.sh b/scripts/core/clashctl.sh index 6391897..be46014 100644 --- a/scripts/core/clashctl.sh +++ b/scripts/core/clashctl.sh @@ -1423,7 +1423,7 @@ print_add_feedback() { local url="${2:-}" echo "✔ 已添加订阅并设为当前主订阅:$name" - [ -n "${url:-}" ] && echo "📡 URL:$url" + [ -n "${url:-}" ] && echo "📡 URL:$(subscription_url_for_display "$url")" echo "📋 最新订阅列表(clash ls):" } diff --git a/scripts/core/config.sh b/scripts/core/config.sh index 3a3c86a..d55edec 100644 --- a/scripts/core/config.sh +++ b/scripts/core/config.sh @@ -458,18 +458,23 @@ subscription_url_scheme() { local url="$1" case "$url" in - http://*) echo "http" ;; - https://*) echo "https" ;; - file://*) echo "file" ;; - *) echo "unknown" ;; + http://*) echo "http" ;; + https://*) echo "https" ;; + file://*) echo "file" ;; + vmess://*) echo "vmess" ;; + vless://*) echo "vless" ;; + trojan://*) echo "trojan" ;; + tuic://*) echo "tuic" ;; + hysteria2://*) echo "hysteria2" ;; + hy2://*) echo "hy2" ;; + anytls://*) echo "anytls" ;; + *) echo "unknown" ;; esac } -subscription_url_is_supported() { - local url="$1" - - case "$(subscription_url_scheme "$url")" in - http|https|file) +subscription_scheme_is_share_link() { + case "$1" in + vmess|vless|trojan|tuic|hysteria2|hy2|anytls) return 0 ;; *) @@ -478,6 +483,37 @@ subscription_url_is_supported() { 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://\n' "$scheme" + return 0 + fi + + printf '%s\n' "$url" +} + subscription_file_path_from_url() { local url="$1" local path @@ -801,20 +837,22 @@ subscription_cache_store() { local fmt="${2:-clash}" local src="$3" local source_url="${4:-}" - local cache_file meta_file + local cache_file meta_file display_url display_source_url [ -s "$src" ] || return 0 cache_file="$(subscription_cache_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)" cp -f "$src" "$cache_file" cat > "$meta_file" </dev/null || echo "clash")" url_text="$(subscription_url_by_name "$name" 2>/dev/null || true)" + url_text="$(subscription_url_for_display "$url_text")" echo "📡 订阅名称:$name" echo "🔧 订阅类型:$type_text" @@ -1269,6 +1308,7 @@ print_subscription_health_verbose() { 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)" + url="$(subscription_url_for_display "$url")" if [ "$name" = "$active" ]; then echo "* $name" @@ -2372,6 +2412,7 @@ show_subscription() { url="$(subscription_url 2>/dev/null || true)" fmt="$(subscription_format)" + url="$(subscription_url_for_display "$url")" if [ -n "${url:-}" ]; then echo "订阅地址:$url" @@ -2997,6 +3038,7 @@ print_subscription_pick_line() { 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_for_display "$url_text")" [ -n "${url_text:-}" ] || url_text="-" if [ "$show_index" = "true" ]; then @@ -3103,6 +3145,11 @@ subscription_list_recommendation_lines() { detect_subscription_format() { local url="$1" + if subscription_url_is_share_link "$url"; then + echo "convert" + return 0 + fi + case "$url" in *.yaml|*.yml|*".yaml?"*|*".yml?"*) echo "clash" @@ -3542,7 +3589,7 @@ convert_subscription_via_subconverter() { local api tmp_file curl_error_file local curl_meta curl_rc http_code effective_url errexit_was_set local log_file - local scheme convert_url subscription_ua + local scheme convert_url display_convert_url subscription_ua local validate_ok="false" local failure_type process_status preview_file @@ -3561,10 +3608,13 @@ convert_subscription_via_subconverter() { convert_url="$LOCAL_SUBSCRIPTION_CONVERT_URL" ;; *) - die "不支持的订阅协议:$url" + subscription_scheme_is_share_link "$scheme" \ + || die "不支持的订阅协议:$url" ;; esac + display_convert_url="$(subscription_url_for_display "$convert_url")" + case "$fetch_reason" in auto|install|bootstrap|"") if [ "$scheme" != "file" ] && subscription_cache_restore "$url" "convert" "$out_file"; then @@ -3627,10 +3677,16 @@ convert_subscription_via_subconverter() { if [ "$scheme" = "file" ]; then echo "param: url=" else - echo "param: url=$convert_url" + echo "param: url=$display_convert_url" fi 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: " + else + echo "effective_url: $effective_url" + fi + fi echo "curl_rc: $curl_rc" echo "http_code: ${http_code:-unknown}" echo "process_status: $process_status" @@ -4067,25 +4123,30 @@ fetch_subscription_source() { else reason="订阅转换失败" if [ "${SUBCONVERTER_LAST_ZERO_NODES:-false}" = "true" ]; then - 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" + if subscription_url_is_share_link "$url"; then + warn "subconverter 未从分享链接解析到节点" + reason="订阅转换失败:No nodes were found;分享链接无法作为 Clash YAML fallback" 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 diff --git a/scripts/dev/check-direct-share-link-add.sh b/scripts/dev/check-direct-share-link-add.sh new file mode 100644 index 0000000..8cb371f --- /dev/null +++ b/scripts/dev/check-direct-share-link-add.sh @@ -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://" "$(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://'; 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://' "$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")" +)