Merge pull request #202 from wnlen/dev

Dev
This commit is contained in:
Arvin
2026-04-14 17:04:56 +08:00
committed by GitHub
3 changed files with 232 additions and 24 deletions
+52 -10
View File
@@ -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`。
## 🏗️ 架构设计架构简述
+28 -2
View File
@@ -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"
+152 -12
View File
@@ -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="订阅下载失败"