From 7cb66e7da17a175d34b34eb7eea4608aad19cf48 Mon Sep 17 00:00:00 2001 From: wnlen <62139570+wnlen@users.noreply.github.com> Date: Wed, 14 Jan 2026 13:55:06 +0800 Subject: [PATCH] Add subscription management, mixin, and tun support --- .env | 16 ++++ README.md | 43 ++++++++++ clashctl | 174 +++++++++++++++++++++++++++++++++++++--- conf/mixin.d/README.md | 9 +++ scripts/config_utils.sh | 86 ++++++++++++++++++++ start.sh | 5 ++ update.sh | 5 ++ 7 files changed, 329 insertions(+), 9 deletions(-) create mode 100644 conf/mixin.d/README.md create mode 100644 scripts/config_utils.sh diff --git a/.env b/.env index 7492a69..38e8a1c 100644 --- a/.env +++ b/.env @@ -2,6 +2,8 @@ export CLASH_URL='更改为你的clash订阅地址' export CLASH_SECRET='' export CLASH_HEADERS='User-Agent: ClashforWindows/0.20.39' +# 可选:订阅名称(clashctl sub use 会自动写入) +export CLASH_SUBSCRIPTION='' # 可选:指定 Clash 二进制路径(适用于额外架构或自定义位置) # export CLASH_BIN='/path/to/clash' @@ -17,3 +19,17 @@ export EXTERNAL_CONTROLLER_ENABLED=true export EXTERNAL_CONTROLLER=0.0.0.0:9090 # 端口可设置为 auto(自动分配随机端口) + +# Mixin 配置(可叠加多个 YAML 文件,后者可覆盖前者) +# export CLASH_MIXIN_PATHS='conf/mixin.d/base.yaml,conf/mixin.d/rules.yaml' +# export CLASH_MIXIN_DIR='conf/mixin.d' + +# Tun 模式配置(需要 Clash Premium/Meta 支持) +# export CLASH_TUN_ENABLE=false +# export CLASH_TUN_STACK=system +# export CLASH_TUN_AUTO_ROUTE=true +# export CLASH_TUN_AUTO_REDIRECT=false +# export CLASH_TUN_STRICT_ROUTE=false +# export CLASH_TUN_DEVICE= +# export CLASH_TUN_MTU= +# export CLASH_TUN_DNS_HIJACK='any:53' diff --git a/README.md b/README.md index 32fb70a..a45da80 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,17 @@ $ sudo ./clashctl update $ sudo ./clashctl set-url "https://example.com/your-subscribe" ``` +订阅管理(多订阅): + +```bash +$ sudo ./clashctl sub add office "https://example.com/office" "User-Agent: ClashforWindows/0.20.39" +$ sudo ./clashctl sub add personal "https://example.com/personal" +$ sudo ./clashctl sub list +$ sudo ./clashctl sub use personal +$ sudo ./clashctl sub update +$ sudo ./clashctl sub log +``` + 安装脚本会将 `clashctl` 安装到 `/usr/local/bin/clashctl`,安装后可直接使用: ```bash @@ -180,6 +191,38 @@ $ sudo bash restart.sh --update $ sudo bash update.sh ``` +如需通过订阅管理更新,可执行: + +```bash +$ sudo clashctl sub update personal +``` + +
+ +## Mixin 配置 + +可通过 mixin 追加或覆盖 Clash 配置。默认读取 `conf/mixin.d` 下的 `.yaml/.yml` 文件(按文件名排序)。也可以通过 `.env` 设置指定路径: + +```bash +export CLASH_MIXIN_PATHS='conf/mixin.d/base.yaml,conf/mixin.d/rules.yaml' +export CLASH_MIXIN_DIR='conf/mixin.d' +``` + +
+ +## Tun 模式 + +Tun 模式需要 Clash Premium/Meta 支持。可在 `.env` 中启用并配置: + +```bash +export CLASH_TUN_ENABLE=true +export CLASH_TUN_STACK=system +export CLASH_TUN_AUTO_ROUTE=true +export CLASH_TUN_AUTO_REDIRECT=false +export CLASH_TUN_STRICT_ROUTE=false +export CLASH_TUN_DNS_HIJACK='any:53' +``` +
## 停止程序 diff --git a/clashctl b/clashctl index d277cbd..4d26e2e 100755 --- a/clashctl +++ b/clashctl @@ -32,6 +32,7 @@ resolve_clash_home() { CLASH_HOME=$(resolve_clash_home) ENV_FILE="$CLASH_HOME/.env" PID_FILE="$CLASH_HOME/temp/clash.pid" +SUBSCRIPTION_FILE="$CLASH_HOME/conf/subscriptions.list" use_systemd() { command -v systemctl >/dev/null 2>&1 @@ -58,30 +59,157 @@ Commands: status Show Clash service status update Refresh subscription config set-url Update CLASH_URL in .env + sub add [headers] Add subscription entry + sub del Delete subscription entry + sub use Activate subscription entry + sub update [name] Update subscription config by entry + sub list List subscriptions + sub log Show subscription update logs Environment: CLASH_HOME Override Clash installation directory USAGE } +set_env_var() { + local key="$1" + local value="${2:-}" + if [ ! -f "$ENV_FILE" ]; then + echo "[ERROR] 未找到 .env 文件: $ENV_FILE" >&2 + exit 1 + fi + local escaped + escaped=$(printf "%s" "$value" | sed "s/'/'\"'\"'/g") + if grep -q "^export ${key}=" "$ENV_FILE"; then + sed -i "s@^export ${key}=.*@export ${key}='${escaped}'@" "$ENV_FILE" + else + echo "export ${key}='${escaped}'" >> "$ENV_FILE" + fi +} + set_url() { local url="$1" if [ -z "$url" ]; then echo "[ERROR] 请提供订阅地址" >&2 exit 1 fi - if [ ! -f "$ENV_FILE" ]; then - echo "[ERROR] 未找到 .env 文件: $ENV_FILE" >&2 + set_env_var "CLASH_URL" "$url" + set_env_var "CLASH_SUBSCRIPTION" "" + echo "[OK] 已更新 CLASH_URL" +} + +ensure_subscription_file() { + mkdir -p "$(dirname "$SUBSCRIPTION_FILE")" + if [ ! -f "$SUBSCRIPTION_FILE" ]; then + touch "$SUBSCRIPTION_FILE" + fi +} + +subscription_lookup() { + local name="$1" + awk -F'|' -v target="$name" '$1 == target {print; exit}' "$SUBSCRIPTION_FILE" +} + +subscription_add() { + local name="$1" + local url="$2" + local headers="${3:-}" + if [ -z "$name" ] || [ -z "$url" ]; then + echo "[ERROR] 用法: clashctl sub add [headers]" >&2 exit 1 fi - local escaped - escaped=$(printf "%s" "$url" | sed "s/'/'\"'\"'/g") - if grep -q '^export CLASH_URL=' "$ENV_FILE"; then - sed -i "s@^export CLASH_URL=.*@export CLASH_URL='${escaped}'@" "$ENV_FILE" - else - echo "export CLASH_URL='${escaped}'" >> "$ENV_FILE" + ensure_subscription_file + if subscription_lookup "$name" >/dev/null; then + echo "[ERROR] 订阅已存在: $name" >&2 + exit 1 fi - echo "[OK] 已更新 CLASH_URL" + printf "%s|%s|%s|-\n" "$name" "$url" "$headers" >> "$SUBSCRIPTION_FILE" + echo "[OK] 已添加订阅: $name" +} + +subscription_del() { + local name="$1" + if [ -z "$name" ]; then + echo "[ERROR] 用法: clashctl sub del " >&2 + exit 1 + fi + ensure_subscription_file + if ! subscription_lookup "$name" >/dev/null; then + echo "[ERROR] 未找到订阅: $name" >&2 + exit 1 + fi + awk -F'|' -v target="$name" 'BEGIN{OFS=FS} $1 != target {print}' "$SUBSCRIPTION_FILE" > "${SUBSCRIPTION_FILE}.tmp" + mv "${SUBSCRIPTION_FILE}.tmp" "$SUBSCRIPTION_FILE" + echo "[OK] 已删除订阅: $name" +} + +subscription_use() { + local name="$1" + if [ -z "$name" ]; then + echo "[ERROR] 用法: clashctl sub use " >&2 + exit 1 + fi + ensure_subscription_file + local line + line=$(subscription_lookup "$name") + if [ -z "$line" ]; then + echo "[ERROR] 未找到订阅: $name" >&2 + exit 1 + fi + local url headers + url=$(echo "$line" | awk -F'|' '{print $2}') + headers=$(echo "$line" | awk -F'|' '{print $3}') + set_env_var "CLASH_URL" "$url" + set_env_var "CLASH_HEADERS" "$headers" + set_env_var "CLASH_SUBSCRIPTION" "$name" + echo "[OK] 已切换订阅: $name" +} + +subscription_touch() { + local name="$1" + local timestamp + timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + awk -F'|' -v target="$name" -v ts="$timestamp" 'BEGIN{OFS=FS} {if ($1==target) {$4=ts} print}' "$SUBSCRIPTION_FILE" > "${SUBSCRIPTION_FILE}.tmp" + mv "${SUBSCRIPTION_FILE}.tmp" "$SUBSCRIPTION_FILE" +} + +subscription_update() { + local name="${1:-}" + if [ -z "$name" ]; then + name=$(awk -F= '/^export CLASH_SUBSCRIPTION=/{print $2}' "$ENV_FILE" | tr -d "'" | tr -d '"') + fi + if [ -z "$name" ]; then + echo "[ERROR] 未指定订阅名称,且 CLASH_SUBSCRIPTION 未设置" >&2 + exit 1 + fi + subscription_use "$name" + run_script update.sh + subscription_touch "$name" + echo "[OK] 订阅已更新: $name" +} + +subscription_list() { + ensure_subscription_file + local active + active=$(awk -F= '/^export CLASH_SUBSCRIPTION=/{print $2}' "$ENV_FILE" | tr -d "'" | tr -d '"') + printf "%-20s %-6s %s\n" "NAME" "ACTIVE" "URL" + while IFS='|' read -r name url headers updated; do + [ -z "$name" ] && continue + if [ "$name" = "$active" ]; then + printf "%-20s %-6s %s\n" "$name" "yes" "$url" + else + printf "%-20s %-6s %s\n" "$name" "no" "$url" + fi + done < "$SUBSCRIPTION_FILE" +} + +subscription_log() { + ensure_subscription_file + printf "%-20s %s\n" "NAME" "LAST_UPDATE" + while IFS='|' read -r name url headers updated; do + [ -z "$name" ] && continue + printf "%-20s %s\n" "$name" "${updated:--}" + done < "$SUBSCRIPTION_FILE" } status_fallback() { @@ -135,6 +263,34 @@ case "$command" in set-url) set_url "${2:-}" ;; + sub) + subcommand="${2:-}" + case "$subcommand" in + add) + subscription_add "${3:-}" "${4:-}" "${5:-}" + ;; + del|delete|rm|remove) + subscription_del "${3:-}" + ;; + use) + subscription_use "${3:-}" + ;; + update) + subscription_update "${3:-}" + ;; + list|ls) + subscription_list + ;; + log) + subscription_log + ;; + *) + echo "[ERROR] 未知订阅命令: $subcommand" >&2 + print_usage + exit 1 + ;; + esac + ;; -h|--help|help|'') print_usage ;; diff --git a/conf/mixin.d/README.md b/conf/mixin.d/README.md new file mode 100644 index 0000000..6bb5cf2 --- /dev/null +++ b/conf/mixin.d/README.md @@ -0,0 +1,9 @@ +# Mixin 配置目录 + +将额外的 Clash YAML 配置放在此目录下,脚本会按文件名排序后依次拼接到生成的 `config.yaml` 末尾。 + +如需手动指定顺序或使用自定义路径,请在 `.env` 中设置: + +```bash +export CLASH_MIXIN_PATHS='conf/mixin.d/base.yaml,conf/mixin.d/rules.yaml' +``` diff --git a/scripts/config_utils.sh b/scripts/config_utils.sh new file mode 100644 index 0000000..de9e95c --- /dev/null +++ b/scripts/config_utils.sh @@ -0,0 +1,86 @@ +#!/bin/bash + +trim_value() { + local value="$1" + echo "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' +} + +apply_tun_config() { + local config_path="$1" + local enable="${CLASH_TUN_ENABLE:-false}" + if [ "$enable" != "true" ]; then + return 0 + fi + + local stack="${CLASH_TUN_STACK:-system}" + local auto_route="${CLASH_TUN_AUTO_ROUTE:-true}" + local auto_redirect="${CLASH_TUN_AUTO_REDIRECT:-false}" + local strict_route="${CLASH_TUN_STRICT_ROUTE:-false}" + local device="${CLASH_TUN_DEVICE:-}" + local mtu="${CLASH_TUN_MTU:-}" + local dns_hijack="${CLASH_TUN_DNS_HIJACK:-}" + + { + echo "" + echo "tun:" + echo " enable: true" + echo " stack: ${stack}" + echo " auto-route: ${auto_route}" + echo " auto-redirect: ${auto_redirect}" + echo " strict-route: ${strict_route}" + if [ -n "$device" ]; then + echo " device: ${device}" + fi + if [ -n "$mtu" ]; then + echo " mtu: ${mtu}" + fi + if [ -n "$dns_hijack" ]; then + echo " dns-hijack:" + IFS=',' read -r -a hijacks <<< "$dns_hijack" + for item in "${hijacks[@]}"; do + local trimmed + trimmed=$(trim_value "$item") + if [ -n "$trimmed" ]; then + echo " - ${trimmed}" + fi + done + fi + } >> "$config_path" +} + +apply_mixin_config() { + local config_path="$1" + local base_dir="${2:-$Server_Dir}" + local mixin_dir="${CLASH_MIXIN_DIR:-$base_dir/conf/mixin.d}" + local mixin_paths=() + + if [ -n "${CLASH_MIXIN_PATHS:-}" ]; then + IFS=',' read -r -a mixin_paths <<< "$CLASH_MIXIN_PATHS" + fi + + if [ -d "$mixin_dir" ]; then + while IFS= read -r -d '' file; do + mixin_paths+=("$file") + done < <(find "$mixin_dir" -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) -print0 | sort -z) + fi + + for path in "${mixin_paths[@]}"; do + local trimmed + trimmed=$(trim_value "$path") + if [ -z "$trimmed" ]; then + continue + fi + if [ "${trimmed:0:1}" != "/" ]; then + trimmed="$base_dir/$trimmed" + fi + if [ -f "$trimmed" ]; then + { + echo "" + echo "# ---- mixin: ${trimmed} ----" + cat "$trimmed" + } >> "$config_path" + else + echo "[WARN] Mixin file not found: $trimmed" >&2 + fi + done +} diff --git a/start.sh b/start.sh index dea88e2..de564bc 100644 --- a/start.sh +++ b/start.sh @@ -49,6 +49,8 @@ CLASH_SOCKS_PORT=$(resolve_port_value "SOCKS" "$CLASH_SOCKS_PORT") CLASH_REDIR_PORT=$(resolve_port_value "REDIR" "$CLASH_REDIR_PORT") EXTERNAL_CONTROLLER=$(resolve_host_port "External Controller" "$EXTERNAL_CONTROLLER" "0.0.0.0") +source "$Server_Dir/scripts/config_utils.sh" + #################### 函数定义 #################### @@ -220,6 +222,9 @@ else sed -i "s/external-controller: 'EXTERNAL_CONTROLLER_PLACEHOLDER'/# external-controller: disabled/g" $Temp_Dir/config.yaml fi +apply_tun_config "$Temp_Dir/config.yaml" +apply_mixin_config "$Temp_Dir/config.yaml" "$Server_Dir" + \cp $Temp_Dir/config.yaml $Conf_Dir/ # Configure Clash Dashboard diff --git a/update.sh b/update.sh index b166f8e..31c7fad 100644 --- a/update.sh +++ b/update.sh @@ -42,6 +42,8 @@ CLASH_SOCKS_PORT=$(resolve_port_value "SOCKS" "$CLASH_SOCKS_PORT") CLASH_REDIR_PORT=$(resolve_port_value "REDIR" "$CLASH_REDIR_PORT") EXTERNAL_CONTROLLER=$(resolve_host_port "External Controller" "$EXTERNAL_CONTROLLER" "0.0.0.0") +source "$Server_Dir/scripts/config_utils.sh" + #################### 函数定义 #################### # 自定义action函数,实现通用action功能 @@ -190,6 +192,9 @@ else sed -i "s/external-controller: 'EXTERNAL_CONTROLLER_PLACEHOLDER'/# external-controller: disabled/g" $Temp_Dir/config.yaml fi +apply_tun_config "$Temp_Dir/config.yaml" +apply_mixin_config "$Temp_Dir/config.yaml" "$Server_Dir" + \cp $Temp_Dir/config.yaml $Conf_Dir/ # Configure Clash Dashboard