This commit is contained in:
Arvin
2026-03-21 13:58:10 +08:00
parent f62ea80d43
commit dda67b180d
72 changed files with 848 additions and 1181 deletions

17
.env
View File

@ -20,6 +20,15 @@ export CLASH_URL=''
# false = 禁用自动更新,直接使用本地已有 config.yaml
export CLASH_AUTO_UPDATE="true"
# 自动下载 Mihomo 内核
CLASH_AUTO_DOWNLOAD=auto
# 内核固定版本
MIHOMO_VERSION=v1.19.21
# 内核自定义下载地址
CLASH_DOWNLOAD_URL_TEMPLATE='https://github.com/MetaCubeX/mihomo/releases/download/v1.19.21/mihomo-linux-amd64-v1.19.21.gz'
# 订阅请求头(可选)
# 常见机场需要 User-Agent如不需要可留空
export CLASH_HEADERS='User-Agent: ClashforWindows/0.20.39'
@ -42,6 +51,8 @@ CLASH_SHOW_SECRET=true
# 是否显示脱敏 Secret推荐
CLASH_SHOW_SECRET_MASKED=true
# External ControllerClash RESTful API
# ⚠️ 安全建议:
# - 默认仅监听本机127.0.0.1:9090 (推荐)
@ -84,10 +95,10 @@ export CLASH_CONFIG_TEST_TIMEOUT="30"
# -------------------------
# 可叠加多个 YAML 文件,后者覆盖前者(逗号分隔)
# export CLASH_MIXIN_PATHS='conf/mixin.d/base.yaml,conf/mixin.d/rules.yaml'
# export CLASH_MIXIN_PATHS='config/mixin.d/base.yaml,config/mixin.d/rules.yaml'
# 指定 Mixin 目录默认conf/mixin.d
# export CLASH_MIXIN_DIR='conf/mixin.d'
# 指定 Mixin 目录默认config/mixin.d
# export CLASH_MIXIN_DIR='config/mixin.d'
# -------------------------
# 6) Tun 模式(高级可选,需要 Clash Meta / Premium

View File

