#!/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/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 "$@"