Files
clash-for-linux/clashctl
T
2026-03-22 20:13:58 +08:00

1561 lines
36 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
ui_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
选项:
--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
}
trim_value() {
local s="${1:-}"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
printf '%s' "$s"
}
read_env_value() {
local key="$1"
local env_file="$PROJECT_DIR/.env"
[ -f "$env_file" ] || return 1
sed -nE "s/^[[:space:]]*(export[[:space:]]+)?${key}=['\"]?([^'\"]*)['\"]?.*$/\2/p" "$env_file" | head -n 1
}
write_env_value() {
local key="$1"
local value="$2"
local env_file="$PROJECT_DIR/.env"
local tmp_file
touch "$env_file"
tmp_file="$(mktemp)"
awk -v k="$key" -v v="$value" '
BEGIN { updated = 0 }
$0 ~ "^[[:space:]]*(export[[:space:]]+)?" k "=" {
print "export " k "='\''" v "'\''"
updated = 1
next
}
{ print }
END {
if (!updated) {
print "export " k "='\''" v "'\''"
}
}
' "$env_file" > "$tmp_file" && mv "$tmp_file" "$env_file"
}
is_valid_subscription_url() {
local url
url="$(trim_value "${1:-}")"
[ -n "$url" ] || return 1
case "$url" in
http://*|https://*)
;;
*)
return 1
;;
esac
[[ "$url" =~ [[:space:]] ]] && return 1
[[ "$url" == "-"* ]] && return 1
return 0
}
prompt_for_subscription_url() {
local current_url=""
local input_url=""
current_url="$(read_env_value "CLASH_URL" || true)"
current_url="$(trim_value "$current_url")"
ui_blank
ui_subheader "订阅配置"
if is_valid_subscription_url "$current_url"; then
printf " 当前订阅: %s\n" "$current_url"
printf " 直接回车可继续使用当前订阅\n"
else
printf " 当前未配置有效订阅,请输入订阅链接\n"
fi
while true; do
printf " 订阅链接: "
IFS= read -r input_url
input_url="$(trim_value "$input_url")"
if [ -z "$input_url" ]; then
if is_valid_subscription_url "$current_url"; then
input_url="$current_url"
else
ui_error "订阅链接不能为空"
continue
fi
fi
if ! is_valid_subscription_url "$input_url"; then
ui_error "订阅链接格式非法,必须以 http:// 或 https:// 开头"
continue
fi
write_env_value "CLASH_URL" "$input_url"
export CLASH_URL="$input_url"
ui_ok "订阅链接已保存"
return 0
done
}
ensure_subscription_url_ready() {
local current_url=""
current_url="$(read_env_value "CLASH_URL" || true)"
current_url="$(trim_value "$current_url")"
if is_valid_subscription_url "$current_url"; then
export CLASH_URL="$current_url"
return 0
fi
prompt_for_subscription_url
}
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
}
generate_config_once() {
ui_info "正在重新生成配置 ..."
if ! "$PROJECT_DIR/scripts/generate_config.sh"; then
ui_error "配置生成失败"
return 1
fi
ui_ok "配置生成成功"
}
cmd_generate() {
ensure_subscription_url_ready || return 1
generate_config_once
}
cmd_start() {
local mode
mode="$(detect_mode)"
case "$mode" in
systemd|systemd-installed)
start_via_systemd
ui_ok "已通过 systemd 启动 Clash"
;;
script|none)
start_via_script
ui_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
ui_ok "来自 systemd 的停止请求,已跳过重复停止操作"
return 0
fi
stop_via_systemd
ui_ok "已通过 systemd 停止 Clash"
;;
systemd-installed)
ui_info "systemd 服务已安装但未运行"
;;
script)
stop_via_script
ui_ok "已通过脚本模式停止 Clash"
;;
none)
ui_info "Clash 未运行"
;;
*)
err "未知模式: $mode"
exit 1
;;
esac
}
show_update_result_summary() {
[ -n "${LAST_CONFIG_RESULT:-}" ] || return 0
echo
ui_subheader "配置更新结果"
if [ "$LAST_CONFIG_RESULT" = "success" ]; then
echo " 状态 : 已成功更新配置"
return 0
fi
echo " 状态 : 失败(已沿用旧配置)"
[ -n "$LAST_CONFIG_REASON" ] && echo " 原因 : $LAST_CONFIG_REASON"
[ -n "$LAST_CONFIG_HINT" ] && echo " 建议 : $LAST_CONFIG_HINT"
}
safe_regenerate_config() {
local config_file="$PROJECT_DIR/runtime/config.yaml"
LAST_CONFIG_RESULT="success"
LAST_CONFIG_REASON=""
LAST_CONFIG_HINT=""
ui_info "正在重新生成配置 ..."
if bash "$PROJECT_DIR/scripts/generate_config.sh" >/dev/null 2>&1; then
return 0
fi
LAST_CONFIG_RESULT="failed"
case "$(read_state_value reason 2>/dev/null || true)" in
url_invalid)
LAST_CONFIG_REASON="订阅链接格式错误"
LAST_CONFIG_HINT="必须以 http:// 或 https:// 开头"
;;
url_missing)
LAST_CONFIG_REASON="未配置订阅链接"
LAST_CONFIG_HINT="请先设置 CLASH_URL"
;;
*)
LAST_CONFIG_REASON="订阅不可用或配置生成失败"
LAST_CONFIG_HINT="请检查订阅链接和网络连接"
;;
esac
if [ -s "$config_file" ]; then
return 2
fi
return 1
}
cmd_update() {
local resume_mode="${1:-}"
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"
# 第二阶段:跳过 git 更新,只执行更新后的收尾逻辑
if [ "$resume_mode" != "--resume-after-self-update" ]; then
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
ui_warn "检测到本地已修改但未提交的文件,自动切换到安全强制更新模式"
pull_ok=0
fi
if [ "$pull_ok" -eq 1 ]; then
ui_info "正在从 ${remote_name}/${branch} 拉取最新代码 ..."
if ! git -C "$PROJECT_DIR" pull --ff-only "$remote_name" "$branch"; then
ui_warn "git pull 失败,自动切换到强制更新模式"
pull_ok=0
fi
fi
if [ "$pull_ok" -eq 0 ]; then
ui_info "正在获取 ${remote_name}/${branch} 最新代码 ..."
if ! git -C "$PROJECT_DIR" fetch "$remote_name" "$branch"; then
err "git fetch 失败"
exit 1
fi
ui_warn "正在重置本地代码到 ${remote_name}/${branch}"
if ! git -C "$PROJECT_DIR" reset --hard "${remote_name}/${branch}"; then
err "git reset --hard 失败"
exit 1
fi
fi
if [ -f "$PROJECT_DIR/.env.bak" ]; then
mv -f "$PROJECT_DIR/.env.bak" "$PROJECT_DIR/.env" 2>/dev/null || true
fi
hash -r
ui_info "已更新到最新代码,正在切换到新版本继续执行..."
exec "$PROJECT_DIR/clashctl" update --resume-after-self-update
fi
# ===== 从这里开始,一定是新版本脚本在执行 =====
ui_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
ui_info "正在停止旧实例 ..."
if has_systemd; then
systemctl stop clash-for-linux.service >/dev/null 2>&1 || true
else
"$PROJECT_DIR/scripts/stop_clash.sh" >/dev/null 2>&1 || true
fi
local regen_result=0
if safe_regenerate_config; then
regen_result=0
else
regen_result=$?
fi
if [ "$regen_result" -eq 1 ]; then
err "配置生成失败,且不存在可用旧配置"
exit 1
fi
ui_info "正在启动服务 ..."
if has_systemd; then
if ! systemctl start clash-for-linux.service; then
err "systemd 启动失败"
exit 1
fi
ui_info "等待服务就绪..."
if wait_for_systemd_active; then
if [ "$regen_result" -eq 2 ]; then
ui_ok "代码已更新,当前继续使用旧配置运行"
else
ui_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
ui_ok "代码已更新,当前继续使用旧配置运行"
show_update_result_summary
else
ui_ok "更新完成"
fi
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
ui_info "正在备份本地环境变量覆盖项 ..."
backup_local_env_overrides "$env_file" "$backup_file"
fi
ui_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
ui_info "正在恢复本地环境变量 ..."
restore_local_env_overrides "$env_file" "$backup_file"
fi
ui_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
regen_result=0
safe_regenerate_config || regen_result=$?
if [ "$regen_result" -eq 1 ]; then
err "配置生成失败,且不存在可用旧配置"
exit 1
fi
if [ "$regen_result" -eq 1 ]; then
err "配置生成失败,且不存在可用旧配置"
exit 1
fi
ui_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
ui_ok "强制更新完成,当前继续使用旧配置运行"
else
ui_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() {
ui_info "正在停止 Clash ..."
cmd_stop "${1:-false}" || true
sleep 1
ui_info "正在重新生成配置 ..."
if ! cmd_generate; then
return 1
fi
ui_info "正在启动 Clash ..."
if ! cmd_start; then
err "启动失败"
return 1
fi
if has_systemd; then
ui_info "正在等待服务启动完成 ..."
if wait_for_systemd_active; then
ui_ok "重启完成"
return 0
fi
err "服务未就绪"
systemctl status "$SERVICE_NAME" -l --no-pager || true
return 1
fi
ui_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)
local current_url=""
current_url="$(read_env_value "CLASH_URL" || true)"
if is_valid_subscription_url "$current_url"; then
echo "$current_url"
else
echo "未配置订阅"
fi
;;
update)
ensure_subscription_url_ready || return 1
ui_info "正在停止 Clash ..."
cmd_stop || true
generate_config_once || return 1
ui_info "正在启动 Clash ..."
cmd_start || return 1
ui_ok "订阅已更新并应用"
;;
*)
err "未知 sub 子命令: $subcmd"
echo "用法: clashctl sub [show|update]"
return 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"
ui_ok "已写入 CLASH_TUN=true"
;;
off)
write_env_bool "CLASH_TUN" "false"
ui_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"
ui_ok "已写入 CLASH_MIXIN=true"
;;
off)
write_env_bool "CLASH_MIXIN" "false"
ui_ok "已写入 CLASH_MIXIN=false"
;;
*)
err "未知 mixin 子命令: $subcmd"
echo "用法: clashctl mixin [status|on|off]"
exit 1
;;
esac
}
cmd_status() {
local pid="-"
local running_text="未运行"
local service_active_text="-"
local service_enabled_text="-"
local proxy_port="-"
local dashboard_port="-"
local controller_addr="-"
local dashboard_url="-"
local secret_display="未配置"
local secret_value=""
mode="$(detect_mode)"
case "$mode" in
systemd)
if systemctl is-active --quiet "$SERVICE_NAME"; then
running_text="运行中"
else
running_text="未运行"
fi
local active_raw=""
active_raw="$(systemctl is-active "$SERVICE_NAME" 2>/dev/null || true)"
case "$active_raw" in
active) service_active_text="运行中" ;;
inactive) service_active_text="未运行" ;;
failed) service_active_text="失败" ;;
activating) service_active_text="启动中" ;;
deactivating) service_active_text="停止中" ;;
*) service_active_text="${active_raw:--}" ;;
esac
local enabled_raw=""
enabled_raw="$(systemctl is-enabled "$SERVICE_NAME" 2>/dev/null || true)"
case "$enabled_raw" in
enabled) service_enabled_text="已开启" ;;
disabled) service_enabled_text="未开启" ;;
static) service_enabled_text="静态" ;;
indirect) service_enabled_text="间接" ;;
masked) service_enabled_text="已屏蔽" ;;
*) service_enabled_text="${enabled_raw:--}" ;;
esac
pid="$(systemctl show -p MainPID --value "$SERVICE_NAME" 2>/dev/null || true)"
[ -z "${pid:-}" ] && pid="-"
[ "$pid" = "0" ] && pid="-"
;;
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)
if [ -n "${pid:-}" ] && kill -0 "$pid" 2>/dev/null; then
running_text="运行中"
else
running_text="未运行"
pid="-"
fi
service_active_text="-"
service_enabled_text="-"
;;
none)
running_text="未运行"
service_active_text="-"
service_enabled_text="-"
pid="-"
;;
esac
[ -f "$RUNTIME_CONFIG" ] && config_exists="是"
[ -f "$STATE_FILE" ] && state_exists="是"
gen_status="$(read_state_value LAST_GENERATE_STATUS || true)"
gen_reason="$(read_state_value LAST_GENERATE_REASON || true)"
cfg_source="$(read_state_value LAST_CONFIG_SOURCE || true)"
gen_time="$(read_state_value LAST_GENERATE_AT || true)"
run_result="$(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)"
start_time="$(read_state_value LAST_RUN_AT || true)"
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 "-")"
secret_value="$(read_runtime_config_value "secret" 2>/dev/null || true)"
if [ -z "${secret_value:-}" ] && [ -f "$PROJECT_DIR/.env" ]; then
secret_value="$(sed -nE "s/^[[:space:]]*(export[[:space:]]+)?CLASH_SECRET=['\"]?([^'\"]+)['\"]?.*$/\2/p" "$PROJECT_DIR/.env" | head -n 1)"
fi
[ -n "${secret_value:-}" ] && secret_display="$secret_value"
proxy_port="$(read_runtime_config_value "mixed-port" || true)"
[ -z "$proxy_port" ] && proxy_port="$(read_runtime_config_value "port" || true)"
[ -z "$proxy_port" ] && proxy_port="-"
controller_addr="$(read_runtime_config_value "external-controller" || true)"
[ -z "$controller_addr" ] && controller_addr="-"
ui_summary_begin "运行与服务"
ui_summary_row "运行模式" "$mode"
ui_summary_row "运行状态" "$running_text"
ui_summary_row "服务状态" "$service_active_text"
ui_summary_row "开机自启" "$service_enabled_text"
ui_summary_row "进程 ID" "${pid:--}"
ui_summary_end
ui_blank
ui_summary_begin "端口与面板"
ui_summary_row "面板地址" "$dashboard_url"
ui_summary_row "密钥配置" "$secret_display"
ui_summary_row "代理端口" "$proxy_port"
ui_summary_row "面板端口" "$dashboard_port"
ui_summary_row "控制地址" "$controller_addr"
ui_summary_end
}
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_info "基础信息"
ui_info "项目路径:$PROJECT_DIR"
ui_info "运行模式:$mode"
ui_info "配置文件:$RUNTIME_CONFIG"
ui_blank
ui_info "[检查] 运行配置"
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_info "状态文件"
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_info "服务运行"
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_info "控制面板"
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_info "端口监听"
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_info "面板访问"
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_info "其他组件"
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 "$@"