@ -48,7 +48,7 @@
```
git clone --branch master --depth 1 https://github.com/wnlen/clash-for-linux.git
cd clash-for-linux
sudo bash install.sh
bash install.sh
```
安装脚本将自动完成:
@ -68,13 +68,13 @@ sudo bash install.sh
编辑 `.env` 文件,设置订阅地址:
```
sudo bash -c 'echo "CLASH_URL=<订阅地址>" > /root/clash-for-linux/.env'
bash -c 'echo "CLASH_URL=<订阅地址>" > /root/clash-for-linux/.env'
```
配置完成后,**重启服务使配置生效**
```
sudo systemctl restart clash-for-linux.service
systemctl restart clash-for-linux.service
```
说明:
@ -91,7 +91,7 @@ sudo systemctl restart clash-for-linux.service
true = 启动时检查订阅并重新下载/转换配置
false = 禁用自动更新,直接使用本地已有 config.yaml
```
sudo bash -c 'echo "CLASH_AUTO_UPDATE=false" > /opt/clash-for-linux/.env'
bash -c 'echo "CLASH_AUTO_UPDATE=false" > /opt/clash-for-linux/.env'
```
------
@ -122,19 +122,19 @@ http://127.0.0.1:9090/ui
编辑 `.env` 文件,设置公网访问(对外端口不用改,改了机器人也能扫到,密钥设置长点就行):
```
sudo bash -c 'echo "EXTERNAL_CONTROLLER=0.0.0.0:9090" > /opt/clash-for-linux/.env'
bash -c 'echo "EXTERNAL_CONTROLLER=0.0.0.0:9090" > /opt/clash-for-linux/.env'
```
配置完成后,**重启服务使配置生效**
```
sudo systemctl restart clash-for-linux.service
systemctl restart clash-for-linux.service
```
密钥留空时:脚本可自动生成随机值
获取密钥命令:
```
sudo sed -nE 's/^[[:space:]]*secret:[[:space:]]*//p' "/opt/clash-for-linux/conf/config.yaml" | head -n 1
sed -nE 's/^[[:space:]]*secret:[[:space:]]*//p' "/opt/clash-for-linux/runtime/config.yaml" | head -n 1
```
@ -190,7 +190,7 @@ clashctl sub log
### 修改 Clash 配置并重启
```
vim conf/config.yaml
vim runtime/config.yaml
clashctl restart
```
@ -214,12 +214,12 @@ clashctl sub update personal
用于追加或覆盖 Clash 配置。
- 默认读取:`conf/mixin.d/*.yaml`(按文件名排序)
- 默认读取:`config/mixin.d/*.yaml`(按文件名排序)
- 也可在 `.env` 中指定:
```
export CLASH_MIXIN_DIR='conf/mixin.d'
export CLASH_MIXIN_PATHS='conf/mixin.d/base.yaml,conf/mixin.d/rules.yaml'
export CLASH_MIXIN_DIR='config/mixin.d'
export CLASH_MIXIN_PATHS='config/mixin.d/base.yaml,config/mixin.d/rules.yaml'
```
------
@ -267,7 +267,7 @@ env | grep -E 'http_proxy|https_proxy'
## 🧹 卸载
```
sudo bash uninstall.sh
bash uninstall.sh
```
------
@ -314,25 +314,25 @@ export SUBCONVERTER_DOWNLOAD_URL_TEMPLATE='https://example.com/subconverter_{arc
1. 开启 IP 转发
```bash
echo "net.ipv4.ip_forward = 1" | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
echo "net.ipv4.ip_forward = 1" | tee -a /etc/sysctl.conf
sysctl -p
```
2.配置iptables
```bash
# 先清空旧规则
sudo iptables -t nat -F
iptables -t nat -F
# 允许本机访问代理端口
sudo iptables -t nat -A OUTPUT -p tcp --dport 7890 -j RETURN
sudo iptables -t nat -A OUTPUT -p tcp --dport 7891 -j RETURN
sudo iptables -t nat -A OUTPUT -p tcp --dport 7892 -j RETURN
iptables -t nat -A OUTPUT -p tcp --dport 7890 -j RETURN
iptables -t nat -A OUTPUT -p tcp --dport 7891 -j RETURN
iptables -t nat -A OUTPUT -p tcp --dport 7892 -j RETURN
# 让所有 TCP 流量通过 7892 代理
sudo iptables -t nat -A PREROUTING -p tcp -j REDIRECT --to-ports 7892
iptables -t nat -A PREROUTING -p tcp -j REDIRECT --to-ports 7892
# 保存规则
sudo iptables-save | sudo tee /etc/iptables.rules
iptables-save | tee /etc/iptables.rules
```
3. 让 iptables 规则开机生效
@ -345,7 +345,7 @@ exit 0
```
```bash
sudo chmod +x /etc/rc.local
chmod +x /etc/rc.local
```

458
clashctl
View File

@ -65,44 +65,27 @@ usage() {
Usage:
clashctl COMMAND [OPTIONS]
Core Commands:
Commands:
on 开启当前终端代理
off 关闭当前终端代理
start 启动 Clash
stop 停止 Clash
restart 重新生成配置并重启
status 查看当前状态
update git pull + 生成配置 + 重启
generate 仅生成配置,不启动
mode 查看当前运行模式systemd/script/none
Utility Commands:
ui 输出 Dashboard 地址
secret 输出当前 secret
sub show 查看订阅地址
sub update 重新生成配置并重启
tun status|on|off 查看/启用/关闭 Tun
mixin status|on|off 查看/启用/关闭 Mixin
doctor 健康检查
logs [-f] [-n 100] 查看日志
update git pull + 重新生成配置并重启
sub show|update 查看订阅地址 / 重新生成配置并重启
tun status|on|off 查看/启用/关闭 Tun
mixin status|on|off 查看/启用/关闭 Mixin
Options:
--from-systemd 内部使用,避免 stop 递归调用 systemctl
-h, --help 显示帮助信息
Examples:
clashctl on
clashctl off
clashctl start
clashctl stop
clashctl restart
clashctl status
clashctl update
clashctl generate
clashctl ui
clashctl secret
clashctl sub show
clashctl tun on
EOF
}
@ -145,6 +128,66 @@ read_state_value() {
sed -nE "s/^${key}=(.*)$/\1/p" "$STATE_FILE" | head -n 1
}
command_exists() {
command -v "$1" >/dev/null 2>&1
}
port_from_controller() {
local controller
controller="$(read_runtime_config_value "external-controller" || true)"
if [ -n "${controller:-}" ]; then
printf '%s\n' "${controller##*:}"
else
printf '9090\n'
fi
}
http_port_from_config() {
local v
v="$(read_runtime_config_value "mixed-port" || true)"
if [ -n "${v:-}" ]; then
printf '%s\n' "$v"
return 0
fi
v="$(read_runtime_config_value "port" || true)"
if [ -n "${v:-}" ]; then
printf '%s\n' "$v"
return 0
fi
printf '7890\n'
}
check_port_listening() {
local port="$1"
if command_exists ss; then
ss -lnt 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${port}$"
return $?
elif command_exists netstat; then
netstat -lnt 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${port}$"
return $?
fi
return 2
}
check_dashboard_http() {
local url="$1"
if command_exists curl; then
curl -fsS --max-time 3 "$url" >/dev/null 2>&1
return $?
elif command_exists wget; then
wget -q -T 3 -O /dev/null "$url" >/dev/null 2>&1
return $?
fi
return 2
}
cmd_on() {
require_profiled
# shellcheck disable=SC1090
@ -163,6 +206,11 @@ cmd_mode() {
detect_mode
}
cmd_generate() {
"$PROJECT_DIR/scripts/generate_config.sh"
ok "Config generated"
}
cmd_start() {
local mode
mode="$(detect_mode)"
@ -174,6 +222,7 @@ cmd_start() {
;;
script|none)
start_via_script
ok "Clash started via script mode"
;;
*)
err "未知模式: $mode"
@ -190,13 +239,15 @@ cmd_stop() {
case "$mode" in
systemd)
if [ "$from_systemd" = "true" ]; then
# 被 systemd ExecStop 调用时,不能再反向 systemctl stop 自己
ok "Stop requested from systemd, skip recursive systemctl stop"
exit 0
return 0
fi
stop_via_systemd
ok "Clash stopped via systemd"
;;
systemd-installed)
info "systemd service installed but not running"
;;
script)
stop_via_script
ok "Clash stopped via script mode"
@ -211,51 +262,142 @@ cmd_stop() {
esac
}
cmd_generate() {
"$PROJECT_DIR/scripts/generate_config.sh"
ok "Config generated"
cmd_restart() {
cmd_generate
cmd_stop "${1:-false}" || true
cmd_start
}
cmd_restart() {
"$PROJECT_DIR/scripts/generate_config.sh"
cmd_update() {
git -C "$PROJECT_DIR" pull
cmd_restart
ok "Project updated"
}
local mode
mode="$(detect_mode)"
cmd_ui() {
local raw="${1:-}"
local controller host port secret base_url
case "$mode" in
systemd|systemd-installed)
restart_via_systemd
ok "Clash restarted via systemd"
controller="$(read_runtime_config_value "external-controller" || true)"
[ -n "${controller:-}" ] || controller="127.0.0.1:9090"
host="${controller%:*}"
port="${controller##*:}"
case "$host" in
0.0.0.0|::|localhost)
host="$(hostname -I 2>/dev/null | awk '{print $1}')"
[ -n "${host:-}" ] || host="127.0.0.1"
;;
script|none)
restart_via_script
ok "Clash restarted via script mode"
esac
secret="$(read_runtime_config_value "secret" || true)"
base_url="http://${host}:${port}/ui"
if [ -n "${secret:-}" ]; then
base_url="${base_url}/#/setup?hostname=${host}&port=${port}&secret=${secret}"
fi
if [ "$raw" = "--raw" ]; then
printf '%s\n' "$base_url"
return 0
fi
printf '%s\n' "$base_url"
}
cmd_secret() {
local secret
if [ ! -s "$RUNTIME_CONFIG" ]; then
err "runtime config not found: $RUNTIME_CONFIG"
echo "Please run: clashctl generate" >&2
exit 1
fi
secret="$(read_runtime_config_value "secret" || true)"
if [ -z "${secret:-}" ]; then
err "secret not found in $RUNTIME_CONFIG"
exit 1
fi
printf '%s\n' "$secret"
}
cmd_sub() {
local subcmd="${1:-show}"
case "$subcmd" in
show)
if [ -f "$ENV_FILE" ]; then
grep -E '^[[:space:]]*(export[[:space:]]+)?CLASH_URL=' "$ENV_FILE" || echo "CLASH_URL=未配置"
else
err "未找到 .env"
exit 1
fi
;;
update)
cmd_restart
;;
*)
err "未知模式: $mode"
err "未知 sub 子命令: $subcmd"
echo "用法: clashctl sub [show|update]"
exit 1
;;
esac
}
cmd_update() {
git -C "$PROJECT_DIR" pull
"$PROJECT_DIR/scripts/generate_config.sh"
cmd_tun() {
local subcmd="${1:-status}"
local mode
mode="$(detect_mode)"
case "$mode" in
systemd)
restart_via_systemd
ok "Project updated and restarted via systemd"
case "$subcmd" in
status)
if [ -f "$ENV_FILE" ]; then
grep -E '^[[:space:]]*(export[[:space:]]+)?CLASH_TUN=' "$ENV_FILE" || echo 'CLASH_TUN=未配置'
else
err "未找到 .env"
exit 1
fi
;;
script|none)
restart_via_script
ok "Project updated and restarted via script mode"
on)
write_env_bool "CLASH_TUN" "true"
ok "已写入 CLASH_TUN=true"
;;
off)
write_env_bool "CLASH_TUN" "false"
ok "已写入 CLASH_TUN=false"
;;
*)
err "未知模式: $mode"
err "未知 tun 子命令: $subcmd"
echo "用法: clashctl tun [status|on|off]"
exit 1
;;
esac
}
cmd_mixin() {
local subcmd="${1:-status}"
case "$subcmd" in
status)
if [ -f "$ENV_FILE" ]; then
grep -E '^[[:space:]]*(export[[:space:]]+)?CLASH_MIXIN=' "$ENV_FILE" || echo 'CLASH_MIXIN=未配置'
else
err "未找到 .env"
exit 1
fi
;;
on)
write_env_bool "CLASH_MIXIN" "true"
ok "已写入 CLASH_MIXIN=true"
;;
off)
write_env_bool "CLASH_MIXIN" "false"
ok "已写入 CLASH_MIXIN=false"
;;
*)
err "未知 mixin 子命令: $subcmd"
echo "用法: clashctl mixin [status|on|off]"
exit 1
;;
esac
@ -387,23 +529,6 @@ cmd_status() {
fi
echo "Secret : $secret_exists"
echo
case "$running" in
yes)
ok "status summary: running"
;;
no)
if [ "$mode" = "systemd-installed" ]; then
warn "status summary: installed but not running"
else
warn "status summary: not running"
fi
;;
*)
warn "status summary: unknown"
;;
esac
}
doctor_ok() {
@ -418,182 +543,6 @@ doctor_err() {
printf "\033[31m[ERROR]\033[0m %s\n" "$*"
}
command_exists() {
command -v "$1" >/dev/null 2>&1
}
port_from_controller() {
local controller
controller="$(read_runtime_config_value "external-controller" || true)"
if [ -n "${controller:-}" ]; then
printf '%s\n' "${controller##*:}"
else
printf '9090\n'
fi
}
http_port_from_config() {
local v
v="$(read_runtime_config_value "mixed-port" || true)"
if [ -n "${v:-}" ]; then
printf '%s\n' "$v"
return 0
fi
v="$(read_runtime_config_value "port" || true)"
if [ -n "${v:-}" ]; then
printf '%s\n' "$v"
return 0
fi
printf '7890\n'
}
check_port_listening() {
local port="$1"
if command_exists ss; then
ss -lnt 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${port}$"
return $?
elif command_exists netstat; then
netstat -lnt 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${port}$"
return $?
fi
return 2
}
check_dashboard_http() {
local url="$1"
if command_exists curl; then
curl -fsS --max-time 3 "$url" >/dev/null 2>&1
return $?
elif command_exists wget; then
wget -q -T 3 -O /dev/null "$url" >/dev/null 2>&1
return $?
fi
return 2
}
cmd_ui() {
local raw="${1:-}"
local controller host port
controller="$(read_runtime_config_value "external-controller" || true)"
[ -n "${controller:-}" ] || controller="127.0.0.1:9090"
host="${controller%:*}"
port="${controller##*:}"
case "$host" in
0.0.0.0|::|localhost)
host="$(hostname -I 2>/dev/null | awk '{print $1}')"
[ -n "${host:-}" ] || host="127.0.0.1"
;;
esac
if [ "$raw" = "--raw" ]; then
printf 'http://%s:%s/ui\n' "$host" "$port"
return 0
fi
echo "Dashboard URL:"
printf 'http://%s:%s/ui\n' "$host" "$port"
}
cmd_secret() {
local secret
secret="$(read_runtime_config_value "secret" || true)"
if [ -n "${secret:-}" ]; then
echo "$secret"
else
err "未读取到 secret"
exit 1
fi
}
cmd_sub() {
local subcmd="${1:-show}"
case "$subcmd" in
show)
if [ -f "$ENV_FILE" ]; then
grep -E '^[[:space:]]*(export[[:space:]]+)?CLASH_URL=' "$ENV_FILE" || echo "CLASH_URL=未配置"
else
err "未找到 .env"
exit 1
fi
;;
update)
cmd_restart
;;
*)
err "未知 sub 子命令: $subcmd"
echo "用法: clashctl sub [show|update]"
exit 1
;;
esac
}
cmd_tun() {
local subcmd="${1:-status}"
case "$subcmd" in
status)
if [ -f "$ENV_FILE" ]; then
grep -E '^[[:space:]]*(export[[:space:]]+)?CLASH_TUN=' "$ENV_FILE" || echo 'CLASH_TUN=未配置'
else
err "未找到 .env"
exit 1
fi
;;
on)
write_env_bool "CLASH_TUN" "true"
ok "已写入 CLASH_TUN=true"
;;
off)
write_env_bool "CLASH_TUN" "false"
ok "已写入 CLASH_TUN=false"
;;
*)
err "未知 tun 子命令: $subcmd"
echo "用法: clashctl tun [status|on|off]"
exit 1
;;
esac
}
cmd_mixin() {
local subcmd="${1:-status}"
case "$subcmd" in
status)
if [ -f "$ENV_FILE" ]; then
grep -E '^[[:space:]]*(export[[:space:]]+)?CLASH_MIXIN=' "$ENV_FILE" || echo 'CLASH_MIXIN=未配置'
else
err "未找到 .env"
exit 1
fi
;;
on)
write_env_bool "CLASH_MIXIN" "true"
ok "已写入 CLASH_MIXIN=true"
;;
off)
write_env_bool "CLASH_MIXIN" "false"
ok "已写入 CLASH_MIXIN=false"
;;
*)
err "未知 mixin 子命令: $subcmd"
echo "用法: clashctl mixin [status|on|off]"
exit 1
;;
esac
}
cmd_doctor() {
local mode running="no"
local failed=0
@ -612,7 +561,6 @@ cmd_doctor() {
echo "Config : $RUNTIME_CONFIG"
echo
# 1. 检查运行配置是否存在
if [ -s "$RUNTIME_CONFIG" ]; then
doctor_ok "runtime config exists: $RUNTIME_CONFIG"
else
@ -620,7 +568,6 @@ cmd_doctor() {
failed=1
fi
# 2. 检查配置里是否还有未渲染占位符
if [ -f "$RUNTIME_CONFIG" ]; then
if grep -q '\${' "$RUNTIME_CONFIG"; then
doctor_err "runtime config contains unresolved placeholders"
@ -630,7 +577,6 @@ cmd_doctor() {
fi
fi
# 3. 检查 state.env
if [ -f "$STATE_FILE" ]; then
doctor_ok "state file exists: $STATE_FILE"
@ -658,7 +604,6 @@ cmd_doctor() {
warned=1
fi
# 4. 检查运行模式 / 进程状态
case "$mode" in
systemd)
if systemctl is-active --quiet "$SERVICE_NAME"; then
@ -692,7 +637,6 @@ cmd_doctor() {
;;
esac
# 5. 检查 dashboard 地址
dashboard_url="$(cmd_ui --raw 2>/dev/null || true)"
controller="$(read_runtime_config_value "external-controller" || true)"
dashboard_port="$(port_from_controller)"
@ -711,7 +655,6 @@ cmd_doctor() {
warned=1
fi
# 6. 检查 HTTP 代理端口
http_port="$(http_port_from_config)"
if check_port_listening "$http_port"; then
doctor_ok "proxy port is listening: $http_port"
@ -731,7 +674,6 @@ cmd_doctor() {
fi
fi
# 7. 检查 dashboard 端口
if check_port_listening "$dashboard_port"; then
doctor_ok "dashboard port is listening: $dashboard_port"
else
@ -750,7 +692,6 @@ cmd_doctor() {
fi
fi
# 8. 检查 dashboard HTTP 可访问性
if [ -n "${dashboard_url:-}" ]; then
if check_dashboard_http "$dashboard_url"; then
doctor_ok "dashboard http reachable"
@ -766,7 +707,6 @@ cmd_doctor() {
fi
fi
# 9. 检查 secret
secret="$(read_runtime_config_value "secret" || true)"
if [ -n "${secret:-}" ]; then
doctor_ok "secret exists in runtime config"
@ -775,7 +715,6 @@ cmd_doctor() {
warned=1
fi
# 10. 检查 clashctl 安装位置(可选)
if command_exists clashctl; then
doctor_ok "clashctl command available: $(command -v clashctl)"
else
@ -783,7 +722,6 @@ cmd_doctor() {
warned=1
fi
# 11. 检查代理快捷函数文件
if [ -f "$PROFILED_FILE" ]; then
doctor_ok "shell proxy helper exists: $PROFILED_FILE"
else
@ -807,13 +745,13 @@ cmd_doctor() {
}
cmd_logs() {
shift || true
local follow="false"
local lines="50"
local mode
local arg
shift || true
while [ $# -gt 0 ]; do
arg="$1"
case "$arg" in
@ -893,7 +831,7 @@ main() {
cmd_stop "$from_systemd"
;;
restart)
cmd_restart
cmd_restart "$from_systemd"
;;
status)
cmd_status

View File

@ -5,5 +5,5 @@
如需手动指定顺序或使用自定义路径,请在 `.env` 中设置:
```bash
export CLASH_MIXIN_PATHS='conf/mixin.d/base.yaml,conf/mixin.d/rules.yaml'
export CLASH_MIXIN_PATHS='config/mixin.d/base.yaml,config/mixin.d/rules.yaml'
```

View File

@ -25,14 +25,14 @@ Mixin 用于在 **不直接修改主配置文件** 的情况下,
### Default Behavior
- 默认读取目录:`conf/mixin.d/`
- 默认读取目录:`config/mixin.d/`
- 按文件名排序后依次合并
- 后加载的文件会覆盖前面的配置
### Example
```yaml
# conf/mixin.d/10-rules.yaml
# config/mixin.d/10-rules.yaml
rules:
- DOMAIN-SUFFIX,example.com,DIRECT
```
@ -170,7 +170,7 @@ clash-for-linux 以 **安全默认配置** 为原则:
### Service Keeps Restarting
- 检查 `conf/config.yaml` 是否存在语法错误
- 检查 `runtime/config.yaml` 是否存在语法错误
- 查看 systemd 日志:
```

