mirror of
https://github.com/wnlen/clash-for-linux.git
synced 2026-10-10 12:13:04 +08:00
1247 lines
28 KiB
Bash
Executable File
1247 lines
28 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
|
||
resolve_project_dir() {
|
||
# 1) 显式指定优先
|
||
if [ -n "${CLASH_INSTALL_DIR:-}" ] && [ -d "${CLASH_INSTALL_DIR:-}" ]; then
|
||
printf '%s\n' "$CLASH_INSTALL_DIR"
|
||
return 0
|
||
fi
|
||
|
||
# 2) 解析脚本真实路径(兼容软链/安装到 /usr/local/bin)
|
||
local src dir
|
||
src="${BASH_SOURCE[0]}"
|
||
|
||
while [ -L "$src" ]; do
|
||
dir="$(cd -P "$(dirname "$src")" && pwd)"
|
||
src="$(readlink "$src")"
|
||
[[ "$src" != /* ]] && src="$dir/$src"
|
||
done
|
||
|
||
dir="$(cd -P "$(dirname "$src")" && pwd)"
|
||
|
||
# 如果 clashctl 就在项目根目录
|
||
if [ -f "$dir/scripts/service_lib.sh" ]; then
|
||
printf '%s\n' "$dir"
|
||
return 0
|
||
fi
|
||
|
||
# 3) 常见安装目录兜底
|
||
for candidate in \
|
||
"/opt/clash-for-linux" \
|
||
"$HOME/clash-for-linux" \
|
||
"/root/clash-for-linux"
|
||
do
|
||
if [ -f "$candidate/scripts/service_lib.sh" ]; then
|
||
printf '%s\n' "$candidate"
|
||
return 0
|
||
fi
|
||
done
|
||
|
||
echo "[ERROR] 无法定位项目目录" >&2
|
||
exit 1
|
||
}
|
||
|
||
PROJECT_DIR="$(resolve_project_dir)"
|
||
SERVICE_NAME="clash-for-linux.service"
|
||
PROFILED_FILE="/etc/profile.d/clash-for-linux.sh"
|
||
ENV_FILE="$PROJECT_DIR/.env"
|
||
RUNTIME_DIR="$PROJECT_DIR/runtime"
|
||
RUNTIME_CONFIG="$RUNTIME_DIR/config.yaml"
|
||
STATE_FILE="$RUNTIME_DIR/state.env"
|
||
LOG_FILE="$PROJECT_DIR/logs/clash.log"
|
||
|
||
# shellcheck disable=SC1091
|
||
source "$PROJECT_DIR/scripts/service_lib.sh"
|
||
|
||
log() { printf "%b\n" "$*"; }
|
||
info() { log "\033[36m[INFO]\033[0m $*"; }
|
||
ok() { log "\033[32m[OK]\033[0m $*"; }
|
||
warn() { log "\033[33m[WARN]\033[0m $*"; }
|
||
err() { log "\033[31m[ERROR]\033[0m $*"; }
|
||
|
||
usage() {
|
||
cat <<'EOF'
|
||
用法:
|
||
clashctl 命令 [选项]
|
||
|
||
指令:
|
||
on 开启当前终端代理
|
||
off 关闭当前终端代理
|
||
start 启动 Clash
|
||
stop 停止 Clash
|
||
restart 重启并自动应用当前配置
|
||
status 查看当前状态
|
||
update 更新到最新版本并自动应用配置
|
||
mode 查看当前运行模式(systemd/script/none)
|
||
ui 输出 Dashboard 地址
|
||
secret 输出当前 secret
|
||
doctor 健康检查
|
||
logs [-f] [-n 100] 查看日志
|
||
sub show|update 查看订阅地址 / 更新订阅并应用配置
|
||
tun status|on|off 查看/启用/关闭 Tun
|
||
mixin status|on|off 查看/启用/关闭 Mixin
|
||
|
||
高级命令:
|
||
generate 生成配置(调试用,不会启动服务)
|
||
|
||
选项:
|
||
--from-systemd 内部使用,避免 stop 递归调用 systemctl
|
||
-h, --help 显示帮助信息
|
||
EOF
|
||
}
|
||
|
||
require_profiled() {
|
||
if [ ! -f "$PROFILED_FILE" ]; then
|
||
err "未安装 Shell 代理快捷命令:$PROFILED_FILE"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
read_runtime_config_value() {
|
||
local key="$1"
|
||
[ -f "$RUNTIME_CONFIG" ] || return 1
|
||
|
||
awk -v k="$key" '
|
||
$0 ~ "^[[:space:]]*" k ":[[:space:]]*" {
|
||
line = $0
|
||
sub("^[[:space:]]*" k ":[[:space:]]*", "", line)
|
||
gsub("\r", "", line)
|
||
|
||
# 去掉首尾引号
|
||
sub(/^"/, "", line)
|
||
sub(/"$/, "", line)
|
||
sub(/^'\''/, "", line)
|
||
sub(/'\''$/, "", line)
|
||
|
||
print line
|
||
exit
|
||
}
|
||
' "$RUNTIME_CONFIG"
|
||
}
|
||
|
||
write_env_bool() {
|
||
local key="$1"
|
||
local value="$2"
|
||
|
||
if [ ! -f "$ENV_FILE" ]; then
|
||
err "未找到 .env: $ENV_FILE"
|
||
exit 1
|
||
fi
|
||
|
||
if grep -qE "^[[:space:]]*(export[[:space:]]+)?${key}=" "$ENV_FILE"; then
|
||
sed -i -E "s|^[[:space:]]*(export[[:space:]]+)?${key}=.*$|export ${key}=\"${value}\"|g" "$ENV_FILE"
|
||
else
|
||
echo "已写入环境变量 ${key}=\"${value}\"" >> "$ENV_FILE"
|
||
fi
|
||
}
|
||
|
||
read_state_value() {
|
||
local key="$1"
|
||
[ -f "$STATE_FILE" ] || return 1
|
||
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)"
|
||
[ -n "${controller:-}" ] || return 1
|
||
printf '%s\n' "${controller##*:}"
|
||
}
|
||
|
||
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
|
||
|
||
return 1
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
has_git_repo() {
|
||
git -C "$PROJECT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1
|
||
}
|
||
|
||
get_current_branch() {
|
||
git -C "$PROJECT_DIR" branch --show-current 2>/dev/null
|
||
}
|
||
|
||
cmd_on() {
|
||
require_profiled
|
||
# shellcheck disable=SC1090
|
||
source "$PROFILED_FILE"
|
||
proxy_on
|
||
}
|
||
|
||
cmd_off() {
|
||
require_profiled
|
||
# shellcheck disable=SC1090
|
||
source "$PROFILED_FILE"
|
||
proxy_off
|
||
}
|
||
|
||
cmd_mode() {
|
||
detect_mode
|
||
}
|
||
|
||
cmd_generate() {
|
||
if ! bash "$PROJECT_DIR/scripts/generate_config.sh"; then
|
||
err "配置生成失败"
|
||
return 1
|
||
fi
|
||
ok "Config generated"
|
||
}
|
||
|
||
cmd_start() {
|
||
local mode
|
||
mode="$(detect_mode)"
|
||
|
||
case "$mode" in
|
||
systemd|systemd-installed)
|
||
start_via_systemd
|
||
ok "已通过 systemd 启动 Clash"
|
||
;;
|
||
script|none)
|
||
start_via_script
|
||
ok "已通过脚本模式启动 Clash"
|
||
;;
|
||
*)
|
||
err "未知模式: $mode"
|
||
exit 1
|
||
;;
|
||
esac
|
||
}
|
||
|
||
cmd_stop() {
|
||
local from_systemd="${1:-false}"
|
||
local mode
|
||
mode="$(detect_mode)"
|
||
|
||
case "$mode" in
|
||
systemd)
|
||
if [ "$from_systemd" = "true" ]; then
|
||
ok "来自 systemd 的停止请求,已跳过重复停止操作"
|
||
return 0
|
||
fi
|
||
stop_via_systemd
|
||
ok "已通过 systemd 停止 Clash"
|
||
;;
|
||
systemd-installed)
|
||
info "systemd 服务已安装但未运行"
|
||
;;
|
||
script)
|
||
stop_via_script
|
||
ok "已通过脚本模式停止 Clash"
|
||
;;
|
||
none)
|
||
info "Clash 未运行"
|
||
;;
|
||
*)
|
||
err "未知模式: $mode"
|
||
exit 1
|
||
;;
|
||
esac
|
||
}
|
||
|
||
cmd_update() {
|
||
local branch remote_name dirty pull_ok=1
|
||
|
||
if ! has_git_repo; then
|
||
err "当前目录不是 Git 仓库: $PROJECT_DIR"
|
||
exit 1
|
||
fi
|
||
|
||
branch="$(get_current_branch)"
|
||
if [ -z "${branch:-}" ]; then
|
||
err "无法识别当前分支"
|
||
exit 1
|
||
fi
|
||
|
||
remote_name="origin"
|
||
|
||
# 保护本地 .env(如果你已经把 .env 忽略掉,这段也保留,兜底更稳)
|
||
if [ -f "$PROJECT_DIR/.env" ]; then
|
||
cp -f "$PROJECT_DIR/.env" "$PROJECT_DIR/.env.bak" 2>/dev/null || true
|
||
fi
|
||
|
||
dirty="$(git -C "$PROJECT_DIR" status --porcelain --untracked-files=no)"
|
||
if [ -n "${dirty:-}" ]; then
|
||
echo "[WARN] 检测到本地已修改但未提交的文件,自动切换到安全强制更新模式"
|
||
pull_ok=0
|
||
fi
|
||
|
||
if [ "$pull_ok" -eq 1 ]; then
|
||
echo "[INFO] 正在从 ${remote_name}/${branch} 拉取最新代码 ..."
|
||
if ! git -C "$PROJECT_DIR" pull --ff-only "$remote_name" "$branch"; then
|
||
echo "[WARN] git pull 失败,自动切换到强制更新模式"
|
||
pull_ok=0
|
||
fi
|
||
fi
|
||
|
||
if [ "$pull_ok" -eq 0 ]; then
|
||
echo "[INFO] 正在获取 ${remote_name}/${branch} 最新代码 ..."
|
||
if ! git -C "$PROJECT_DIR" fetch "$remote_name" "$branch"; then
|
||
err "git fetch 失败"
|
||
exit 1
|
||
fi
|
||
|
||
echo "[WARN] 正在重置本地代码到 ${remote_name}/${branch}"
|
||
if ! git -C "$PROJECT_DIR" reset --hard "${remote_name}/${branch}"; then
|
||
err "git reset --hard 失败"
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
# 恢复 .env
|
||
if [ -f "$PROJECT_DIR/.env.bak" ]; then
|
||
mv -f "$PROJECT_DIR/.env.bak" "$PROJECT_DIR/.env" 2>/dev/null || true
|
||
fi
|
||
|
||
# 修复脚本权限,避免 203/EXEC
|
||
echo "[INFO] 修复执行权限中..."
|
||
chmod +x "$PROJECT_DIR"/scripts/*.sh 2>/dev/null || true
|
||
chmod +x "$PROJECT_DIR"/bin/* 2>/dev/null || true
|
||
sed -i 's/\r$//' "$PROJECT_DIR"/scripts/*.sh 2>/dev/null || true
|
||
|
||
# 先停服务,避免 generate 时误判端口占用导致漂移
|
||
echo "[INFO] 停止服务(准备重新生成配置)..."
|
||
if has_systemd; then
|
||
systemctl stop clash-for-linux.service 2>/dev/null || true
|
||
sleep 1
|
||
fi
|
||
|
||
echo "[INFO] 生成配置中..."
|
||
if ! bash "$PROJECT_DIR/scripts/generate_config.sh"; then
|
||
err "配置生成失败"
|
||
exit 1
|
||
fi
|
||
|
||
echo "[INFO] 启动服务中..."
|
||
if has_systemd; then
|
||
if ! systemctl start clash-for-linux.service; then
|
||
err "systemd 启动失败"
|
||
exit 1
|
||
fi
|
||
|
||
echo "[INFO] 等待服务就绪..."
|
||
if wait_for_systemd_active; then
|
||
ok "更新完成"
|
||
cmd_status
|
||
return 0
|
||
fi
|
||
|
||
err "服务启动未就绪"
|
||
systemctl status clash-for-linux.service -l --no-pager || true
|
||
exit 1
|
||
else
|
||
if ! "$PROJECT_DIR/scripts/run_clash.sh" --daemon; then
|
||
err "脚本模式启动失败"
|
||
exit 1
|
||
fi
|
||
|
||
ok "更新完成"
|
||
cmd_status
|
||
fi
|
||
}
|
||
|
||
cmd_update_force() {
|
||
local branch remote_name
|
||
local env_file backup_file
|
||
|
||
if ! has_git_repo; then
|
||
err "当前目录不是 Git 仓库: $PROJECT_DIR"
|
||
exit 1
|
||
fi
|
||
|
||
branch="$(get_current_branch)"
|
||
if [ -z "${branch:-}" ]; then
|
||
err "无法识别当前分支"
|
||
exit 1
|
||
fi
|
||
|
||
remote_name="origin"
|
||
env_file="$PROJECT_DIR/.env"
|
||
backup_file="$PROJECT_DIR/runtime/.env.local.backup"
|
||
|
||
mkdir -p "$PROJECT_DIR/runtime"
|
||
|
||
if [ -f "$env_file" ]; then
|
||
echo "[INFO] 正在备份本地环境变量覆盖项 ..."
|
||
backup_local_env_overrides "$env_file" "$backup_file"
|
||
fi
|
||
|
||
echo "[WARN] 强制更新:本地代码修改将被覆盖"
|
||
git -C "$PROJECT_DIR" fetch "$remote_name" "$branch" || {
|
||
err "git fetch 失败"
|
||
exit 1
|
||
}
|
||
|
||
git -C "$PROJECT_DIR" reset --hard "${remote_name}/${branch}" || {
|
||
err "git reset --hard 失败"
|
||
exit 1
|
||
}
|
||
|
||
if [ -f "$env_file" ] && [ -f "$backup_file" ]; then
|
||
echo "[INFO] 正在恢复本地环境变量 ..."
|
||
restore_local_env_overrides "$env_file" "$backup_file"
|
||
fi
|
||
|
||
echo "[INFO] 正在重新生成配置 ..."
|
||
if ! bash "$PROJECT_DIR/scripts/generate_config.sh"; then
|
||
err "配置生成失败"
|
||
exit 1
|
||
fi
|
||
|
||
echo "[INFO] 正在重启服务 ..."
|
||
if has_systemd; then
|
||
systemctl restart clash-for-linux.service || {
|
||
err "systemd 重启失败"
|
||
exit 1
|
||
}
|
||
else
|
||
bash "$PROJECT_DIR/scripts/run_clash.sh" --daemon || {
|
||
err "脚本模式启动失败"
|
||
exit 1
|
||
}
|
||
fi
|
||
|
||
ok "强制更新完成"
|
||
cmd_status
|
||
}
|
||
|
||
# === 修复执行权限 ===
|
||
fix_exec_permissions() {
|
||
local dir="${PROJECT_DIR:-$(pwd)}"
|
||
|
||
chmod +x "$dir"/scripts/*.sh 2>/dev/null || true
|
||
chmod +x "$dir"/bin/* 2>/dev/null || true
|
||
|
||
# 可选:修复 CRLF(防止 Windows 换行)
|
||
sed -i 's/\r$//' "$dir"/scripts/*.sh 2>/dev/null || true
|
||
}
|
||
|
||
# === 等待 service 进入 active ===
|
||
wait_for_service_active() {
|
||
local svc="${1:-clash-for-linux.service}"
|
||
local timeout="${2:-10}"
|
||
|
||
for ((i=0; i<timeout; i++)); do
|
||
state=$(systemctl is-active "$svc" 2>/dev/null || true)
|
||
if [ "$state" = "active" ]; then
|
||
return 0
|
||
fi
|
||
sleep 1
|
||
done
|
||
|
||
return 1
|
||
}
|
||
|
||
# === 获取实际端口(从 runtime/config.yaml 解析)===
|
||
get_actual_ports() {
|
||
local cfg="$PROJECT_DIR/runtime/config.yaml"
|
||
|
||
ACTUAL_HTTP_PORT=""
|
||
ACTUAL_CTRL_PORT=""
|
||
|
||
[ -f "$cfg" ] || return
|
||
|
||
# mixed-port
|
||
ACTUAL_HTTP_PORT=$(awk -F': *' '
|
||
/^[[:space:]]*mixed-port:/ {
|
||
gsub("\r","",$2)
|
||
print $2
|
||
exit
|
||
}' "$cfg")
|
||
|
||
# external-controller
|
||
local ctrl
|
||
ctrl=$(awk -F': *' '
|
||
/^[[:space:]]*external-controller:/ {
|
||
gsub("\r","",$2)
|
||
print $2
|
||
exit
|
||
}' "$cfg")
|
||
|
||
if [ -n "$ctrl" ]; then
|
||
ACTUAL_CTRL_PORT="${ctrl##*:}"
|
||
fi
|
||
|
||
export ACTUAL_HTTP_PORT ACTUAL_CTRL_PORT
|
||
}
|
||
|
||
wait_for_systemd_active() {
|
||
local i state
|
||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||
state="$(systemctl is-active "$SERVICE_NAME" 2>/dev/null || true)"
|
||
if [ "$state" = "active" ]; then
|
||
return 0
|
||
fi
|
||
sleep 1
|
||
done
|
||
return 1
|
||
}
|
||
|
||
cmd_restart() {
|
||
echo "[INFO] 正在停止 Clash ..."
|
||
cmd_stop "${1:-false}" || true
|
||
sleep 1
|
||
|
||
echo "[INFO] 正在重新生成配置 ..."
|
||
if ! cmd_generate; then
|
||
return 1
|
||
fi
|
||
|
||
echo "[INFO] 正在启动 Clash ..."
|
||
if ! cmd_start; then
|
||
err "启动失败"
|
||
return 1
|
||
fi
|
||
|
||
if has_systemd; then
|
||
echo "[INFO] 正在等待服务启动完成 ..."
|
||
if wait_for_systemd_active; then
|
||
ok "重启完成"
|
||
return 0
|
||
fi
|
||
|
||
err "服务未就绪"
|
||
systemctl status "$SERVICE_NAME" -l --no-pager || true
|
||
return 1
|
||
fi
|
||
|
||
ok "重启完成"
|
||
}
|
||
|
||
cmd_ui() {
|
||
local raw="${1:-}"
|
||
local controller host port secret base_url
|
||
|
||
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="$(curl -fsS --max-time 5 ifconfig.me 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}')"
|
||
[ -n "${host:-}" ] || host="127.0.0.1"
|
||
;;
|
||
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"
|
||
echo "请先执行 install.sh 或 clashctl generate" >&2
|
||
exit 1
|
||
fi
|
||
|
||
secret="$(read_runtime_config_value "secret" || true)"
|
||
if [ -z "${secret:-}" ]; then
|
||
err "未找到 secret 在配置: $RUNTIME_CONFIG"
|
||
exit 1
|
||
fi
|
||
|
||
printf '%s\n' "$secret"
|
||
}
|
||
|
||
cmd_sub() {
|
||
local subcmd="${1:-show}"
|
||
|
||
case "$subcmd" in
|
||
show)
|
||
if [ -f "$ENV_FILE" ]; then
|
||
local current_url
|
||
current_url="$(sed -nE "s/^[[:space:]]*(export[[:space:]]+)?CLASH_URL=['\"]?([^'\"]*)['\"]?$/\2/p" "$ENV_FILE" | head -n 1)"
|
||
if [ -n "${current_url:-}" ]; then
|
||
echo "[1] $current_url"
|
||
else
|
||
echo "未配置订阅"
|
||
fi
|
||
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_status() {
|
||
local mode running="no"
|
||
local service_active="" service_enabled=""
|
||
local pid=""
|
||
local controller dashboard_url
|
||
local config_source generate_status generate_reason generate_at
|
||
local run_status run_mode run_pid run_at
|
||
local http_port dashboard_port
|
||
local secret_exists="no"
|
||
|
||
mode="$(detect_mode)"
|
||
|
||
case "$mode" in
|
||
systemd)
|
||
if systemctl is-active --quiet "$SERVICE_NAME"; then
|
||
running="yes"
|
||
fi
|
||
service_active="$(systemctl is-active "$SERVICE_NAME" 2>/dev/null || true)"
|
||
service_enabled="$(systemctl is-enabled "$SERVICE_NAME" 2>/dev/null || true)"
|
||
;;
|
||
systemd-installed)
|
||
running="no"
|
||
service_active="$(systemctl is-active "$SERVICE_NAME" 2>/dev/null || true)"
|
||
service_enabled="$(systemctl is-enabled "$SERVICE_NAME" 2>/dev/null || true)"
|
||
;;
|
||
script)
|
||
if is_script_running; then
|
||
running="yes"
|
||
fi
|
||
pid="$(read_pid 2>/dev/null || true)"
|
||
;;
|
||
none)
|
||
running="no"
|
||
;;
|
||
esac
|
||
|
||
generate_status="$(read_state_value LAST_GENERATE_STATUS || true)"
|
||
generate_reason="$(read_state_value LAST_GENERATE_REASON || true)"
|
||
config_source="$(read_state_value LAST_CONFIG_SOURCE || true)"
|
||
generate_at="$(read_state_value LAST_GENERATE_AT || true)"
|
||
|
||
run_status="$(read_state_value LAST_RUN_STATUS || true)"
|
||
run_mode="$(read_state_value LAST_RUN_MODE || true)"
|
||
run_pid="$(read_state_value LAST_RUN_PID || true)"
|
||
run_at="$(read_state_value LAST_RUN_AT || true)"
|
||
|
||
# 一律以 runtime/config.yaml 为准,避免显示旧端口
|
||
http_port="$(http_port_from_config || true)"
|
||
dashboard_port="$(port_from_controller || true)"
|
||
controller="$(read_runtime_config_value "external-controller" || true)"
|
||
|
||
# dashboard_url 最好也建立在 runtime config 基础上
|
||
dashboard_url="$(cmd_ui --raw 2>/dev/null || true)"
|
||
|
||
if [ -n "$(read_runtime_config_value "secret" || true)" ]; then
|
||
secret_exists="yes"
|
||
fi
|
||
|
||
echo "=== Clash 状态 ==="
|
||
echo "项目路径 : $PROJECT_DIR"
|
||
echo "运行模式 : $mode"
|
||
echo "运行状态 : $running"
|
||
echo "配置文件 : $RUNTIME_CONFIG"
|
||
|
||
if [ -f "$RUNTIME_CONFIG" ]; then
|
||
echo "配置存在 : 是"
|
||
else
|
||
echo "配置存在 : 否"
|
||
fi
|
||
|
||
if [ -f "$STATE_FILE" ]; then
|
||
echo "状态文件 : $STATE_FILE"
|
||
else
|
||
echo "状态文件 : 缺失"
|
||
fi
|
||
|
||
case "$mode" in
|
||
systemd|systemd-installed)
|
||
echo "服务类型 : systemd"
|
||
echo "服务状态 : ${service_active:-未知}"
|
||
echo "开机自启 : ${service_enabled:-未知}"
|
||
;;
|
||
script)
|
||
echo "服务类型 : 脚本模式"
|
||
echo "进程 ID : ${pid:-未知}"
|
||
;;
|
||
none)
|
||
echo "服务类型 : 未安装"
|
||
;;
|
||
esac
|
||
|
||
echo "配置生成状态 : ${generate_status:-未知}"
|
||
if [ -n "${generate_reason:-}" ]; then
|
||
echo "生成失败原因 : $generate_reason"
|
||
fi
|
||
if [ -n "${config_source:-}" ]; then
|
||
echo "配置来源 : $config_source"
|
||
fi
|
||
if [ -n "${generate_at:-}" ]; then
|
||
echo "生成时间 : $generate_at"
|
||
fi
|
||
|
||
if [ -n "${run_status:-}" ]; then
|
||
echo "运行结果 : $run_status"
|
||
fi
|
||
if [ -n "${run_mode:-}" ]; then
|
||
echo "运行方式 : $run_mode"
|
||
fi
|
||
if [ -n "${run_pid:-}" ]; then
|
||
echo "运行进程 : $run_pid"
|
||
fi
|
||
if [ -n "${run_at:-}" ]; then
|
||
echo "启动时间 : $run_at"
|
||
fi
|
||
|
||
echo "代理端口 : ${http_port:-未知}"
|
||
echo "面板端口 : ${dashboard_port:-未知}"
|
||
|
||
if [ -n "${controller:-}" ]; then
|
||
echo "控制地址 : $controller"
|
||
else
|
||
echo "控制地址 : 未知"
|
||
fi
|
||
|
||
if [ -n "${dashboard_url:-}" ]; then
|
||
echo "面板地址 : $dashboard_url"
|
||
fi
|
||
|
||
echo "密钥状态 : $secret_exists"
|
||
}
|
||
|
||
doctor_ok() {
|
||
printf "\033[32m[OK]\033[0m %s\n" "$*"
|
||
}
|
||
|
||
doctor_warn() {
|
||
printf "\033[33m[WARN]\033[0m %s\n" "$*"
|
||
}
|
||
|
||
doctor_err() {
|
||
printf "\033[31m[ERROR]\033[0m %s\n" "$*"
|
||
}
|
||
|
||
cmd_doctor() {
|
||
local mode running="no"
|
||
local failed=0
|
||
local warned=0
|
||
|
||
local controller dashboard_url dashboard_port
|
||
local http_port
|
||
local secret
|
||
local last_generate_status last_generate_reason last_config_source
|
||
|
||
mode="$(detect_mode)"
|
||
|
||
echo "=== Clash 健康检查 ==="
|
||
echo "项目路径 : $PROJECT_DIR"
|
||
echo "运行模式 : $mode"
|
||
echo "配置文件 : $RUNTIME_CONFIG"
|
||
echo
|
||
|
||
if [ -s "$RUNTIME_CONFIG" ]; then
|
||
doctor_ok "运行配置文件存在: $RUNTIME_CONFIG"
|
||
else
|
||
doctor_err "运行配置文件不存在或为空: $RUNTIME_CONFIG"
|
||
failed=1
|
||
fi
|
||
|
||
if [ -f "$RUNTIME_CONFIG" ]; then
|
||
if grep -q '\${' "$RUNTIME_CONFIG"; then
|
||
doctor_err "配置文件存在未替换变量"
|
||
failed=1
|
||
else
|
||
doctor_ok "配置文件检查通过(无未替换变量)"
|
||
fi
|
||
fi
|
||
|
||
if [ -f "$STATE_FILE" ]; then
|
||
doctor_ok "状态文件存在: $STATE_FILE"
|
||
|
||
last_generate_status="$(read_state_value LAST_GENERATE_STATUS || true)"
|
||
last_generate_reason="$(read_state_value LAST_GENERATE_REASON || true)"
|
||
last_config_source="$(read_state_value LAST_CONFIG_SOURCE || true)"
|
||
|
||
if [ -n "${last_generate_status:-}" ]; then
|
||
if [ "$last_generate_status" = "success" ]; then
|
||
doctor_ok "最近配置生成状态:成功 (${last_generate_reason:-未知})"
|
||
else
|
||
doctor_warn "最近配置生成状态:${last_generate_status:-未知} (${last_generate_reason:-未知})"
|
||
warned=1
|
||
fi
|
||
else
|
||
doctor_warn "状态文件存在,但 LAST_GENERATE_STATUS 为空"
|
||
warned=1
|
||
fi
|
||
|
||
if [ -n "${last_config_source:-}" ]; then
|
||
doctor_ok "配置来源: $last_config_source"
|
||
fi
|
||
else
|
||
doctor_warn "状态文件缺失: $STATE_FILE"
|
||
warned=1
|
||
fi
|
||
|
||
case "$mode" in
|
||
systemd)
|
||
if systemctl is-active --quiet "$SERVICE_NAME"; then
|
||
running="yes"
|
||
doctor_ok "systemd 服务运行正常"
|
||
else
|
||
doctor_err "systemd 服务已安装但未运行"
|
||
failed=1
|
||
fi
|
||
;;
|
||
script)
|
||
if is_script_running; then
|
||
running="yes"
|
||
doctor_ok "脚本模式进程正在运行 (pid=$(read_pid 2>/dev/null || echo 未知))"
|
||
else
|
||
doctor_err "已检测到脚本模式,但进程未运行"
|
||
failed=1
|
||
fi
|
||
;;
|
||
systemd-installed)
|
||
doctor_warn "systemd 单元已存在,但服务未运行"
|
||
warned=1
|
||
;;
|
||
none)
|
||
doctor_warn "未检测到运行中的实例"
|
||
warned=1
|
||
;;
|
||
*)
|
||
doctor_err "未知运行模式: $mode"
|
||
failed=1
|
||
;;
|
||
esac
|
||
|
||
dashboard_url="$(cmd_ui --raw 2>/dev/null || true)"
|
||
controller="$(read_runtime_config_value "external-controller" || true)"
|
||
dashboard_port="$(port_from_controller)"
|
||
|
||
if [ -n "${controller:-}" ]; then
|
||
doctor_ok "控制地址已配置: $controller"
|
||
else
|
||
doctor_warn "未配置 external-controller,使用默认值: 127.0.0.1:9090"
|
||
warned=1
|
||
fi
|
||
|
||
if [ -n "${dashboard_url:-}" ]; then
|
||
doctor_ok "面板地址: $dashboard_url"
|
||
else
|
||
doctor_warn "无法获取面板地址"
|
||
warned=1
|
||
fi
|
||
|
||
http_port="$(http_port_from_config)"
|
||
if check_port_listening "$http_port"; then
|
||
doctor_ok "代理端口正常监听: $http_port"
|
||
else
|
||
rc=$?
|
||
if [ "$rc" -eq 2 ]; then
|
||
doctor_warn "无法检测代理端口(缺少 ss/netstat 工具)"
|
||
warned=1
|
||
else
|
||
if [ "$running" = "yes" ]; then
|
||
doctor_err "代理端口未监听: $http_port"
|
||
failed=1
|
||
else
|
||
doctor_warn "代理端口未监听: $http_port"
|
||
warned=1
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
if check_port_listening "$dashboard_port"; then
|
||
doctor_ok "面板端口正常监听: $dashboard_port"
|
||
else
|
||
rc=$?
|
||
if [ "$rc" -eq 2 ]; then
|
||
doctor_warn "无法检测面板端口(缺少 ss/netstat 工具)"
|
||
warned=1
|
||
else
|
||
if [ "$running" = "yes" ]; then
|
||
doctor_err "面板端口未监听: $dashboard_port"
|
||
failed=1
|
||
else
|
||
doctor_warn "面板端口未监听: $dashboard_port"
|
||
warned=1
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
if [ -n "${dashboard_url:-}" ]; then
|
||
if check_dashboard_http "$dashboard_url"; then
|
||
doctor_ok "面板访问正常"
|
||
else
|
||
rc=$?
|
||
if [ "$rc" -eq 2 ]; then
|
||
doctor_warn "无法检测面板 HTTP(缺少 curl/wget 工具)"
|
||
warned=1
|
||
else
|
||
doctor_warn "面板无法访问: $dashboard_url"
|
||
warned=1
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
secret="$(read_runtime_config_value "secret" || true)"
|
||
if [ -n "${secret:-}" ]; then
|
||
doctor_ok "已配置 secret"
|
||
else
|
||
doctor_warn "未配置 secret"
|
||
warned=1
|
||
fi
|
||
|
||
if command_exists clashctl; then
|
||
doctor_ok "clashctl 命令可用: $(command -v clashctl)"
|
||
else
|
||
doctor_warn "未在 PATH 中找到 clashctl 命令"
|
||
warned=1
|
||
fi
|
||
|
||
if [ -f "$PROFILED_FILE" ]; then
|
||
doctor_ok "Shell 代理辅助脚本存在: $PROFILED_FILE"
|
||
else
|
||
doctor_warn "Shell 代理辅助脚本缺失: $PROFILED_FILE"
|
||
warned=1
|
||
fi
|
||
|
||
echo
|
||
if [ "$failed" -ne 0 ]; then
|
||
doctor_err "检查结果:失败"
|
||
return 1
|
||
fi
|
||
|
||
if [ "$warned" -ne 0 ]; then
|
||
doctor_warn "检查结果:警告"
|
||
return 0
|
||
fi
|
||
|
||
doctor_ok "检查结果:健康"
|
||
return 0
|
||
}
|
||
|
||
cmd_logs() {
|
||
shift || true
|
||
|
||
local follow="false"
|
||
local lines="50"
|
||
local mode
|
||
local arg
|
||
|
||
while [ $# -gt 0 ]; do
|
||
arg="$1"
|
||
case "$arg" in
|
||
-f|--follow)
|
||
follow="true"
|
||
;;
|
||
-n|--lines)
|
||
shift || true
|
||
if [ $# -eq 0 ]; then
|
||
err "logs: -n/--lines 需要一个数字参数"
|
||
exit 1
|
||
fi
|
||
lines="$1"
|
||
;;
|
||
*)
|
||
err "未知 logs 参数: $arg"
|
||
echo "用法: clashctl logs [-f] [-n 100]"
|
||
exit 1
|
||
;;
|
||
esac
|
||
shift || true
|
||
done
|
||
|
||
mode="$(detect_mode)"
|
||
|
||
case "$mode" in
|
||
systemd|systemd-installed)
|
||
if ! command -v journalctl >/dev/null 2>&1; then
|
||
err "未找到 journalctl,无法读取 systemd 日志"
|
||
exit 1
|
||
fi
|
||
|
||
if [ "$follow" = "true" ]; then
|
||
journalctl -u "$SERVICE_NAME" -n "$lines" -f
|
||
else
|
||
journalctl -u "$SERVICE_NAME" -n "$lines" --no-pager
|
||
fi
|
||
;;
|
||
script|none)
|
||
if [ ! -f "$LOG_FILE" ]; then
|
||
warn "未找到日志文件: $LOG_FILE"
|
||
exit 0
|
||
fi
|
||
|
||
if [ "$follow" = "true" ]; then
|
||
tail -n "$lines" -f "$LOG_FILE"
|
||
else
|
||
tail -n "$lines" "$LOG_FILE"
|
||
fi
|
||
;;
|
||
*)
|
||
err "未知模式: $mode"
|
||
exit 1
|
||
;;
|
||
esac
|
||
}
|
||
|
||
read_env_kv() {
|
||
local env_file="$1"
|
||
local key="$2"
|
||
|
||
[ -f "$env_file" ] || return 0
|
||
sed -nE "s/^[[:space:]]*(export[[:space:]]+)?${key}=['\"]?([^'\"]*)['\"]?$/\2/p" "$env_file" | head -n 1
|
||
}
|
||
|
||
write_env_kv() {
|
||
local env_file="$1"
|
||
local key="$2"
|
||
local value="$3"
|
||
local escaped="${value//\\/\\\\}"
|
||
escaped="${escaped//&/\\&}"
|
||
escaped="${escaped//|/\\|}"
|
||
escaped="${escaped//\'/\'\\\'\'}"
|
||
|
||
if grep -qE "^[[:space:]]*(export[[:space:]]+)?${key}=" "$env_file"; then
|
||
sed -i -E "s|^[[:space:]]*(export[[:space:]]+)?${key}=.*$|export ${key}='${escaped}'|g" "$env_file"
|
||
else
|
||
printf "export %s='%s'\n" "$key" "$value" >> "$env_file"
|
||
fi
|
||
}
|
||
|
||
backup_local_env_overrides() {
|
||
local env_file="$1"
|
||
local backup_file="$2"
|
||
|
||
: > "$backup_file"
|
||
|
||
local keys=(
|
||
CLASH_URL
|
||
CLASH_SECRET
|
||
CLASH_HTTP_PORT
|
||
CLASH_SOCKS_PORT
|
||
CLASH_REDIR_PORT
|
||
CLASH_LISTEN_IP
|
||
CLASH_ALLOW_LAN
|
||
EXTERNAL_CONTROLLER_ENABLED
|
||
EXTERNAL_CONTROLLER
|
||
ALLOW_INSECURE_TLS
|
||
CLASH_AUTO_UPDATE
|
||
CLASH_DOWNLOAD_URL_TEMPLATE
|
||
)
|
||
|
||
local key value
|
||
for key in "${keys[@]}"; do
|
||
value="$(read_env_kv "$env_file" "$key")"
|
||
if [ -n "${value:-}" ]; then
|
||
printf "%s=%s\n" "$key" "$value" >> "$backup_file"
|
||
fi
|
||
done
|
||
}
|
||
|
||
restore_local_env_overrides() {
|
||
local env_file="$1"
|
||
local backup_file="$2"
|
||
|
||
[ -f "$backup_file" ] || return 0
|
||
|
||
local line key value
|
||
while IFS= read -r line; do
|
||
[ -n "$line" ] || continue
|
||
key="${line%%=*}"
|
||
value="${line#*=}"
|
||
write_env_kv "$env_file" "$key" "$value"
|
||
done < "$backup_file"
|
||
}
|
||
|
||
main() {
|
||
local from_systemd="false"
|
||
|
||
if [ "${1:-}" = "--from-systemd" ]; then
|
||
from_systemd="true"
|
||
shift
|
||
fi
|
||
|
||
case "${1:-}" in
|
||
on)
|
||
cmd_on
|
||
;;
|
||
off)
|
||
cmd_off
|
||
;;
|
||
start)
|
||
cmd_start
|
||
;;
|
||
stop)
|
||
cmd_stop "$from_systemd"
|
||
;;
|
||
restart)
|
||
cmd_restart "$from_systemd"
|
||
;;
|
||
status)
|
||
cmd_status
|
||
;;
|
||
update)
|
||
shift
|
||
cmd_update "$@"
|
||
;;
|
||
update-force)
|
||
shift
|
||
cmd_update_force "$@"
|
||
;;
|
||
generate)
|
||
cmd_generate
|
||
;;
|
||
mode)
|
||
cmd_mode
|
||
;;
|
||
ui)
|
||
shift || true
|
||
cmd_ui "${1:-}"
|
||
;;
|
||
secret)
|
||
cmd_secret
|
||
;;
|
||
sub)
|
||
shift || true
|
||
cmd_sub "${1:-show}"
|
||
;;
|
||
|
||
tun)
|
||
shift || true
|
||
cmd_tun "${1:-status}"
|
||
;;
|
||
mixin)
|
||
shift || true
|
||
cmd_mixin "${1:-status}"
|
||
;;
|
||
doctor)
|
||
cmd_doctor
|
||
;;
|
||
logs)
|
||
cmd_logs "$@"
|
||
;;
|
||
""|-h|--help)
|
||
usage
|
||
;;
|
||
*)
|
||
err "未知命令: ${1:-}"
|
||
usage
|
||
exit 1
|
||
;;
|
||
esac
|
||
}
|
||
|
||
main "$@" |