Files
clash-for-linux/clashctl
T
2026-03-22 16:29:59 +08:00

1366 lines
31 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"
# shellcheck source=scripts/ui.sh
source "$PROJECT_DIR/scripts/ui.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
}
safe_regenerate_config() {
local config_file="$PROJECT_DIR/runtime/config.yaml"
echo "[INFO] 正在重新生成配置 ..."
if bash "$PROJECT_DIR/scripts/generate_config.sh"; then
return 0
fi
echo "[WARN] 配置重新生成失败,可能是订阅不可用或网络异常"
if [ -s "$config_file" ]; then
echo "[WARN] 检测到已有可用配置,继续沿用当前配置"
return 2
fi
echo "[ERROR] 未找到可用的本地配置,无法继续"
return 1
}
cmd_update() {
local branch remote_name dirty pull_ok=1
local regen_result=0
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
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
echo "[INFO] 修复执行权限中..."
chmod +x "$PROJECT_DIR"/clashctl 2>/dev/null || true
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
safe_regenerate_config
regen_result=$?
if [ "$regen_result" -eq 1 ]; then
err "配置生成失败,且不存在可用旧配置"
exit 1
fi
echo "[INFO] 正在重启服务 ..."
if has_systemd; then
if ! systemctl restart clash-for-linux.service; then
err "systemd 重启失败"
exit 1
fi
echo "[INFO] 等待服务就绪..."
if wait_for_systemd_active; then
if [ "$regen_result" -eq 2 ]; then
ok "代码已更新,当前继续使用旧配置运行"
else
ok "更新完成"
fi
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
if [ "$regen_result" -eq 2 ]; then
ok "代码已更新,当前继续使用旧配置运行"
else
ok "更新完成"
fi
cmd_status
fi
}
cmd_update_force() {
local branch remote_name
local env_file backup_file
local regen_result=0
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] 修复执行权限中..."
chmod +x "$PROJECT_DIR"/clashctl 2>/dev/null || true
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
safe_regenerate_config
regen_result=$?
if [ "$regen_result" -eq 1 ]; 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
if [ "$regen_result" -eq 2 ]; then
ok "强制更新完成,当前继续使用旧配置运行"
else
ok "强制更新完成"
fi
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
local local_ip public_ip public_host
local local_url public_url remote_url
local recommended_url recommended_label
controller="$(read_runtime_config_value "external-controller" || true)"
[ -n "${controller:-}" ] || controller="127.0.0.1:9090"
host="${controller%:*}"
port="${controller##*:}"
secret="$(read_runtime_config_value "secret" || true)"
local_ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
[ -n "${local_ip:-}" ] || local_ip="127.0.0.1"
public_ip="$(curl -fsS --max-time 5 ifconfig.me 2>/dev/null || true)"
case "$host" in
0.0.0.0|::|"")
public_host="${public_ip:-$local_ip}"
;;
localhost|127.0.0.1)
public_host="${public_ip:-$local_ip}"
;;
*)
public_host="$host"
;;
esac
local_url="http://${local_ip}:${port}/ui"
public_url="http://${public_host}:${port}/ui"
remote_url=""
if [ -n "${CLASH_DASHBOARD_PUBLIC_URL:-}" ]; then
remote_url="${CLASH_DASHBOARD_PUBLIC_URL%/}"
fi
recommended_url="$public_url"
recommended_label="公网"
if [ -n "$remote_url" ]; then
recommended_url="$remote_url"
recommended_label="远程"
fi
if [ "$raw" = "--raw" ]; then
printf '%s\n' "$recommended_url"
return 0
fi
ui_header "clashctl 面板"
ui_blank
ui_summary_begin "😼 Clash Web 控制台"
ui_summary_row "推荐访问" "$recommended_url"
ui_summary_row "推荐类型" "$recommended_label"
ui_summary_end
ui_blank
ui_summary_begin "其他访问方式"
ui_summary_row "内网" "$local_url"
ui_summary_row "公网" "$public_url"
if [ -n "$remote_url" ]; then
ui_summary_row "远程" "$remote_url"
fi
ui_summary_end
ui_blank
ui_summary_begin "连接信息"
ui_summary_row "控制地址" "$controller"
ui_summary_row "Web 密钥" "$( [ -n "${secret:-}" ] && printf '已配置' || printf '未配置' )"
ui_summary_end
ui_blank
ui_subheader "提示"
printf ' • 若无法访问,请放行端口 %s\n' "$port"
if [ -n "${secret:-}" ]; then
printf ' • 首次打开面板后,输入 Web 密钥即可连接\n'
else
printf ' • 当前未配置 Web 密钥,可直接访问面板\n'
fi
}
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 service_type="未安装"
local running="未运行"
local service_active="-" service_enabled="-"
local pid="-"
local controller="-"
local dashboard_url="-"
local config_source="-"
local generate_status="-"
local generate_reason="-"
local generate_at="-"
local run_status="-"
local run_mode="-"
local run_pid="-"
local run_at="-"
local http_port="-"
local dashboard_port="-"
local secret_status="未配置"
local config_exists="否"
local state_exists="否"
mode="$(detect_mode)"
case "$mode" in
systemd)
service_type="systemd"
running="运行中"
service_active="$(systemctl is-active "$SERVICE_NAME" 2>/dev/null || true)"
service_enabled="$(systemctl is-enabled "$SERVICE_NAME" 2>/dev/null || true)"
;;
systemd-installed)
service_type="systemd"
running="未运行"
service_active="$(systemctl is-active "$SERVICE_NAME" 2>/dev/null || true)"
service_enabled="$(systemctl is-enabled "$SERVICE_NAME" 2>/dev/null || true)"
;;
script)
service_type="脚本模式"
if is_script_running; then
running="运行中"
fi
pid="$(read_pid 2>/dev/null || echo "-")"
;;
none)
service_type="未安装"
running="未运行"
;;
esac
[ -f "$RUNTIME_CONFIG" ] && config_exists="是"
[ -f "$STATE_FILE" ] && state_exists="是"
generate_status="$(read_state_value LAST_GENERATE_STATUS || echo "-")"
generate_reason="$(read_state_value LAST_GENERATE_REASON || echo "-")"
config_source="$(read_state_value LAST_CONFIG_SOURCE || echo "-")"
generate_at="$(read_state_value LAST_GENERATE_AT || echo "-")"
run_status="$(read_state_value LAST_RUN_STATUS || echo "-")"
run_mode="$(read_state_value LAST_RUN_MODE || echo "-")"
run_pid="$(read_state_value LAST_RUN_PID || echo "-")"
run_at="$(read_state_value LAST_RUN_AT || echo "-")"
http_port="$(http_port_from_config || echo "-")"
dashboard_port="$(port_from_controller || echo "-")"
controller="$(read_runtime_config_value "external-controller" || echo "-")"
dashboard_url="$(cmd_ui --raw 2>/dev/null || echo "-")"
if [ -n "$(read_runtime_config_value "secret" || true)" ]; then
secret_status="已配置"
fi
ui_header "clashctl 状态"
ui_blank
ui_summary_begin "运行状态"
ui_summary_row "项目路径" "$PROJECT_DIR"
ui_summary_row "运行模式" "$mode"
ui_summary_row "运行状态" "$running"
ui_summary_row "配置存在" "$config_exists"
ui_summary_row "状态文件" "$state_exists"
ui_summary_end
ui_blank
ui_summary_begin "服务信息"
ui_summary_row "服务类型" "$service_type"
ui_summary_row "服务状态" "$service_active"
ui_summary_row "开机自启" "$service_enabled"
ui_summary_row "进程 ID" "$pid"
ui_summary_end
ui_blank
ui_summary_begin "配置与运行"
ui_summary_row "生成状态" "$generate_status"
ui_summary_row "失败原因" "$generate_reason"
ui_summary_row "配置来源" "$config_source"
ui_summary_row "生成时间" "$generate_at"
ui_summary_row "运行结果" "$run_status"
ui_summary_row "运行方式" "$run_mode"
ui_summary_row "运行进程" "$run_pid"
ui_summary_row "启动时间" "$run_at"
ui_summary_end
ui_blank
ui_summary_begin "端口与面板"
ui_summary_row "代理端口" "$http_port"
ui_summary_row "面板端口" "$dashboard_port"
ui_summary_row "控制地址" "$controller"
# ui_summary_row "面板地址" "$dashboard_url"
ui_summary_row "密钥状态" "$secret_status"
ui_summary_end
ui_blank
ui_subheader "面板地址"
printf ' %s\n' "$dashboard_url"
}
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="未运行"
local failed=0
local warned=0
local controller="-"
local dashboard_url="-"
local dashboard_port="-"
local http_port="-"
local secret=""
local last_generate_status="-"
local last_generate_reason="-"
local last_config_source="-"
mode="$(detect_mode)"
ui_header "clashctl 诊断"
ui_step "[检查] 基础信息"
ui_info "项目路径:$PROJECT_DIR"
ui_info "运行模式:$mode"
ui_info "配置文件:$RUNTIME_CONFIG"
ui_blank
ui_step "[检查] 运行配置"
if [ -s "$RUNTIME_CONFIG" ]; then
ui_ok "运行配置文件存在"
else
ui_error "运行配置文件不存在或为空"
failed=1
fi
if [ -f "$RUNTIME_CONFIG" ]; then
if grep -q '\${' "$RUNTIME_CONFIG"; then
ui_error "配置文件存在未替换变量"
failed=1
else
ui_ok "配置文件检查通过"
fi
fi
ui_blank
ui_step "[检查] 状态文件"
if [ -f "$STATE_FILE" ]; then
ui_ok "状态文件存在"
last_generate_status="$(read_state_value LAST_GENERATE_STATUS || echo "-")"
last_generate_reason="$(read_state_value LAST_GENERATE_REASON || echo "-")"
last_config_source="$(read_state_value LAST_CONFIG_SOURCE || echo "-")"
if [ "$last_generate_status" = "success" ]; then
ui_ok "最近配置生成成功"
elif [ "$last_generate_status" != "-" ]; then
ui_warn "最近配置生成状态:$last_generate_status"
warned=1
else
ui_warn "状态文件存在但生成状态缺失"
warned=1
fi
[ "$last_generate_reason" != "-" ] && ui_info "生成原因:$last_generate_reason"
[ "$last_config_source" != "-" ] && ui_info "配置来源:$last_config_source"
else
ui_warn "状态文件缺失"
warned=1
fi
ui_blank
ui_step "[检查] 服务运行"
case "$mode" in
systemd)
if systemctl is-active --quiet "$SERVICE_NAME"; then
running="运行中"
ui_ok "systemd 服务运行正常"
else
ui_error "systemd 服务已安装但未运行"
failed=1
fi
;;
script)
if is_script_running; then
running="运行中"
ui_ok "脚本模式进程正在运行"
else
ui_error "脚本模式进程未运行"
failed=1
fi
;;
systemd-installed)
ui_warn "systemd 服务已安装但未运行"
warned=1
;;
none)
ui_warn "未检测到运行中的实例"
warned=1
;;
*)
ui_error "未知运行模式:$mode"
failed=1
;;
esac
ui_blank
ui_step "[检查] 控制面板"
dashboard_url="$(cmd_ui --raw 2>/dev/null || echo "-")"
controller="$(read_runtime_config_value "external-controller" || echo "-")"
dashboard_port="$(port_from_controller || echo "-")"
if [ "$controller" != "-" ]; then
ui_ok "控制地址已配置:$controller"
else
ui_warn "未配置 external-controller,将使用默认值"
warned=1
fi
if [ "$dashboard_url" != "-" ]; then
ui_ok "面板地址可解析"
else
ui_warn "无法获取面板地址"
warned=1
fi
ui_blank
ui_step "[检查] 端口监听"
http_port="$(http_port_from_config || echo "-")"
if [ "$http_port" != "-" ]; then
if check_port_listening "$http_port"; then
ui_ok "代理端口监听正常:$http_port"
else
rc=$?
if [ "$rc" -eq 2 ]; then
ui_warn "无法检测代理端口(缺少 ss/netstat)"
warned=1
else
if [ "$running" = "运行中" ]; then
ui_error "代理端口未监听:$http_port"
failed=1
else
ui_warn "代理端口未监听:$http_port"
warned=1
fi
fi
fi
else
ui_warn "无法解析代理端口"
warned=1
fi
if [ "$dashboard_port" != "-" ]; then
if check_port_listening "$dashboard_port"; then
ui_ok "面板端口监听正常:$dashboard_port"
else
rc=$?
if [ "$rc" -eq 2 ]; then
ui_warn "无法检测面板端口(缺少 ss/netstat)"
warned=1
else
if [ "$running" = "运行中" ]; then
ui_error "面板端口未监听:$dashboard_port"
failed=1
else
ui_warn "面板端口未监听:$dashboard_port"
warned=1
fi
fi
fi
else
ui_warn "无法解析面板端口"
warned=1
fi
ui_blank
ui_step "[检查] 面板访问"
if [ "$dashboard_url" != "-" ]; then
if check_dashboard_http "$dashboard_url"; then
ui_ok "面板访问正常"
else
rc=$?
if [ "$rc" -eq 2 ]; then
ui_warn "无法检测面板 HTTP(缺少 curl/wget)"
warned=1
else
ui_warn "面板无法访问"
warned=1
fi
fi
fi
ui_blank
ui_step "[检查] 其他组件"
secret="$(read_runtime_config_value "secret" || true)"
if [ -n "${secret:-}" ]; then
ui_ok "已配置 Web 密钥"
else
ui_warn "未配置 Web 密钥"
warned=1
fi
if command_exists clashctl; then
ui_ok "clashctl 命令可用"
else
ui_warn "未在 PATH 中找到 clashctl"
warned=1
fi
if [ -f "$PROFILED_FILE" ]; then
ui_ok "Shell 代理辅助脚本存在"
else
ui_warn "Shell 代理辅助脚本缺失"
warned=1
fi
ui_blank
ui_summary_begin "诊断结果"
if [ "$failed" -ne 0 ]; then
ui_summary_row "状态" "失败"
ui_summary_row "说明" "存在必须修复的问题"
ui_summary_end
ui_next \
"查看状态:clashctl status" \
"重启服务:clashctl restart"
ui_debug_block \
"journalctl -u $SERVICE_NAME -e --no-pager" \
"cat $RUNTIME_CONFIG"
return 1
fi
if [ "$warned" -ne 0 ]; then
ui_summary_row "状态" "警告"
ui_summary_row "说明" "可运行,但存在风险项"
ui_summary_end
return 0
fi
ui_summary_row "状态" "健康"
ui_summary_row "说明" "检查全部通过"
ui_summary_end
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 "$@"