Merge pull request #103 from wnlen/codex/add-subscription-management-with-mixin-and-tun-mode

Add subscription management, mixin and tun configuration support
This commit is contained in:
wnlen
2026-01-14 13:57:47 +08:00
committed by GitHub
7 changed files with 329 additions and 9 deletions

16
.env
View File

@ -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'

View File

@ -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
```
<br>
## 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'
```
<br>
## 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'
```
<br>
## 停止程序

174
clashctl
View File

@ -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 <url> Update CLASH_URL in .env
sub add <name> <url> [headers] Add subscription entry
sub del <name> Delete subscription entry
sub use <name> 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 <name> <url> [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 <name>" >&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 <name>" >&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
;;

9
conf/mixin.d/README.md Normal file
View File

@ -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'
```

86
scripts/config_utils.sh Normal file
View File

@ -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
}

View File

@ -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

View File

@ -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