View File

@ -87,15 +87,14 @@ CLASH_START_SERVICE=true
### `CLASH_AUTO_DOWNLOAD`
```
```env
CLASH_AUTO_DOWNLOAD=auto
```
- 是否自动下载 Clash 内核
- 是否在本地未检测到可用内核时自动下载 Mihomo 内核
- 可选值:
- `auto`(默认):检测不到内核时自动下载
- `true`:强制重新下载
- `false`:关闭自动下载
- `auto`(默认):当未检测到可用内核时自动下载(已有内核则不覆盖)
- `false`:不进行任何自动下载,仅使用本地已有内核(找不到则报错)
- `true`:强制重新下载内核(即使本地已有也会覆盖)
适用于:
@ -107,12 +106,13 @@ CLASH_AUTO_DOWNLOAD=auto
### `CLASH_DOWNLOAD_URL_TEMPLATE`
```
CLASH_DOWNLOAD_URL_TEMPLATE=https://github.com/Dreamacro/clash/releases/latest/download/clash-{arch}.gz
```
```env
CLASH_DOWNLOAD_URL_TEMPLATE=https://your-mirror.example.com/{version}/mihomo-{arch}-{version}.gz
- Mihomo 内核下载地址模板(可选,高级配置)
- 仅在 CLASH_AUTO_DOWNLOAD=true 或 auto 且本地无内核时生效
- 默认情况下无需配置,脚本会自动使用官方 GitHub Release 地址
- Clash 内核下载地址模板
- `{arch}` 会自动替换为当前系统架构(如 `amd64``arm64`
适用于:
@ -173,13 +173,13 @@ Mixin 用于在不修改主配置的情况下,追加或覆盖 Clash 配置项
### 默认行为
- 默认读取目录:`conf/mixin.d/`
- 默认读取目录:`config/mixin.d/`
- 按文件名排序后依次合并
### 示例
```
# conf/mixin.d/rules.yaml
# config/mixin.d/rules.yaml
rules:
- DOMAIN-SUFFIX,example.com,DIRECT
```

View File

@ -1,113 +1,33 @@
#!/bin/bash
#!/usr/bin/env bash
set -euo pipefail
# =========================
# 基础参数
# =========================
Server_Dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
Install_Dir="${CLASH_INSTALL_DIR:-$Server_Dir}"
Server_Dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
Install_Dir="${CLASH_INSTALL_DIR:-/opt/clash-for-linux}"
Service_Name="clash-for-linux"
Service_User="root"
Service_Group="root"
# =========================
# 彩色输出(统一 printf + 自动降级 + 手动关色)
# =========================
# ---- 关色开关(优先级最高)----
NO_COLOR_FLAG=0
for arg in "$@"; do
case "$arg" in
--no-color|--nocolor)
NO_COLOR_FLAG=1
;;
esac
done
if [[ -n "${NO_COLOR:-}" ]] || [[ -n "${CLASH_NO_COLOR:-}" ]]; then
NO_COLOR_FLAG=1
fi
# ---- 初始化颜色 ----
if [[ "$NO_COLOR_FLAG" -eq 0 ]] && [[ -t 1 ]] && command -v tput >/dev/null 2>&1; then
if tput setaf 1 >/dev/null 2>&1; then
C_RED="$(tput setaf 1)"
C_GREEN="$(tput setaf 2)"
C_YELLOW="$(tput setaf 3)"
C_BLUE="$(tput setaf 4)"
C_CYAN="$(tput setaf 6)"
C_GRAY="$(tput setaf 8 2>/dev/null || true)"
C_BOLD="$(tput bold)"
C_UL="$(tput smul)"
C_NC="$(tput sgr0)"
fi
fi
# ---- ANSI fallback ----
if [[ "$NO_COLOR_FLAG" -eq 0 ]] && [[ -t 1 ]] && [[ -z "${C_NC:-}" ]]; then
C_RED=$'\033[31m'
C_GREEN=$'\033[32m'
C_YELLOW=$'\033[33m'
C_BLUE=$'\033[34m'
C_CYAN=$'\033[36m'
C_GRAY=$'\033[90m'
C_BOLD=$'\033[1m'
C_UL=$'\033[4m'
C_NC=$'\033[0m'
fi
# ---- 强制无色 ----
if [[ "$NO_COLOR_FLAG" -eq 1 ]] || [[ ! -t 1 ]]; then
C_RED='' C_GREEN='' C_YELLOW='' C_BLUE='' C_CYAN='' C_GRAY='' C_BOLD='' C_UL='' C_NC=''
fi
# =========================
# 基础输出函数
# =========================
log() { printf "%b\n" "$*"; }
info() { log "${C_CYAN}[INFO]${C_NC} $*"; }
ok() { log "${C_GREEN}[OK]${C_NC} $*"; }
warn() { log "${C_YELLOW}[WARN]${C_NC} $*"; }
err() { log "${C_RED}[ERROR]${C_NC} $*"; }
# =========================
# 样式助手
# =========================
path() { printf "%b" "${C_BOLD}$*${C_NC}"; }
cmd() { printf "%b" "${C_GRAY}$*${C_NC}"; }
url() { printf "%b" "${C_UL}$*${C_NC}"; }
good() { printf "%b" "${C_GREEN}$*${C_NC}"; }
bad() { printf "%b" "${C_RED}$*${C_NC}"; }
# =========================
# 分段标题CLI 风格 section
# =========================
section() {
local title="$*"
log ""
log "${C_BOLD}${title}${C_NC}"
log "${C_GRAY}────────────────────────────────────────${C_NC}"
}
# =========================
# 前置校验
# 基础校验
# =========================
if [ "$(id -u)" -ne 0 ]; then
err "需要 root 权限执行安装脚本(请使用 bash install.sh"
echo "[ERROR] root required" >&2
exit 1
fi
if [ ! -f "${Server_Dir}/.env" ]; then
err "未找到 .env 文件,请确认脚本所在目录:${Server_Dir}"
echo "[ERROR] .env not found in ${Server_Dir}" >&2
exit 1
fi
# =========================
# 同步到安装目录(保持你原逻辑)
# 同步文件
# =========================
mkdir -p "$Install_Dir"
if [ "$Server_Dir" != "$Install_Dir" ]; then
info "同步项目文件到安装目录:${Install_Dir}"
echo "[INFO] sync project to ${Install_Dir}"
if command -v rsync >/dev/null 2>&1; then
rsync -a --delete --exclude '.git' "$Server_Dir/" "$Install_Dir/"
else
@ -115,253 +35,80 @@ if [ "$Server_Dir" != "$Install_Dir" ]; then
fi
fi
chmod +x "$Install_Dir"/*.sh 2>/dev/null || true
chmod +x "$Install_Dir"/clashctl 2>/dev/null || true
chmod +x "$Install_Dir"/scripts/* 2>/dev/null || true
chmod +x "$Install_Dir"/bin/* 2>/dev/null || true
chmod +x "$Install_Dir"/clashctl 2>/dev/null || true
# =========================
# 加载环境与依赖脚本
# 目录初始化(新结构)
# =========================
mkdir -p \
"$Install_Dir/runtime" \
"$Install_Dir/logs" \
"$Install_Dir/config/mixin.d"
# =========================
# 加载 env
# =========================
# shellcheck disable=SC1090
source "$Install_Dir/.env"
# shellcheck disable=SC1090
source "$Install_Dir/scripts/get_cpu_arch.sh"
# shellcheck disable=SC1090
source "$Install_Dir/scripts/resolve_clash.sh"
# shellcheck disable=SC1090
source "$Install_Dir/scripts/port_utils.sh"
if [[ -z "${CpuArch:-}" ]]; then
err "无法识别 CPU 架构"
# =========================
# 内核检查
# =========================
if ! resolve_clash_bin "$Install_Dir" "${CpuArch:-}" >/dev/null 2>&1; then
echo "[ERROR] clash core not ready" >&2
exit 1
fi
# =========================
# .env 写入工具write_env_kv必须在 prompt 之前定义)
# - 自动创建文件
# - 存在则替换,不存在则追加
# - 统一写成export KEY="VALUE"
# - 自动转义双引号/反斜杠
# 安装 clashctl
# =========================
escape_env_value() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
write_env_kv() {
local file="$1"
local key="$2"
local val="$3"
mkdir -p "$(dirname "$file")" 2>/dev/null || true
[ -f "$file" ] || touch "$file"
val="$(printf '%s' "$val" | tr -d '\r')"
local esc
esc="$(escape_env_value "$val")"
if grep -qE "^[[:space:]]*(export[[:space:]]+)?${key}=" "$file"; then
sed -i -E "s|^[[:space:]]*(export[[:space:]]+)?${key}=.*|export ${key}=\"${esc}\"|g" "$file"
else
printf 'export %s="%s"\n' "$key" "$esc" >> "$file"
fi
}
install -m 0755 "$Install_Dir/clashctl" /usr/local/bin/clashctl
# =========================
# 交互式填写订阅地址(仅在 CLASH_URL 为空时触发)
# - 若非 TTYCI/管道)则跳过交互
# - 若用户回车跳过,则保持原行为:装完提示手动配置
# 安装 proxy helper
# =========================
prompt_clash_url_if_empty() {
# 兼容 .env 里可能是 CLASH_URL= / export CLASH_URL= / 带引号
local cur="${CLASH_URL:-}"
cur="${cur%\"}"; cur="${cur#\"}"
if [ -n "$cur" ]; then
return 0
fi
# 非交互环境:不阻塞
if [ ! -t 0 ]; then
warn "CLASH_URL 为空且当前为非交互环境stdin 非 TTY将跳过输入引导。"
return 0
fi
echo
warn "未检测到订阅地址CLASH_URL 为空)"
echo "请粘贴你的 Clash 订阅地址(直接回车跳过,稍后手动编辑 .env"
read -r -p "Clash URL: " input_url
input_url="$(printf '%s' "$input_url" | tr -d '\r')"
# 回车跳过:保持原行为(不写入)
if [ -z "$input_url" ]; then
warn "已跳过填写订阅地址,安装完成后请手动编辑:${Install_Dir}/.env"
return 0
fi
# 先校验再写入,避免污染 .env
if ! echo "$input_url" | grep -Eq '^https?://'; then
err "订阅地址格式不正确(必须以 http:// 或 https:// 开头)"
exit 1
fi
ENV_FILE="${Install_Dir}/.env"
mkdir -p "$Install_Dir"
[ -f "$ENV_FILE" ] || touch "$ENV_FILE"
# ✅ 只用这一套写入逻辑(统一 export KEY="...",兼容旧格式)
write_env_kv "$ENV_FILE" "CLASH_URL" "$input_url"
export CLASH_URL="$input_url"
ok "已写入订阅地址到:${ENV_FILE}"
cat >/etc/profile.d/clash-for-linux.sh <<EOF
proxy_on() {
local port="\${1:-7890}"
export http_proxy="http://127.0.0.1:\${port}"
export https_proxy="\$http_proxy"
export HTTP_PROXY="\$http_proxy"
export HTTPS_PROXY="\$http_proxy"
export no_proxy="127.0.0.1,localhost"
export NO_PROXY="\$no_proxy"
echo "[OK] Proxy enabled: \$http_proxy"
}
prompt_clash_url_if_empty
proxy_off() {
unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY no_proxy NO_PROXY
echo "[OK] Proxy disabled"
}
EOF
chmod 644 /etc/profile.d/clash-for-linux.sh
# =========================
# 端口冲突检测(保持你原逻辑)
# =========================
CLASH_HTTP_PORT=${CLASH_HTTP_PORT:-7890}
CLASH_SOCKS_PORT=${CLASH_SOCKS_PORT:-7891}
CLASH_REDIR_PORT=${CLASH_REDIR_PORT:-7892}
EXTERNAL_CONTROLLER=${EXTERNAL_CONTROLLER:-127.0.0.1:9090}
parse_port() {
local raw="$1"
raw="${raw##*:}"
echo "$raw"
}
Port_Conflicts=()
for port in "$CLASH_HTTP_PORT" "$CLASH_SOCKS_PORT" "$CLASH_REDIR_PORT" "$(parse_port "$EXTERNAL_CONTROLLER")"; do
if [ "$port" = "auto" ] || [ -z "$port" ]; then
continue
fi
if [[ "$port" =~ ^[0-9]+$ ]]; then
if is_port_in_use "$port"; then
Port_Conflicts+=("$port")
fi
fi
done
if [ "${#Port_Conflicts[@]}" -ne 0 ]; then
warn "检测到端口冲突: ${Port_Conflicts[*]},运行时将自动分配可用端口"
fi
install -d -m 0755 "$Install_Dir/conf" "$Install_Dir/logs" "$Install_Dir/temp"
# =========================
# Clash 内核就绪检查/下载
# =========================
if ! resolve_clash_bin "$Install_Dir" "$CpuArch" >/dev/null 2>&1; then
err "Clash 内核未就绪,请检查下载配置或手动放置二进制"
exit 1
fi
# =========================
# fonction 工具函数区
# =========================
# 等待 config.yaml 出现并写入 secret默认最多等 6 秒)
wait_secret_ready() {
local conf_file="$1"
local timeout_sec="${2:-6}"
local end=$((SECONDS + timeout_sec))
while [ "$SECONDS" -lt "$end" ]; do
if [ -s "$conf_file" ] && grep -qE '^[[:space:]]*secret:' "$conf_file"; then
return 0
fi
sleep 0.2
done
return 1
}
# 计算字符串可视宽度:中文大概率按 2 宽处理(简单够用版)
# 注:终端宽度/字体不统一时,中文宽度估算永远只能“近似”
vis_width() {
python3 - <<'PY' "$1"
import sys
s=sys.argv[1]
w=0
for ch in s:
# East Asian Wide/FullWidth 近似当 2
w += 2 if ord(ch) >= 0x2E80 else 1
print(w)
PY
}
pad_right() { # pad_right "text" width
local s="$1" w="$2"
local cur
cur="$(vis_width "$s")"
local pad=$(( w - cur ))
(( pad < 0 )) && pad=0
printf "%s%*s" "$s" "$pad" ""
}
box_title() { # box_title "标题" width
local title="$1" width="$2"
local inner=$((width-2))
printf "┌%s┐\n" "$(printf '─%.0s' $(seq 1 $inner))"
# 标题居中(近似)
local t=" $title "
local tw; tw="$(vis_width "$t")"
local left=$(( (inner - tw)/2 )); ((left<0)) && left=0
local right=$(( inner - tw - left )); ((right<0)) && right=0
printf "│%*s%s%*s│\n" "$left" "" "$t" "$right" ""
printf "├%s┤\n" "$(printf '─%.0s' $(seq 1 $inner))"
}
box_row() { # box_row "key" "value" width keyw
local k="$1" v="$2" width="$3" keyw="$4"
local inner=$((width-2))
# 形如:│ key: value │
local left="$(pad_right "$k" "$keyw")"
local line=" ${left} ${v}"
local lw; lw="$(vis_width "$line")"
local pad=$(( inner - lw )); ((pad<0)) && pad=0
printf "│%s%*s│\n" "$line" "$pad" ""
}
box_end() { # box_end width
local width="$1" inner=$((width-2))
printf "└%s┘\n" "$(printf '─%.0s' $(seq 1 $inner))"
}
# 从 config.yaml 提取 secret强韧支持缩进/引号/CRLF/尾空格)
read_secret_from_config() {
local conf_file="$1"
[ -f "$conf_file" ] || return 1
# 1) 找到 secret 行 -> 2) 去掉 key 和空格 -> 3) 去掉首尾引号 -> 4) 去掉 CR
local s
s="$(
sed -nE 's/^[[:space:]]*secret:[[:space:]]*//p' "$conf_file" \
| head -n 1 \
| sed -E 's/^[[:space:]]*"(.*)"[[:space:]]*$/\1/; s/^[[:space:]]*'\''(.*)'\''[[:space:]]*$/\1/' \
| tr -d '\r'
)"
# 去掉纯空格
s="$(printf '%s' "$s" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
[ -n "$s" ] || return 1
printf '%s' "$s"
}
# =========================
# systemd 安装与启动
# 安装 systemd
# =========================
Service_Enabled="unknown"
Service_Started="unknown"
if command -v systemctl >/dev/null 2>&1; then
CLASH_SERVICE_USER="$Service_User" CLASH_SERVICE_GROUP="$Service_Group" "$Install_Dir/scripts/install_systemd.sh"
CLASH_SERVICE_USER="$Service_User" CLASH_SERVICE_GROUP="$Service_Group" \
"$Install_Dir/scripts/install_systemd.sh" "$Install_Dir"
if [ "${CLASH_ENABLE_SERVICE:-true}" = "true" ]; then
systemctl start "${Service_Name}.service" || true
systemctl enable "${Service_Name}.service" || true
fi
if [ "${CLASH_START_SERVICE:-true}" = "true" ]; then
systemctl start "${Service_Name}.service" || true
fi
@ -378,270 +125,26 @@ if command -v systemctl >/dev/null 2>&1; then
Service_Started="inactive"
fi
else
warn "未检测到 systemd已跳过服务单元生成"
echo "[WARN] systemd not found, will use script mode"
fi
# =========================
# Shell 代理快捷命令
# 生成:/etc/profile.d/clash-for-linux.sh
# 输出(全部收敛到 clashctl
# =========================
PROFILED_FILE="/etc/profile.d/clash-for-linux.sh"
echo
echo "=== Install Complete ==="
echo "Install Dir : $Install_Dir"
echo "clashctl : /usr/local/bin/clashctl"
install_profiled() {
local install_dir="$Install_Dir"
echo
echo "Next:"
echo " clashctl generate"
echo " clashctl start"
echo " clashctl doctor"
cat >"$PROFILED_FILE" <<EOF
# Clash for Linux proxy helpers
# Auto-generated by clash-for-linux installer.
# ===== 自动加载 .env =====
CLASH_INSTALL_DIR="${install_dir}"
ENV_FILE="\${CLASH_INSTALL_DIR}/.env"
if [ -f "\$ENV_FILE" ]; then
set +u
. "\$ENV_FILE" >/dev/null 2>&1 || true
set -u
fi
# ===== 默认值(兜底)=====
CLASH_LISTEN_IP="\${CLASH_LISTEN_IP:-127.0.0.1}"
CLASH_HTTP_PORT="\${CLASH_HTTP_PORT:-7890}"
CLASH_SOCKS_PORT="\${CLASH_SOCKS_PORT:-7891}"
# ===== 开启代理 =====
proxy_on() {
export http_proxy="http://\${CLASH_LISTEN_IP}:\${CLASH_HTTP_PORT}"
export https_proxy="http://\${CLASH_LISTEN_IP}:\${CLASH_HTTP_PORT}"
export HTTP_PROXY="http://\${CLASH_LISTEN_IP}:\${CLASH_HTTP_PORT}"
export HTTPS_PROXY="http://\${CLASH_LISTEN_IP}:\${CLASH_HTTP_PORT}"
export all_proxy="socks5://\${CLASH_LISTEN_IP}:\${CLASH_SOCKS_PORT}"
export ALL_PROXY="socks5://\${CLASH_LISTEN_IP}:\${CLASH_SOCKS_PORT}"
export no_proxy="127.0.0.1,localhost,::1"
export NO_PROXY="127.0.0.1,localhost,::1"
echo "[OK] Proxy enabled: http://\${CLASH_LISTEN_IP}:\${CLASH_HTTP_PORT}"
}
# ===== 关闭代理 =====
proxy_off() {
unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY
unset all_proxy ALL_PROXY no_proxy NO_PROXY
echo "[OK] Proxy disabled"
}
# ===== 状态 =====
proxy_status() {
echo "http_proxy=\${http_proxy:-<empty>}"
echo "https_proxy=\${https_proxy:-<empty>}"
echo "all_proxy=\${all_proxy:-<empty>}"
echo "CLASH_HTTP_PORT=\${CLASH_HTTP_PORT}"
echo "CLASH_SOCKS_PORT=\${CLASH_SOCKS_PORT}"
}
EOF
chmod 644 "$PROFILED_FILE"
# ===== 自动写入 bashrc关键=====
local BASHRC_FILE="/root/.bashrc"
local SOURCE_LINE='[ -f /etc/profile.d/clash-for-linux.sh ] && source /etc/profile.d/clash-for-linux.sh'
if [ -f "$BASHRC_FILE" ]; then
if ! grep -Fq "$SOURCE_LINE" "$BASHRC_FILE"; then
echo "$SOURCE_LINE" >> "$BASHRC_FILE"
fi
fi
}
install_profiled || true
# =========================
# 安装 clashctl 命令
# =========================
if [ -f "$Install_Dir/clashctl" ]; then
install -m 0755 "$Install_Dir/clashctl" /usr/local/bin/clashctl
fi
if [ -f "$Install_Dir/clashon" ]; then
install -m 0755 "$Install_Dir/clashon" /usr/local/bin/clashon
fi
if [ -f "$Install_Dir/clashoff" ]; then
install -m 0755 "$Install_Dir/clashoff" /usr/local/bin/clashoff
fi
# =========================
# 友好收尾输出
# - 不再强调瞬时 active / inactive
# - 统一引导到 clashctl
# - 兼容 systemd / 非 systemd
# =========================
section "安装完成"
ok "Clash for Linux 已安装至: $(path "${Install_Dir}")"
log "📦 安装目录:$(path "${Install_Dir}")"
log "👤 运行用户:${Service_User}:${Service_Group}"
log "🧩 管理入口:$(cmd "clashctl")"
if command -v systemctl >/dev/null 2>&1; then
log "🔧 服务名称:${Service_Name}.service"
else
log "🔧 运行模式:非 systemd 环境(将使用脚本模式兜底)"
fi
# =========================
# 安装结果
# 不在这里渲染瞬时运行态,避免误导
# =========================
section "安装结果"
ok "核心文件已就位"
ok "运行入口已收敛为 clashctl"
if [[ -x "/usr/local/bin/clashctl" ]]; then
ok "命令已可用:$(cmd "clashctl")"
else
warn "未检测到 /usr/local/bin/clashctl请确认安装脚本是否已完成链接"
fi
log ""
log "${C_BOLD}推荐下一步:${C_NC}"
log " 1. $(cmd "clashctl generate") # 生成运行配置"
log " 2. $(cmd "clashctl start") # 启动 Clash"
log " 3. $(cmd "clashctl doctor") # 健康检查"
# =========================
# 控制面板 / Secret
# =========================
section "控制面板"
api_port="$(parse_port "${EXTERNAL_CONTROLLER}")"
api_host="${EXTERNAL_CONTROLLER%:*}"
get_public_ip() {
if command -v curl >/dev/null 2>&1; then
curl -4 -fsS --max-time 3 https://api.ipify.org 2>/dev/null \
|| curl -4 -fsS --max-time 3 https://ifconfig.me 2>/dev/null \
|| curl -4 -fsS --max-time 3 https://ipv4.icanhazip.com 2>/dev/null \
|| true
elif command -v wget >/dev/null 2>&1; then
wget -qO- --timeout=3 https://api.ipify.org 2>/dev/null \
|| wget -qO- --timeout=3 https://ifconfig.me 2>/dev/null \
|| wget -qO- --timeout=3 https://ipv4.icanhazip.com 2>/dev/null \
|| true
else
true
fi
}
if [[ -z "$api_host" ]] || [[ "$api_host" == "$EXTERNAL_CONTROLLER" ]]; then
api_host="127.0.0.1"
fi
if [[ "$api_host" == "0.0.0.0" ]] || [[ "$api_host" == "::" ]] || [[ "$api_host" == "localhost" ]]; then
api_host="$(get_public_ip | tr -d '\r\n')"
[[ -z "$api_host" ]] && api_host="$(hostname -I 2>/dev/null | awk '{print $1}')"
[[ -z "$api_host" ]] && api_host="127.0.0.1"
fi
CONF_DIR="$Install_Dir/conf"
TEMP_DIR="$Install_Dir/temp"
RUNTIME_DIR="$Install_Dir/runtime"
SECRET_VAL=""
SECRET_FILE=""
read_secret_safe() {
local f="$1"
[[ -f "$f" ]] || return 1
read_secret_from_config "$f" 2>/dev/null || true
}
for _ in {1..15}; do
for f in \
"$RUNTIME_DIR/config.yaml" \
"$TEMP_DIR/config.yaml" \
"$CONF_DIR/config.yaml"
do
SECRET_VAL="$(read_secret_safe "$f")"
if [[ -n "$SECRET_VAL" ]]; then
SECRET_FILE="$f"
break 2
fi
done
sleep 0.2
done
dash="http://${api_host}:${api_port}/ui"
log "🌐 Dashboard$(url "$dash")"
SHOW_FILE="${SECRET_FILE:-$RUNTIME_DIR/config.yaml}"
if [[ -n "$SECRET_VAL" ]]; then
log "🔐 Secret${C_YELLOW}${SECRET_VAL}${C_NC}"
else
log "🔐 Secret${C_YELLOW}暂未读取到${C_NC}"
log " 启动后可查看:$(cmd "sed -nE 's/^[[:space:]]*secret:[[:space:]]*//p' \"$RUNTIME_DIR/config.yaml\" | head -n 1")"
log " 或检查旧路径:$(cmd "sed -nE 's/^[[:space:]]*secret:[[:space:]]*//p' \"$TEMP_DIR/config.yaml\" | head -n 1")"
fi
# =========================
# 订阅配置(必须)
# =========================
section "订阅状态"
ENV_FILE="${Install_Dir}/.env"
if [[ -n "${CLASH_URL:-}" ]]; then
ok "订阅地址已配置CLASH_URL 已写入 .env"
else
warn "订阅地址未配置(必须)"
log ""
log "配置订阅地址:"
log " $(cmd "bash -c 'printf \"%s\n\" \"CLASH_URL=<订阅地址>\" > \"${ENV_FILE}\"'")"
log ""
log "配置完成后执行:"
log " 1. $(cmd "clashctl generate")"
log " 2. $(cmd "clashctl start")"
log " 3. $(cmd "clashctl doctor")"
fi
# =========================
# 常用命令(统一只教 clashctl
# =========================
section "常用命令"
log " $(cmd "clashctl status") # 查看运行状态"
log " $(cmd "clashctl logs") # 查看最近日志"
log " $(cmd "clashctl logs -f") # 持续追踪日志"
log " $(cmd "clashctl stop") # 停止 Clash"
log " $(cmd "clashctl restart") # 重启 Clash"
log " $(cmd "clashctl doctor") # 健康检查"
# =========================
# 终端代理(可选)
# =========================
section "终端代理(可选)"
log " $(cmd "clashctl on") # 开启当前终端代理"
log " $(cmd "clashctl off") # 关闭当前终端代理"
# =========================
# 旧入口收敛提示
# =========================
section "说明"
log "旧脚本已收敛,请优先使用:$(cmd "clashctl")"
log "不建议继续直接操作 restart.sh / update.sh / 手写 systemctl 命令"
# =========================
# 启动后快速诊断
# =========================
sleep 1
if command -v journalctl >/dev/null 2>&1; then
if journalctl -u "${Service_Name}.service" -n 50 --no-pager 2>/dev/null \
| grep -q "Clash订阅地址不可访问"; then
warn "检测到启动异常:订阅不可用,请检查 CLASH_URL可能过期 / 404 / 被墙)"
log "建议执行:$(cmd "clashctl doctor")"
fi
fi
echo
echo "Commands:"
echo " clashctl status"
echo " clashctl logs"
echo " clashctl restart"
echo " clashctl stop"

