diff --git a/README.md b/README.md index bcc481d..25f813c 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ bash install.sh clashsecret 🔑 查看或设置 Web 密钥 📦 订阅 clashctl add ➕ 添加订阅 + clashctl add local ➕ 从 runtime/subscriptions 导入本地订阅 clashctl use 💱 切换订阅 clashctl ls 📡 查看订阅列表 📌 高级 @@ -157,6 +158,33 @@ clashctl sub remove <名称> WSL / 普通用户如果无权写入 `/etc/environment`,`clashon` 会自动降级:运行时照常启动,当前 Shell 代理变量生效;系统代理持久接管和开机代理保持不可用。 +### 本地配置导入 + +推荐使用交互导入,放置目录为:`$PROJECT_DIR/runtime/subscriptions/` + +```bash +clashctl add local +# 输入:clash.yaml +``` + +实际等价于: + +```bash +clashctl add "file://$PROJECT_DIR/runtime/subscriptions/clash.yaml" +``` + +进阶用法:也可以直接使用 `file://` 绝对路径导入: + +```bash +clashctl add "file:///绝对路径/clash.yaml" home +``` + +支持格式: + +- Clash / Mihomo YAML +- Base64 订阅 +- 分享链接(`vmess` / `vless` / `trojan` / `tuic`) + ### 开机接管(内核 + 代理) ```bash @@ -171,23 +199,37 @@ clashctl boot proxy on|off|status - `boot proxy`:只管理 `/etc/environment` 中的代理持久块(决定开机后是否自动保持代理变量)。 - `boot`:整体接管开关,等价于同时编排 runtime + proxy 两层状态。 -### 本地订阅 +------ -可以先手动下载 Clash 格式的订阅 YAML 文件到本地,再通过 `file://` 添加: +## OpenWrt 脚本模式 -格式:`clashctl add "file:///绝对路径/xxx.yaml" 名称` +当前提供 OpenWrt 脚本模式兼容,适合 x86_64/amd64 与 aarch64/arm64 软路由或设备。该模式复用现有 `script` 运行后端,不包含 procd 开机自启、LuCI、UCI 或 opkg 包化支持,也不承诺 MIPS 与 armv7 设备可用。 + +建议先将项目放到持久化目录,避免放在 `/tmp`、`/run` 等重启会丢失的位置: ```bash -mkdir -p ~/.config/clash-for-linux/subscriptions -curl -L "https://example.com/sub.yaml" -o ~/.config/clash-for-linux/subscriptions/home.yaml -clashctl add "file://$HOME/.config/clash-for-linux/subscriptions/home.yaml" home -clashctl use home -clashon +cd /root +git clone --branch master --depth 1 https://ghfast.top/https://github.com/wnlen/clash-for-linux.git +cd clash-for-linux ``` -`file://` 只支持 Clash 本地 YAML 配置,不支持 convert 格式订阅。如果订阅源需要转换,请先转换为 Clash YAML 后再保存到本地文件。 +安装依赖: ------- +```bash +opkg update +opkg install bash curl tar gzip coreutils-readlink unzip +``` + +安装与手动管理: + +```bash +bash install.sh +clashon +clashctl status +clashoff +``` + +OpenWrt 下 root/system 安装会把 `clashctl`、`clashon`、`clashoff` 等命令入口写入 `/usr/bin`,运行状态、日志和内核二进制仍保存在项目目录的 `runtime/` 下。仅脚本模式不会注册开机自启,设备重启后需要重新执行 `clashon`。 ## 🏗️ 架构设计架构简述 diff --git a/scripts/core/clashctl.sh b/scripts/core/clashctl.sh index 76b8611..df035c4 100644 --- a/scripts/core/clashctl.sh +++ b/scripts/core/clashctl.sh @@ -24,6 +24,7 @@ Usage: 🚀 Main Path: add ➕ 添加订阅 + add local ➕ 从 runtime/subscriptions 导入本地订阅 use 💱 切换订阅 select 💫 切换节点 @@ -91,6 +92,7 @@ usage_advanced() { 🚀 Main Path Reminder: clashctl add [订阅链接] [名称] + clashctl add local clashctl use clashon clashctl select @@ -275,7 +277,6 @@ cmd_on() { ui_warn "系统代理持久接管写入失败,仅当前 Shell 生效" ui_next "clashctl doctor" fi - ui_blank fi load_system_state @@ -2876,6 +2877,7 @@ cmd_ui_help_summary() { printf ' %-18s %s\n' "clashsecret" "🔑 查看或设置 Web 密钥" echo "📦 订阅" printf ' %-18s %s\n' "clashctl add" "➕ 添加订阅" + printf ' %-18s %s\n' "clashctl add local" "➕ 从 runtime/subscriptions 导入本地订阅" printf ' %-18s %s\n' "clashctl use" "💱 切换订阅" printf ' %-18s %s\n' "clashctl ls" "📡 查看订阅列表" echo "📌 高级" @@ -5741,12 +5743,36 @@ add_prompt_name() { done } +add_prompt_local_url() { + local input filename local_path + + printf "请输入本地订阅文件名(默认目录:%s/runtime/subscriptions/):" "$PROJECT_DIR" >&2 + IFS= read -r input || return 1 + filename="$(add_trim_input "$input")" + + [ -n "${filename:-}" ] || die "本地订阅文件名不能为空" + + local_path="$PROJECT_DIR/runtime/subscriptions/$filename" + + [ -f "$local_path" ] || die "本地订阅文件不存在:$local_path" + [ -s "$local_path" ] || die "本地订阅文件为空:$local_path" + + echo "file://$local_path" +} + cmd_add() { local sub_url sub_name sub_fmt prepare ensure_add_use_prerequisites + if [ "${1:-}" = "local" ]; then + [ "$#" -eq 1 ] || die_usage "add local 参数不合法" "clashctl add local" + sub_url="$(add_prompt_local_url)" + cmd_add "$sub_url" + return $? + fi + case "$#" in 0) sub_url="$(add_prompt_url)" @@ -5766,7 +5792,7 @@ cmd_add() { sub_fmt="$(detect_subscription_format "$sub_url")" - set_subscription "$sub_url" "$sub_fmt" "$sub_name" + set_subscription "$sub_url" "$sub_fmt" "$sub_name" "false" set_active_subscription "$sub_name" apply_runtime_change_after_config_mutation print_add_feedback "$sub_name" "$sub_url" diff --git a/scripts/core/config.sh b/scripts/core/config.sh index a6f67b1..4f07ccb 100644 --- a/scripts/core/config.sh +++ b/scripts/core/config.sh @@ -380,6 +380,115 @@ copy_local_subscription_yaml() { [ -s "$out_file" ] || die "本地订阅文件为空:$local_path" } +local_subscription_share_links_to_subconverter_url() { + local file="$1" + + awk ' + { + gsub(/\r/, "") + sub(/^[[:space:]]+/, "") + sub(/[[:space:]]+$/, "") + } + $0 == "" || $0 ~ /^#/ { next } + $0 ~ /^(vmess|vless|trojan|tuic):\/\// { + if (out != "") { + out = out "|" + } + out = out $0 + } + END { + if (out == "") { + exit 1 + } + print out + } + ' "$file" +} + +decode_local_subscription_base64() { + local src="$1" + local out="$2" + local compact len mod + + compact="$(mktemp)" + rm -f "$compact" 2>/dev/null || true + + tr -d '\r\n\t ' < "$src" | tr '_-' '/+' > "$compact" + [ -s "$compact" ] || { + rm -f "$compact" 2>/dev/null || true + return 1 + } + + if grep -Eq '[^A-Za-z0-9+/=]' "$compact"; then + rm -f "$compact" 2>/dev/null || true + return 1 + fi + + len="$(wc -c < "$compact" | tr -d '[:space:]')" + case "$len" in + ''|*[!0-9]*) + rm -f "$compact" 2>/dev/null || true + return 1 + ;; + esac + + mod=$((len % 4)) + case "$mod" in + 0) ;; + 2) printf '==' >> "$compact" ;; + 3) printf '=' >> "$compact" ;; + *) + rm -f "$compact" 2>/dev/null || true + return 1 + ;; + esac + + if base64 --decode "$compact" > "$out" 2>/dev/null || base64 -d "$compact" > "$out" 2>/dev/null; then + rm -f "$compact" 2>/dev/null || true + [ -s "$out" ] + return + fi + + rm -f "$compact" 2>/dev/null || true + return 1 +} + +local_subscription_convert_url_from_url() { + local url="$1" + local local_path decoded_file convert_url + + LOCAL_SUBSCRIPTION_CONVERT_URL="" + local_path="$(subscription_file_path_from_url "$url")" + + [ -f "$local_path" ] || die "本地订阅文件不存在:$local_path" + [ -r "$local_path" ] || die "本地订阅文件不可读:$local_path" + [ -s "$local_path" ] || die "本地订阅文件为空:$local_path" + + if convert_url="$(local_subscription_share_links_to_subconverter_url "$local_path")"; then + info "本地订阅识别为分享链接,将通过 subconverter 转换" + LOCAL_SUBSCRIPTION_CONVERT_URL="$convert_url" + return 0 + fi + + decoded_file="$(mktemp)" + rm -f "$decoded_file" 2>/dev/null || true + + if decode_local_subscription_base64 "$local_path" "$decoded_file"; then + if convert_url="$(local_subscription_share_links_to_subconverter_url "$decoded_file")"; then + rm -f "$decoded_file" 2>/dev/null || true + info "本地订阅识别为 Base64 分享链接,将通过 subconverter 转换" + LOCAL_SUBSCRIPTION_CONVERT_URL="$convert_url" + return 0 + fi + + rm -f "$decoded_file" 2>/dev/null || true + die "本地订阅不是 Clash YAML,Base64 解码后未发现支持的分享链接:$local_path" + fi + + rm -f "$decoded_file" 2>/dev/null || true + die "本地订阅不是 Clash YAML,且无法识别为 Base64 或 vmess/vless/trojan/tuic 分享链接:$local_path" +} + download_subscription_yaml() { local url="$1" local out_file="$2" @@ -2004,6 +2113,7 @@ set_subscription() { local url="$1" local fmt="${2:-}" local name="${3:-default}" + local should_regenerate="${4:-true}" local file [ -n "$url" ] || die "订阅地址不能为空" @@ -2032,7 +2142,9 @@ set_subscription() { } ' "$file" - regenerate_config + if [ "$should_regenerate" = "true" ]; then + regenerate_config + fi } set_active_subscription() { @@ -2945,15 +3057,20 @@ 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 local validate_ok="false" [ -n "${url:-}" ] || return 1 - case "$(subscription_url_scheme "$url")" in + scheme="$(subscription_url_scheme "$url")" + convert_url="$url" + + case "$scheme" in http|https) ;; file) - die "convert 格式暂不支持 file:// 本地订阅,请改用 clash 格式" + local_subscription_convert_url_from_url "$url" + convert_url="$LOCAL_SUBSCRIPTION_CONVERT_URL" ;; *) die "不支持的订阅协议:$url" @@ -2962,7 +3079,7 @@ convert_subscription_via_subconverter() { case "$fetch_reason" in auto|install|bootstrap|"") - if subscription_cache_restore "$url" "convert" "$out_file"; then + if [ "$scheme" != "file" ] && subscription_cache_restore "$url" "convert" "$out_file"; then if subscription_yaml_validate "$out_file"; then return 0 fi @@ -2982,7 +3099,11 @@ convert_subscription_via_subconverter() { info "正在通过 subconverter 转换订阅" case "$convert_reason" in direct-clash-invalid) - info "转换原因:直连订阅已下载,但不是可直接运行的 Clash YAML" + if [ "$scheme" = "file" ]; then + info "转换原因:本地订阅不是可直接运行的 Clash YAML" + else + info "转换原因:直连订阅已下载,但不是可直接运行的 Clash YAML" + fi ;; subscription-type-convert) info "转换原因:订阅类型为 convert" @@ -2993,7 +3114,11 @@ convert_subscription_via_subconverter() { esac info "subconverter 请求:GET $api" info "subconverter 参数:target=clash" - info "subconverter 参数:url=$url" + if [ "$scheme" = "file" ]; then + info "subconverter 参数:url=<本地订阅分享链接内容>" + else + info "subconverter 参数:url=$convert_url" + fi info "subconverter 未发送参数:insert/config/emoji/list(使用 subconverter 默认值)" errexit_was_set="false" @@ -3006,7 +3131,7 @@ convert_subscription_via_subconverter() { curl_meta="$(curl -sS -L -G "$api" \ --data-urlencode "target=clash" \ - --data-urlencode "url=$url" \ + --data-urlencode "url=$convert_url" \ -o "$tmp_file" \ -w '%{http_code}\n%{url_effective}' 2>"$curl_error_file")" curl_rc=$? @@ -3016,7 +3141,13 @@ convert_subscription_via_subconverter() { http_code="$(printf '%s\n' "$curl_meta" | head -n 1)" effective_url="$(printf '%s\n' "$curl_meta" | sed -n '2p')" - [ -n "${effective_url:-}" ] && info "subconverter 实际请求 URL:$effective_url" + if [ -n "${effective_url:-}" ]; then + if [ "$scheme" = "file" ]; then + info "subconverter 实际请求 URL:<已省略本地订阅分享链接内容>" + else + info "subconverter 实际请求 URL:$effective_url" + fi + fi [ -n "${http_code:-}" ] && info "subconverter HTTP 状态码:$http_code" if [ "$curl_rc" -ne 0 ] || [ -z "${http_code:-}" ] || [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then @@ -3056,7 +3187,7 @@ convert_subscription_via_subconverter() { fi mv -f "$tmp_file" "$out_file" - subscription_cache_store "$url" "convert" "$out_file" "$api" + [ "$scheme" = "file" ] || subscription_cache_store "$url" "convert" "$out_file" "$api" return 0 } @@ -3253,11 +3384,12 @@ fetch_subscription_source() { local name="$1" local out_file="$2" local fetch_reason="${3:-auto}" - local url fmt reason + local url fmt reason scheme local raw_file candidate_file url="$(subscription_url_by_name "$name")" fmt="$(subscription_format_by_name "$name")" + scheme="$(subscription_url_scheme "$url")" raw_file="$(mktemp)" candidate_file="$(mktemp)" @@ -3301,9 +3433,17 @@ fetch_subscription_source() { fi write_subscription_invalid_debug_snapshot "$raw_file" - reason="订阅下载成功,但原始配置与转换结果都不能直接运行" + if [ "$scheme" = "file" ]; then + reason="本地订阅转换成功,但 config 校验失败" + else + reason="订阅下载成功,但原始配置与转换结果都不能直接运行" + fi else - reason="订阅下载成功,但原始配置不能直接运行,且转换失败" + if [ "$scheme" = "file" ]; then + reason="本地订阅不是 Clash YAML,且 subconverter 转换失败" + else + reason="订阅下载成功,但原始配置不能直接运行,且转换失败" + fi fi else reason="订阅下载失败"