Files
clash-for-linux/clashctl
2026-03-21 17:01:05 +08:00

1010 lines
23 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] 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 查看当前状态
generate 仅生成配置,不启动
update 拉取当前分支最新代码并重新生成配置、重启服务
update-force 强制覆盖本地修改后更新并重启
mode 查看当前运行模式systemd/script/none
ui 输出 Dashboard 地址
secret 输出当前 secret
doctor 健康检查
logs [-f] [-n 100] 查看日志
update git pull + 重新生成配置并重启
sub show|update 查看订阅地址 / 重新生成配置并重启
tun status|on|off 查看/启用/关闭 Tun
mixin status|on|off 查看/启用/关闭 Mixin
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
sed -nE "s/^[[:space:]]*${key}:[[:space:]]*//p" "$RUNTIME_CONFIG" \
| head -n 1 \
| tr -d '\r' \
| sed -E 's/^"(.*)"$/\1/; s/^'\''(.*)'\''$/\1/'
}
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)"
if [ -n "${controller:-}" ]; then
printf '%s\n' "${controller##*:}"
else
printf '9090\n'
fi
}
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
printf '7890\n'
}
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() {
"$PROJECT_DIR/scripts/generate_config.sh"
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
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"
dirty="$(git -C "$PROJECT_DIR" status --porcelain --untracked-files=no)"
if [ -n "${dirty:-}" ]; then
err "检测到本地已修改但未提交的文件,已停止更新"
echo "请先提交、stash 或恢复后再执行 clashctl update" >&2
exit 1
fi
echo "[INFO] pulling latest code from ${remote_name}/${branch} ..."
if ! git -C "$PROJECT_DIR" pull --ff-only "$remote_name" "$branch"; then
err "git pull 失败"
exit 1
fi
echo "[INFO] regenerating config ..."
if ! "$PROJECT_DIR/scripts/generate_config.sh"; then
err "配置生成失败"
exit 1
fi
echo "[INFO] restarting service ..."
if has_systemd; then
if ! systemctl restart clash-for-linux.service; then
err "systemd 重启失败"
exit 1
fi
else
if ! "$PROJECT_DIR/scripts/run_clash.sh" --daemon; then
err "脚本模式启动失败"
exit 1
fi
fi
ok "更新完成"
cmd_status
}
cmd_update_force() {
local branch remote_name
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"
echo "[WARN] force update: local 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
}
echo "[INFO] regenerating config ..."
if ! "$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
"$PROJECT_DIR/scripts/run_clash.sh" --daemon || {
err "脚本模式启动失败"
exit 1
}
fi
ok "强制更新完成"
cmd_status
}
cmd_restart() {
cmd_generate
cmd_stop "${1:-false}" || true
cmd_start
}
cmd_update() {
git -C "$PROJECT_DIR" pull
cmd_restart
ok "Project updated"
}
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)"
controller="$(read_runtime_config_value "external-controller" || true)"
dashboard_url="$(cmd_ui --raw 2>/dev/null || true)"
http_port="$(http_port_from_config)"
dashboard_port="$(port_from_controller)"
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"
echo "DashPort : $dashboard_port"
if [ -n "${controller:-}" ]; then
echo "Controller : $controller"
else
echo "Controller : 127.0.0.1:9090 (fallback)"
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
}
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)
cmd_update
;;
generate)
cmd_generate
;;
mode)
cmd_mode
;;
ui)
shift || true
cmd_ui "${1:-}"
;;
secret)
cmd_secret
;;
sub)
shift || true
cmd_sub "${1:-show}"
;;
update)
shift
cmd_update "$@"
;;
update-force)
shift
cmd_update_force "$@"
;;
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 "$@"