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