View File

View File

@ -0,0 +1,6 @@
LAST_GENERATE_STATUS=
LAST_GENERATE_AT=
LAST_RUN_STATUS=
LAST_RUN_MODE=
LAST_RUN_AT=
LAST_RUN_PID=

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 900 B

After

Width:  |  Height:  |  Size: 900 B

View File

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 622 B

After

Width:  |  Height:  |  Size: 622 B

View File

@ -1,16 +1,36 @@
#!/bin/bash
#!/usr/bin/env bash
set -euo pipefail
trim_value() {
local value="$1"
echo "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
}
# =========================
# 安全写入:避免重复块
# =========================
remove_block_if_exists() {
local file="$1"
local marker="$2"
[ -f "$file" ] || return 0
# 删除已有 block从 marker 到文件结束)
if grep -q "$marker" "$file"; then
sed -i "/$marker/,\$d" "$file"
fi
}
# =========================
# TUN 配置
# =========================
apply_tun_config() {
local config_path="$1"
local enable="${CLASH_TUN_ENABLE:-false}"
if [ "$enable" != "true" ]; then
return 0
fi
[ "$enable" = "true" ] || return 0
remove_block_if_exists "$config_path" "# ==== TUN CONFIG START ===="
local stack="${CLASH_TUN_STACK:-system}"
local auto_route="${CLASH_TUN_AUTO_ROUTE:-true}"
@ -22,65 +42,91 @@ apply_tun_config() {
{
echo ""
echo "# ==== TUN CONFIG START ===="
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
[ -n "$device" ] && echo " device: ${device}"
[ -n "$mtu" ] && echo " mtu: ${mtu}"
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
item="$(trim_value "$item")"
[ -n "$item" ] && echo " - ${item}"
done
fi
echo "# ==== TUN CONFIG END ===="
} >> "$config_path"
}
# =========================
# MIXIN 配置
# =========================
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 base_dir="$2"
local mixin_dir="${CLASH_MIXIN_DIR:-$base_dir/config/mixin.d}"
local mixin_paths=()
remove_block_if_exists "$config_path" "# ==== MIXIN CONFIG START ===="
# 用户手动指定优先
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)
done < <(
find "$mixin_dir" -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) \
-print0 | sort -z
)
fi
# 去重
local uniq_paths=()
local seen=""
for path in "${mixin_paths[@]}"; do
local trimmed
trimmed=$(trim_value "$path")
if [ -z "$trimmed" ]; then
continue
path="$(trim_value "$path")"
[ -z "$path" ] && continue
# 相对路径转绝对
if [ "${path:0:1}" != "/" ]; then
path="$base_dir/$path"
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
if [[ "$seen" != *"|$path|"* ]]; then
uniq_paths+=("$path")
seen="${seen}|$path|"
fi
done
# 写入
{
echo ""
echo "# ==== MIXIN CONFIG START ===="
for path in "${uniq_paths[@]}"; do
if [ -f "$path" ]; then
echo ""
echo "# ---- mixin: ${path} ----"
cat "$path"
else
echo "[WARN] Mixin not found: $path" >&2
fi
done
echo "# ==== MIXIN CONFIG END ===="
} >> "$config_path"
}

