Files
clash-for-linux/clashctl
2026-03-21 20:45:39 +08:00

1247 lines
28 KiB
Bash
Executable File
Raw Permalink 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] Unable to locate project directory" >&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'
Usage:
clashctl COMMAND [OPTIONS]
Commands:
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
Advanced Commands:
generate 生成配置(调试用,不会启动服务)
Options:
--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 "export ${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 "Clash started via systemd"
;;
script|none)
start_via_script
ok "Clash started via script mode"
;;
*)
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 "Stop requested from systemd, skip recursive systemctl stop"
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"
;;
none)
info "Clash is not running"
;;
*)
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] pulling latest code from ${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] fetching latest code from ${remote_name}/${branch} ..."
if ! git -C "$PROJECT_DIR" fetch "$remote_name" "$branch"; then
err "git fetch 失败"
exit 1
fi
echo "[WARN] resetting local code to ${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] fixing executable permissions ..."
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] stopping service before regenerate ..."
if has_systemd; then
systemctl stop clash-for-linux.service 2>/dev/null || true
sleep 1
fi
echo "[INFO] regenerating config ..."
if ! bash "$PROJECT_DIR/scripts/generate_config.sh"; then
err "配置生成失败"
exit 1
fi
echo "[INFO] starting service ..."
if has_systemd; then
if ! systemctl start clash-for-linux.service; then
err "systemd 启动失败"
exit 1
fi
echo "[INFO] waiting for service to be active ..."
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] backing up local env overrides ..."
backup_local_env_overrides "$env_file" "$backup_file"
fi
echo "[WARN] force update: local code changes will be discarded"
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] restoring local env overrides ..."
restore_local_env_overrides "$env_file" "$backup_file"
fi
echo "[INFO] regenerating config ..."
if ! bash "$PROJECT_DIR/scripts/generate_config.sh"; then
err "配置生成失败"
exit 1
fi
echo "[INFO] restarting service ..."
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] stopping Clash ..."
cmd_stop "${1:-false}" || true
sleep 1
echo "[INFO] regenerating config ..."
if ! cmd_generate; then
return 1
fi
echo "[INFO] starting Clash ..."
if ! cmd_start; then
err "启动失败"
return 1
fi
if has_systemd; then
echo "[INFO] waiting for service to be active ..."
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 not found: $RUNTIME_CONFIG"
echo "Please run install.sh or 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
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 Status ==="
echo "Project : $PROJECT_DIR"
echo "Mode : $mode"
echo "Running : $running"
echo "Config : $RUNTIME_CONFIG"
if [ -f "$RUNTIME_CONFIG" ]; then
echo "ConfigExists : yes"
else
echo "ConfigExists : no"
fi
if [ -f "$STATE_FILE" ]; then
echo "StateFile : $STATE_FILE"
else
echo "StateFile : missing"
fi
case "$mode" in
systemd|systemd-installed)
echo "Service : installed"
echo "Active : ${service_active:-unknown}"
echo "Enabled : ${service_enabled:-unknown}"
;;
script)
echo "Service : script"
echo "PID : ${pid:-unknown}"
;;
none)
echo "Service : none"
;;
esac
echo "Generate : ${generate_status:-unknown}"
if [ -n "${generate_reason:-}" ]; then
echo "GenReason : $generate_reason"
fi
if [ -n "${config_source:-}" ]; then
echo "ConfigSource : $config_source"
fi
if [ -n "${generate_at:-}" ]; then
echo "GeneratedAt : $generate_at"
fi
if [ -n "${run_status:-}" ]; then
echo "RunStatus : $run_status"
fi
if [ -n "${run_mode:-}" ]; then
echo "RunMode : $run_mode"
fi
if [ -n "${run_pid:-}" ]; then
echo "RunPID : $run_pid"
fi
if [ -n "${run_at:-}" ]; then
echo "RunAt : $run_at"
fi
echo "ProxyPort : ${http_port:-unknown}"
echo "DashPort : ${dashboard_port:-unknown}"
if [ -n "${controller:-}" ]; then
echo "Controller : $controller"
else
echo "Controller : unknown"
fi
if [ -n "${dashboard_url:-}" ]; then
echo "Dashboard : $dashboard_url"
fi
echo "Secret : $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 Doctor ==="
echo "Project : $PROJECT_DIR"
echo "Mode : $mode"
echo "Config : $RUNTIME_CONFIG"
echo
if [ -s "$RUNTIME_CONFIG" ]; then
doctor_ok "runtime config exists: $RUNTIME_CONFIG"
else
doctor_err "runtime config missing or empty: $RUNTIME_CONFIG"
failed=1
fi
if [ -f "$RUNTIME_CONFIG" ]; then
if grep -q '\${' "$RUNTIME_CONFIG"; then
doctor_err "runtime config contains unresolved placeholders"
failed=1
else
doctor_ok "runtime config has no unresolved placeholders"
fi
fi
if [ -f "$STATE_FILE" ]; then
doctor_ok "state file exists: $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 status: success (${last_generate_reason:-unknown})"
else
doctor_warn "last generate status: ${last_generate_status:-unknown} (${last_generate_reason:-unknown})"
warned=1
fi
else
doctor_warn "state file exists but LAST_GENERATE_STATUS is empty"
warned=1
fi
if [ -n "${last_config_source:-}" ]; then
doctor_ok "config source: $last_config_source"
fi
else
doctor_warn "state file missing: $STATE_FILE"
warned=1
fi
case "$mode" in
systemd)
if systemctl is-active --quiet "$SERVICE_NAME"; then
running="yes"
doctor_ok "systemd service is active"
else
doctor_err "systemd service is installed but not active"
failed=1
fi
;;
script)
if is_script_running; then
running="yes"
doctor_ok "script mode process is running (pid=$(read_pid 2>/dev/null || echo unknown))"
else
doctor_err "script mode detected but PID is not running"
failed=1
fi
;;
systemd-installed)
doctor_warn "systemd unit exists but service is not running"
warned=1
;;
none)
doctor_warn "no running instance detected"
warned=1
;;
*)
doctor_err "unknown mode: $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 "external-controller configured: $controller"
else
doctor_warn "external-controller not found in runtime config, fallback assumed: 127.0.0.1:9090"
warned=1
fi
if [ -n "${dashboard_url:-}" ]; then
doctor_ok "dashboard url: $dashboard_url"
else
doctor_warn "dashboard url could not be derived"
warned=1
fi
http_port="$(http_port_from_config)"
if check_port_listening "$http_port"; then
doctor_ok "proxy port is listening: $http_port"
else
rc=$?
if [ "$rc" -eq 2 ]; then
doctor_warn "cannot verify proxy port (ss/netstat not available)"
warned=1
else
if [ "$running" = "yes" ]; then
doctor_err "proxy port is not listening: $http_port"
failed=1
else
doctor_warn "proxy port is not listening: $http_port"
warned=1
fi
fi
fi
if check_port_listening "$dashboard_port"; then
doctor_ok "dashboard port is listening: $dashboard_port"
else
rc=$?
if [ "$rc" -eq 2 ]; then
doctor_warn "cannot verify dashboard port (ss/netstat not available)"
warned=1
else
if [ "$running" = "yes" ]; then
doctor_err "dashboard port is not listening: $dashboard_port"
failed=1
else
doctor_warn "dashboard port is not listening: $dashboard_port"
warned=1
fi
fi
fi
if [ -n "${dashboard_url:-}" ]; then
if check_dashboard_http "$dashboard_url"; then
doctor_ok "dashboard http reachable"
else
rc=$?
if [ "$rc" -eq 2 ]; then
doctor_warn "cannot verify dashboard http (curl/wget not available)"
warned=1
else
doctor_warn "dashboard http not reachable: $dashboard_url"
warned=1
fi
fi
fi
secret="$(read_runtime_config_value "secret" || true)"
if [ -n "${secret:-}" ]; then
doctor_ok "secret exists in runtime config"
else
doctor_warn "secret missing in runtime config"
warned=1
fi
if command_exists clashctl; then
doctor_ok "clashctl command available: $(command -v clashctl)"
else
doctor_warn "clashctl command not found in PATH"
warned=1
fi
if [ -f "$PROFILED_FILE" ]; then
doctor_ok "shell proxy helper exists: $PROFILED_FILE"
else
doctor_warn "shell proxy helper missing: $PROFILED_FILE"
warned=1
fi
echo
if [ "$failed" -ne 0 ]; then
doctor_err "doctor result: FAILED"
return 1
fi
if [ "$warned" -ne 0 ]; then
doctor_warn "doctor result: WARN"
return 0
fi
doctor_ok "doctor result: HEALTHY"
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 "$@"