View File

@ -1,21 +1,6 @@
#!/usr/bin/env bash
set -e
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
RUNTIME_DIR="$PROJECT_DIR/runtime"
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
echo "=== Clash Doctor ==="
if [ -f "$RUNTIME_DIR/config.yaml" ]; then
echo "[OK] config exists"
else
echo "[ERROR] config missing"
fi
if command -v systemctl >/dev/null 2>&1; then
if systemctl is-active --quiet clash-for-linux.service; then
echo "[OK] service running"
else
echo "[WARN] service not running"
fi
fi
exec "$PROJECT_DIR/clashctl" doctor "$@"

View File

@ -12,6 +12,7 @@ STATE_FILE="$RUNTIME_DIR/state.env"
TMP_DOWNLOAD="$RUNTIME_DIR/subscription.raw.yaml"
TMP_NORMALIZED="$RUNTIME_DIR/subscription.normalized.yaml"
TMP_PROXY_FRAGMENT="$RUNTIME_DIR/proxy.fragment.yaml"
TMP_CONFIG="$RUNTIME_DIR/config.yaml.tmp"
mkdir -p "$RUNTIME_DIR" "$CONFIG_DIR" "$LOG_DIR"
@ -64,6 +65,15 @@ generate_secret() {
return 0
fi
if [ -s "$RUNTIME_CONFIG" ]; then
local old_secret
old_secret="$(sed -nE 's/^[[:space:]]*secret:[[:space:]]*"?([^"#]+)"?.*$/\1/p' "$RUNTIME_CONFIG" | head -n 1)"
if [ -n "${old_secret:-}" ]; then
echo "$old_secret"
return 0
fi
fi
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex 16
else
@ -103,10 +113,12 @@ apply_controller_to_config() {
rm -rf "$ui_dir"
mkdir -p "$ui_dir"
cp -a "$PROJECT_DIR/dashboard/public/." "$ui_dir/"
if [ -d "$PROJECT_DIR/dashboard/public" ]; then
cp -a "$PROJECT_DIR/dashboard/public/." "$ui_dir/"
upsert_yaml_kv_local "$file" "external-ui" "$ui_dir"
fi
fi
}
download_subscription() {
@ -121,11 +133,32 @@ download_subscription() {
is_complete_clash_config() {
local file="$1"
grep -qE '^(proxies:|proxy-providers:|mixed-port:|port:)' "$file"
grep -qE '^[[:space:]]*(proxies:|proxy-providers:|mixed-port:|port:)' "$file"
}
cleanup_tmp_files() {
rm -f "$TMP_NORMALIZED" "$TMP_PROXY_FRAGMENT"
rm -f "$TMP_PROXY_FRAGMENT" "$TMP_CONFIG"
}
build_fragment_config() {
local template_file="$1"
local target_file="$2"
sed -n '/^proxies:/,$p' "$TMP_NORMALIZED" > "$TMP_PROXY_FRAGMENT"
cat "$template_file" > "$target_file"
cat "$TMP_PROXY_FRAGMENT" >> "$target_file"
sed -i "s/CLASH_HTTP_PORT_PLACEHOLDER/${CLASH_HTTP_PORT}/g" "$target_file"
sed -i "s/CLASH_SOCKS_PORT_PLACEHOLDER/${CLASH_SOCKS_PORT}/g" "$target_file"
sed -i "s/CLASH_REDIR_PORT_PLACEHOLDER/${CLASH_REDIR_PORT}/g" "$target_file"
sed -i "s/CLASH_LISTEN_IP_PLACEHOLDER/${CLASH_LISTEN_IP}/g" "$target_file"
sed -i "s/CLASH_ALLOW_LAN_PLACEHOLDER/${CLASH_ALLOW_LAN}/g" "$target_file"
}
finalize_config() {
local file="$1"
mv -f "$file" "$RUNTIME_CONFIG"
}
main() {
@ -156,9 +189,10 @@ main() {
cp -f "$TMP_DOWNLOAD" "$TMP_NORMALIZED"
if is_complete_clash_config "$TMP_NORMALIZED"; then
cp -f "$TMP_NORMALIZED" "$RUNTIME_CONFIG"
apply_controller_to_config "$RUNTIME_CONFIG"
apply_secret_to_config "$RUNTIME_CONFIG"
cp -f "$TMP_NORMALIZED" "$TMP_CONFIG"
apply_controller_to_config "$TMP_CONFIG"
apply_secret_to_config "$TMP_CONFIG"
finalize_config "$TMP_CONFIG"
write_state "success" "subscription_full" "subscription_full"
cleanup_tmp_files
exit 0
@ -171,22 +205,14 @@ main() {
exit 1
fi
sed -n '/^proxies:/,$p' "$TMP_NORMALIZED" > "$TMP_PROXY_FRAGMENT"
cat "$template_file" > "$RUNTIME_CONFIG"
cat "$TMP_PROXY_FRAGMENT" >> "$RUNTIME_CONFIG"
sed -i "s/CLASH_HTTP_PORT_PLACEHOLDER/${CLASH_HTTP_PORT}/g" "$RUNTIME_CONFIG"
sed -i "s/CLASH_SOCKS_PORT_PLACEHOLDER/${CLASH_SOCKS_PORT}/g" "$RUNTIME_CONFIG"
sed -i "s/CLASH_REDIR_PORT_PLACEHOLDER/${CLASH_REDIR_PORT}/g" "$RUNTIME_CONFIG"
sed -i "s/CLASH_LISTEN_IP_PLACEHOLDER/${CLASH_LISTEN_IP}/g" "$RUNTIME_CONFIG"
sed -i "s/CLASH_ALLOW_LAN_PLACEHOLDER/${CLASH_ALLOW_LAN}/g" "$RUNTIME_CONFIG"
apply_controller_to_config "$RUNTIME_CONFIG"
apply_secret_to_config "$RUNTIME_CONFIG"
build_fragment_config "$template_file" "$TMP_CONFIG"
apply_controller_to_config "$TMP_CONFIG"
apply_secret_to_config "$TMP_CONFIG"
finalize_config "$TMP_CONFIG"
write_state "success" "subscription_fragment_merged" "subscription_fragment"
cleanup_tmp_files
}
trap cleanup_tmp_files EXIT
main "$@"

View File

@ -10,14 +10,14 @@ SERVICE_GROUP="${CLASH_SERVICE_GROUP:-root}"
RUNTIME_DIR="$PROJECT_DIR/runtime"
LOG_DIR="$PROJECT_DIR/logs"
CONF_DIR="$PROJECT_DIR/conf"
CONFIG_DIR="$PROJECT_DIR/config"
if [ "$(id -u)" -ne 0 ]; then
echo "[ERROR] root required to install systemd unit" >&2
exit 1
fi
install -d -m 0755 "$RUNTIME_DIR" "$LOG_DIR" "$CONF_DIR"
install -d -m 0755 "$RUNTIME_DIR" "$LOG_DIR" "$CONFIG_DIR" "$CONFIG_DIR/mixin.d"
cat >"$UNIT_PATH" <<EOF
[Unit]
@ -35,8 +35,11 @@ Group=${SERVICE_GROUP}
WorkingDirectory=${PROJECT_DIR}
Environment=HOME=/root
ExecStart=/bin/bash ${PROJECT_DIR}/scripts/run_clash.sh --foreground
ExecStop=/bin/bash ${PROJECT_DIR}/clashctl --from-systemd stop
ExecStart=${PROJECT_DIR}/clashctl start
ExecStop=${PROJECT_DIR}/clashctl --from-systemd stop
ExecReload=${PROJECT_DIR}/clashctl restart
PIDFile=${PROJECT_DIR}/runtime/clash.pid
Restart=always
RestartSec=5s
@ -62,4 +65,5 @@ echo "[OK] systemd unit installed: ${UNIT_PATH}"
echo "start : systemctl start ${SERVICE_NAME}.service"
echo "stop : systemctl stop ${SERVICE_NAME}.service"
echo "restart : systemctl restart ${SERVICE_NAME}.service"
echo "reload : systemctl reload ${SERVICE_NAME}.service"
echo "status : systemctl status ${SERVICE_NAME}.service -l --no-pager"

View File

@ -1,20 +1,6 @@
#!/usr/bin/env bash
set -e
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
LOG_FILE="$PROJECT_DIR/logs/clash.log"
SERVICE_NAME="clash-for-linux.service"
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [ "${1:-}" = "-f" ]; then
if command -v journalctl >/dev/null 2>&1; then
journalctl -u "$SERVICE_NAME" -f
else
tail -f "$LOG_FILE"
fi
else
if command -v journalctl >/dev/null 2>&1; then
journalctl -u "$SERVICE_NAME" -n 50 --no-pager
else
tail -n 50 "$LOG_FILE"
fi
fi
exec "$PROJECT_DIR/clashctl" logs "$@"

View File

@ -1,36 +1,49 @@
#!/bin/bash
#!/usr/bin/env bash
set -euo pipefail
PORT_CHECK_WARNED=${PORT_CHECK_WARNED:-0}
# =========================
# 判断端口是否被占用(更稳)
# =========================
is_port_in_use() {
local port="$1"
if command -v ss >/dev/null 2>&1; then
ss -lnt | awk '{print $4}' | grep -E "(:|\.)${port}$" >/dev/null 2>&1
ss -lnt 2>/dev/null | awk '{print $4}' | grep -E "[:.]${port}$" >/dev/null 2>&1
return $?
fi
if command -v netstat >/dev/null 2>&1; then
netstat -lnt | awk '{print $4}' | grep -E "(:|\.)${port}$" >/dev/null 2>&1
netstat -lnt 2>/dev/null | awk '{print $4}' | grep -E "[:.]${port}$" >/dev/null 2>&1
return $?
fi
if command -v lsof >/dev/null 2>&1; then
lsof -iTCP -sTCP:LISTEN -P -n | awk '{print $9}' | grep -E "(:|\.)${port}$" >/dev/null 2>&1
lsof -iTCP -sTCP:LISTEN -P -n 2>/dev/null | awk '{print $9}' | grep -E "[:.]${port}$" >/dev/null 2>&1
return $?
fi
if [ "$PORT_CHECK_WARNED" -eq 0 ]; then
echo -e "\033[33m[WARN] 未找到端口检测工具,端口冲突检测可能不准确\033[0m" >&2
echo "[WARN] no port check tool found (ss/netstat/lsof)" >&2
PORT_CHECK_WARNED=1
fi
return 1
}
# =========================
# 找可用端口(优化版)
# =========================
find_available_port() {
local start_port=${1:-20000}
local end_port=${2:-65000}
local start="${1:-20000}"
local end="${2:-65000}"
local port
# 优先随机尝试
if command -v shuf >/dev/null 2>&1; then
for _ in {1..50}; do
port=$(shuf -i "${start_port}-${end_port}" -n 1)
for _ in {1..30}; do
port=$(shuf -i "${start}-${end}" -n 1)
if ! is_port_in_use "$port"; then
echo "$port"
return 0
@ -38,7 +51,8 @@ find_available_port() {
done
fi
for port in $(seq "$start_port" "$end_port"); do
# fallback 顺序扫描(限制范围避免慢)
for port in $(seq "$start" "$((start + 2000))"); do
if ! is_port_in_use "$port"; then
echo "$port"
return 0
@ -48,43 +62,56 @@ find_available_port() {
return 1
}
# =========================
# 解析端口值(核心函数)
# =========================
resolve_port_value() {
local name="$1"
local value="$2"
local resolved
# auto / 空
if [ -z "$value" ] || [ "$value" = "auto" ]; then
resolved=$(find_available_port)
if [ -z "$resolved" ]; then
resolved=$(find_available_port) || {
echo "[ERROR] ${name} failed to allocate port" >&2
return 1
fi
echo -e "\033[33m[WARN] ${name} 端口已自动分配为 ${resolved}\033[0m" >&2
}
echo "[WARN] ${name} auto assigned: ${resolved}" >&2
echo "$resolved"
return 0
fi
if [[ "$value" =~ ^[0-9]+$ ]]; then
# 非数字
if ! [[ "$value" =~ ^[0-9]+$ ]]; then
echo "[ERROR] invalid port: $value" >&2
return 1
fi
# 被占用 → 自动替换
if is_port_in_use "$value"; then
resolved=$(find_available_port)
if [ -n "$resolved" ]; then
echo -e "\033[33m[WARN] ${name} 端口 ${value} 已被占用,已自动切换为 ${resolved}\033[0m" >&2
echo "[WARN] ${name} port ${value} in use, switched to ${resolved}" >&2
echo "$resolved"
return 0
fi
fi
fi
echo "$value"
}
# =========================
# 解析 host:port
# =========================
resolve_host_port() {
local name="$1"
local raw="$2"
local default_host="$3"
local host
local port
if [ "$raw" = "auto" ] || [ -z "$raw" ]; then
if [ -z "$raw" ] || [ "$raw" = "auto" ]; then
host="$default_host"
port="auto"
else
@ -97,6 +124,10 @@ resolve_host_port() {
fi
fi
# host 兜底
[ -z "$host" ] && host="$default_host"
port=$(resolve_port_value "$name" "$port") || return 1
echo "${host}:${port}"
}

View File

@ -1,104 +1,180 @@
#!/bin/bash
resolve_clash_arch() {
local raw_arch="$1"
case "$raw_arch" in
x86_64|amd64)
echo "linux-amd64"
;;
aarch64|arm64)
echo "linux-arm64"
;;
armv7*|armv7l)
echo "linux-armv7"
;;
*)
echo "linux-${raw_arch}"
;;
x86_64|amd64) echo "linux-amd64" ;;
aarch64|arm64) echo "linux-arm64" ;;
armv7*|armv7l) echo "linux-armv7" ;;
*) echo "linux-${raw_arch}" ;;
esac
}
get_latest_mihomo_version() {
local url="https://api.github.com/repos/MetaCubeX/mihomo/releases/latest"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$url" \
| grep '"tag_name"' \
| sed -E 's/.*"([^"]+)".*/\1/' \
| head -n 1
elif command -v wget >/dev/null 2>&1; then
wget -qO- "$url" \
| grep '"tag_name"' \
| sed -E 's/.*"([^"]+)".*/\1/' \
| head -n 1
fi
}
download_clash_bin() {
local server_dir="$1"
local detected_arch="$2"
local resolved_arch
local version
local download_url
local download_target
local archive_file
local tmp_bin
resolved_arch="$(resolve_clash_arch "$detected_arch")"
resolved_arch=$(resolve_clash_arch "$detected_arch")
if [ -z "$resolved_arch" ]; then
echo -e "\033[33m[WARN] 无法识别 CPU 架构,跳过 Clash 内核自动下载\033[0m"
echo "[WARN] 无法识别 CPU 架构" >&2
return 1
fi
if [ "${CLASH_AUTO_DOWNLOAD:-auto}" = "false" ]; then
version="${MIHOMO_VERSION:-}"
if [ -z "$version" ]; then
version="$(get_latest_mihomo_version || true)"
fi
if [ -z "$version" ]; then
echo "[ERROR] 无法获取 Mihomo 版本" >&2
return 1
fi
local _default_url="https://github.com/Dreamacro/clash/releases/latest/download/clash-{arch}.gz"
download_url="${CLASH_DOWNLOAD_URL_TEMPLATE:-$_default_url}"
if [ -z "$download_url" ]; then
echo -e "\033[33m[WARN] 未设置 CLASH_DOWNLOAD_URL_TEMPLATE跳过 Clash 内核自动下载\033[0m"
if [ -z "${CLASH_DOWNLOAD_URL_TEMPLATE:-}" ]; then
echo "[ERROR] CLASH_DOWNLOAD_URL_TEMPLATE 未设置" >&2
return 1
fi
download_url="${download_url//\{arch\}/${resolved_arch}}"
download_url="${CLASH_DOWNLOAD_URL_TEMPLATE//\{arch\}/${resolved_arch}}"
download_url="${download_url//\{version\}/${version}}"
download_target="${server_dir}/bin/clash-${resolved_arch}"
archive_file="${server_dir}/temp/clash-${resolved_arch}.download"
archive_file="${server_dir}/runtime/.clash_download.tmp"
tmp_bin="${server_dir}/runtime/.clash_bin.tmp"
mkdir -p "${server_dir}/bin" "${server_dir}/temp"
mkdir -p "${server_dir}/bin" "${server_dir}/runtime"
rm -f "$archive_file" "$tmp_bin"
echo "[INFO] downloading: $download_url"
# =========================
# 下载
# =========================
if command -v curl >/dev/null 2>&1; then
curl -L -sS -o "${archive_file}" "${download_url}"
if ! curl -fL -sS -o "$archive_file" "$download_url"; then
echo "[ERROR] 下载失败: $download_url" >&2
return 1
fi
elif command -v wget >/dev/null 2>&1; then
wget -q -O "${archive_file}" "${download_url}"
if ! wget -q -O "$archive_file" "$download_url"; then
echo "[ERROR] 下载失败: $download_url" >&2
return 1
fi
else
echo -e "\033[33m[WARN] 未找到 curl 或 wget无法自动下载 Clash 内核\033[0m"
echo "[ERROR] 未找到 curl 或 wget" >&2
return 1
fi
if [ -f "${archive_file}" ]; then
if gzip -t "${archive_file}" >/dev/null 2>&1; then
gzip -dc "${archive_file}" >"${download_target}"
else
mv "${archive_file}" "${download_target}"
fi
chmod +x "${download_target}"
echo "${download_target}"
return 0
# =========================
# 基础校验(防 404 / HTML
# =========================
if [ ! -s "$archive_file" ]; then
echo "[ERROR] 下载文件为空" >&2
return 1
fi
echo -e "\033[33m[WARN] Clash 内核自动下载失败\033[0m"
if head -c 200 "$archive_file" | grep -qiE "not found|html"; then
echo "[ERROR] 下载内容疑似错误页面404/HTML" >&2
return 1
fi
# =========================
# 解压 / 直写
# =========================
if gzip -t "$archive_file" >/dev/null 2>&1; then
if ! gzip -dc "$archive_file" > "$tmp_bin"; then
echo "[ERROR] gzip 解压失败" >&2
return 1
fi
else
cp "$archive_file" "$tmp_bin"
fi
# =========================
# ELF 校验(关键)
# =========================
if ! file "$tmp_bin" | grep -q "ELF"; then
echo "[ERROR] 非有效 ELF 二进制" >&2
echo "[DEBUG] file result: $(file "$tmp_bin")" >&2
return 1
fi
chmod +x "$tmp_bin"
mv "$tmp_bin" "$download_target"
rm -f "$archive_file"
echo "[OK] downloaded: $download_target"
echo "$download_target"
}
resolve_clash_bin() {
local server_dir="$1"
local detected_arch="$2"
local resolved_arch
local candidates=()
local candidate
local downloaded_bin
local mode
if [ -n "${CLASH_BIN:-}" ]; then
if [ -x "$CLASH_BIN" ]; then
echo "$CLASH_BIN"
return 0
fi
echo -e "\033[31m[ERROR] CLASH_BIN 指定的文件不可执行: $CLASH_BIN\033[0m"
echo "[ERROR] CLASH_BIN 不可执行: $CLASH_BIN" >&2
return 1
fi
resolved_arch=$(resolve_clash_arch "$detected_arch")
resolved_arch="$(resolve_clash_arch "$detected_arch")"
if [ -n "$resolved_arch" ]; then
candidates+=("${server_dir}/bin/clash-${resolved_arch}")
fi
candidates+=(
"${server_dir}/bin/clash-${detected_arch}"
"${server_dir}/bin/clash"
)
mode="${CLASH_AUTO_DOWNLOAD:-auto}"
case "$mode" in
false)
for candidate in "${candidates[@]}"; do
if [ -x "$candidate" ]; then
echo "$candidate"
return 0
fi
done
;;
auto)
for candidate in "${candidates[@]}"; do
if [ -x "$candidate" ]; then
echo "$candidate"
@ -106,14 +182,33 @@ resolve_clash_bin() {
fi
done
if downloaded_bin=$(download_clash_bin "$server_dir" "$detected_arch"); then
if downloaded_bin="$(download_clash_bin "$server_dir" "$detected_arch")"; then
echo "$downloaded_bin"
return 0
fi
;;
true)
if downloaded_bin="$(download_clash_bin "$server_dir" "$detected_arch")"; then
echo "$downloaded_bin"
return 0
fi
echo -e "\033[31m\n[ERROR] 未找到可用的 Clash 二进制。\033[0m"
echo -e "请将对应架构的二进制放入: $server_dir/bin/"
echo -e "可用命名示例: clash-${resolved_arch} 或 clash-${detected_arch}"
echo -e "或通过 CLASH_BIN 指定自定义路径。"
for candidate in "${candidates[@]}"; do
if [ -x "$candidate" ]; then
echo "$candidate"
return 0
fi
done
;;
*)
echo "[ERROR] CLASH_AUTO_DOWNLOAD 非法值: $mode" >&2
return 1
;;
esac
echo "[ERROR] 未找到可用 Mihomo 内核" >&2
echo "请放入: ${server_dir}/bin/" >&2
return 1
}

View File

@ -12,7 +12,9 @@ mkdir -p "$RUNTIME_DIR" "$LOG_DIR"
FOREGROUND=false
DAEMON=false
# 解析参数
# =========================
# 参数解析
# =========================
for arg in "$@"; do
case "$arg" in
--foreground) FOREGROUND=true ;;
@ -29,6 +31,14 @@ if [ "$FOREGROUND" = true ] && [ "$DAEMON" = true ]; then
exit 2
fi
if [ "$FOREGROUND" = false ] && [ "$DAEMON" = false ]; then
echo "[ERROR] Must specify --foreground or --daemon" >&2
exit 2
fi
# =========================
# 基础校验
# =========================
if [ ! -s "$CONFIG_FILE" ]; then
echo "[ERROR] runtime config not found: $CONFIG_FILE" >&2
exit 2
@ -39,6 +49,9 @@ if grep -q '\${' "$CONFIG_FILE"; then
exit 2
fi
# =========================
# 加载依赖
# =========================
# shellcheck disable=SC1091
source "$PROJECT_DIR/scripts/get_cpu_arch.sh"
# shellcheck disable=SC1091
@ -46,40 +59,49 @@ source "$PROJECT_DIR/scripts/resolve_clash.sh"
# shellcheck disable=SC1091
source "$PROJECT_DIR/scripts/service_lib.sh"
# =========================
# 获取二进制
# =========================
CLASH_BIN="$(resolve_clash_bin "$PROJECT_DIR" "${CpuArch:-}")"
if [ ! -x "$CLASH_BIN" ]; then
echo "[ERROR] clash binary not found or not executable: $CLASH_BIN" >&2
echo "[ERROR] clash binary not executable: $CLASH_BIN" >&2
exit 2
fi
test_config() {
local bin="$1"
local config="$2"
local runtime_dir="$3"
"$bin" -d "$runtime_dir" -t -f "$config" >/dev/null 2>&1
}
if ! test_config "$CLASH_BIN" "$CONFIG_FILE" "$RUNTIME_DIR"; then
echo "[ERROR] config test failed: $CONFIG_FILE" >&2
# =========================
# config 测试(唯一一次)
# =========================
if ! "$CLASH_BIN" -t -f "$CONFIG_FILE" -d "$RUNTIME_DIR" >/dev/null 2>&1; then
echo "[ERROR] clash config test failed: $CONFIG_FILE" >&2
write_run_state "failed" "config-test"
exit 2
fi
# systemd 模式
# =========================
# 前台模式systemd
# =========================
if [ "$FOREGROUND" = true ]; then
write_run_state "running" "systemd"
exec "$CLASH_BIN" -f "$CONFIG_FILE" -d "$RUNTIME_DIR"
fi
# script / daemon 模式
if [ "$DAEMON" = true ]; then
nohup "$CLASH_BIN" -f "$CONFIG_FILE" -d "$RUNTIME_DIR" >>"$LOG_DIR/clash.log" 2>&1 &
pid=$!
echo "$pid" > "$PID_FILE"
write_run_state "running" "script" "$pid"
echo "[OK] Clash started in script mode, pid=$pid"
# =========================
# 后台模式script
# =========================
cleanup_dead_pid
if is_script_running; then
pid="$(read_pid 2>/dev/null || true)"
echo "[INFO] clash already running, pid=${pid:-unknown}"
exit 0
fi
echo "[ERROR] Must specify --foreground or --daemon" >&2
exit 2
nohup "$CLASH_BIN" -f "$CONFIG_FILE" -d "$RUNTIME_DIR" >>"$LOG_DIR/clash.log" 2>&1 &
pid=$!
echo "$pid" > "$PID_FILE"
write_run_state "running" "script" "$pid"
echo "[OK] Clash started in script mode, pid=$pid"

View File

@ -9,6 +9,9 @@ SERVICE_NAME="clash-for-linux.service"
mkdir -p "$RUNTIME_DIR"
# =========================
# 基础能力
# =========================
has_systemd() {
command -v systemctl >/dev/null 2>&1
}
@ -19,17 +22,39 @@ service_unit_exists() {
}
read_pid() {
[ -f "$PID_FILE" ] || return 1
cat "$PID_FILE"
[ -s "$PID_FILE" ] || return 1
tr -d '[:space:]' < "$PID_FILE"
}
is_pid_running() {
local pid="$1"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
is_script_running() {
local pid
pid="$(read_pid 2>/dev/null || true)"
[ -n "${pid:-}" ] && kill -0 "$pid" 2>/dev/null
is_pid_running "$pid"
}
# =========================
# 清理僵尸 PID关键
# =========================
cleanup_dead_pid() {
local pid
pid="$(read_pid 2>/dev/null || true)"
if [ -n "${pid:-}" ] && ! is_pid_running "$pid"; then
rm -f "$PID_FILE"
fi
}
# =========================
# 模式检测(统一)
# =========================
detect_mode() {
cleanup_dead_pid
if service_unit_exists && systemctl is-active --quiet "$SERVICE_NAME"; then
echo "systemd"
elif is_script_running; then
@ -41,59 +66,65 @@ detect_mode() {
fi
}
write_run_state() {
local status="$1"
local mode="${2:-unknown}"
local pid="${3:-}"
# =========================
# state 写入(唯一实现)
# =========================
write_state_kv() {
local key="$1"
local value="$2"
mkdir -p "$RUNTIME_DIR"
touch "$STATE_FILE"
if grep -q '^LAST_RUN_STATUS=' "$STATE_FILE" 2>/dev/null; then
sed -i -E "s/^LAST_RUN_STATUS=.*/LAST_RUN_STATUS=${status}/" "$STATE_FILE"
if grep -q "^${key}=" "$STATE_FILE" 2>/dev/null; then
sed -i "s|^${key}=.*|${key}=${value}|" "$STATE_FILE"
else
echo "LAST_RUN_STATUS=${status}" >> "$STATE_FILE"
fi
if grep -q '^LAST_RUN_MODE=' "$STATE_FILE" 2>/dev/null; then
sed -i -E "s/^LAST_RUN_MODE=.*/LAST_RUN_MODE=${mode}/" "$STATE_FILE"
else
echo "LAST_RUN_MODE=${mode}" >> "$STATE_FILE"
fi
if grep -q '^LAST_RUN_AT=' "$STATE_FILE" 2>/dev/null; then
sed -i -E "s/^LAST_RUN_AT=.*/LAST_RUN_AT=$(date -Iseconds)/" "$STATE_FILE"
else
echo "LAST_RUN_AT=$(date -Iseconds)" >> "$STATE_FILE"
fi
if [ -n "$pid" ]; then
if grep -q '^LAST_RUN_PID=' "$STATE_FILE" 2>/dev/null; then
sed -i -E "s/^LAST_RUN_PID=.*/LAST_RUN_PID=${pid}/" "$STATE_FILE"
else
echo "LAST_RUN_PID=${pid}" >> "$STATE_FILE"
fi
echo "${key}=${value}" >> "$STATE_FILE"
fi
}
write_run_state() {
local status="${1:-unknown}"
local mode="${2:-unknown}"
local pid="${3:-}"
write_state_kv "LAST_RUN_STATUS" "$status"
write_state_kv "LAST_RUN_MODE" "$mode"
write_state_kv "LAST_RUN_AT" "$(date -Iseconds)"
if [ -n "$pid" ]; then
write_state_kv "LAST_RUN_PID" "$pid"
fi
}
# =========================
# systemd 模式
# =========================
start_via_systemd() {
systemctl start "$SERVICE_NAME"
}
stop_via_systemd() {
systemctl stop "$SERVICE_NAME"
systemctl stop "$SERVICE_NAME" || true
cleanup_dead_pid
write_run_state "stopped" "systemd"
rm -f "$PID_FILE"
}
restart_via_systemd() {
systemctl restart "$SERVICE_NAME"
}
# =========================
# script 模式
# =========================
start_via_script() {
cleanup_dead_pid
if is_script_running; then
echo "[INFO] clash already running (script mode)"
echo "[INFO] clash already running (script)"
return 0
fi
"$PROJECT_DIR/scripts/run_clash.sh" --daemon
}
@ -101,11 +132,19 @@ stop_via_script() {
local pid
pid="$(read_pid 2>/dev/null || true)"
if [ -n "${pid:-}" ] && kill -0 "$pid" 2>/dev/null; then
if [ -n "${pid:-}" ] && is_pid_running "$pid"; then
echo "[INFO] stopping clash pid=$pid"
kill "$pid"
kill "$pid" 2>/dev/null || true
for _ in 1 2 3 4 5; do
if ! is_pid_running "$pid"; then
break
fi
sleep 1
if kill -0 "$pid" 2>/dev/null; then
done
if is_pid_running "$pid"; then
kill -9 "$pid" 2>/dev/null || true
fi
fi

View File

@ -1,28 +1,6 @@
#!/usr/bin/env bash
set -e
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck disable=SC1091
source "$PROJECT_DIR/scripts/service_lib.sh"
mode="$(detect_mode)"
echo "=== Clash Status ==="
echo "Project : $PROJECT_DIR"
echo "Mode : $mode"
case "$mode" in
systemd)
echo "Running : yes (systemd)"
;;
script)
echo "Running : yes (script)"
;;
systemd-installed)
echo "Running : no (installed but not started)"
;;
*)
echo "Running : no"
;;
esac
exec "$PROJECT_DIR/clashctl" status "$@"

View File

@ -1,23 +1,6 @@
#!/usr/bin/env bash
set -e
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck disable=SC1091
source "$PROJECT_DIR/scripts/service_lib.sh"
mode="$(detect_mode)"
case "$mode" in
systemd)
stop_via_systemd
echo "[OK] stopped via systemd"
;;
script)
stop_via_script
echo "[OK] stopped via script"
;;
*)
echo "[WARN] nothing is running"
;;
esac
exec "$PROJECT_DIR/clashctl" stop "$@"

View File

@ -1,19 +1,33 @@
[Unit]
Description=Clash for Linux
After=network.target
Description=Clash for Linux (Mihomo)
Documentation=https://github.com/wnlen/clash-for-linux
After=network-online.target nss-lookup.target
Wants=network-online.target
StartLimitIntervalSec=0
StartLimitBurst=10
[Service]
Type=simple
EnvironmentFile=-/etc/default/clash-for-linux
Environment=CLASH_HOME=/opt/clash-for-linux
ExecStart=/bin/bash -c 'exec /bin/bash "${CLASH_HOME}/start.sh"'
ExecStop=/bin/bash -c 'exec /bin/bash "${CLASH_HOME}/shutdown.sh"'
Restart=on-failure
RestartSec=5
User=clash
Group=clash
PIDFile=%E{CLASH_HOME}/temp/clash.pid
Environment=CLASH_ENV_FILE=%E{CLASH_HOME}/temp/clash-for-linux.sh
User=root
Group=root
WorkingDirectory=/opt/clash-for-linux
Environment=HOME=/root
ExecStart=/bin/bash /opt/clash-for-linux/clashctl start
ExecStop=/bin/bash /opt/clash-for-linux/clashctl --from-systemd stop
Restart=always
RestartSec=5s
KillMode=mixed
TimeoutStartSec=120
TimeoutStopSec=30
StandardOutput=journal
StandardError=journal
UMask=0022
NoNewPrivileges=false
[Install]
WantedBy=multi-user.target

View File

@ -82,7 +82,7 @@ fi
# 2) stop process by pid file from all likely dirs
for d in "/root/clash-for-linux" "/opt/clash-for-linux" "${INSTALL_DIR:-}"; do
[ -n "$d" ] || continue
PID_FILE="$d/temp/clash.pid"
PID_FILE="$d/runtime/clash.pid"
if [ -f "$PID_FILE" ]; then
PID="$(cat "$PID_FILE" 2>/dev/null || true)"
if [ -n "${PID:-}" ] && kill -0 "$PID" 2>/dev/null; then