Files
clash-for-linux/scripts/core/clashctl.sh
T
Arvin 3bcc9fd1a0
Text Encoding / utf8 (push) Successful in 1m4s
Clarify local proxy takeover status
2026-05-15 15:43:39 +08:00

7471 lines
214 KiB
Bash
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
SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
PROJECT_DIR="$(cd "$(dirname "$SCRIPT_PATH")/../.." && pwd)"
source "$PROJECT_DIR/scripts/core/common.sh"
source "$PROJECT_DIR/scripts/core/runtime.sh"
source "$PROJECT_DIR/scripts/core/config.sh"
source "$PROJECT_DIR/scripts/core/completion.sh"
source "$PROJECT_DIR/scripts/core/proxy.sh"
source "$PROJECT_DIR/scripts/core/update.sh"
source "$PROJECT_DIR/scripts/init/systemd.sh"
source "$PROJECT_DIR/scripts/init/systemd-user.sh"
source "$PROJECT_DIR/scripts/init/script.sh"
usage() {
cat <<EOF
🐱 Clash 控制台
Usage:
clashon 🚀 开启代理
clashoff ⛔ 关闭代理
clashctl <command>
🚀 Main Path:
add ➕ 添加订阅
add local ➕ 从 runtime/subscriptions 导入本地订阅
use 💱 切换订阅
select 💫 切换节点
🧩 Config:
config 🧩 配置编译管理
config kernel mihomo|clash 🧩 切换指定内核
mixin 🧩 Mixin 配置管理
relay 🔗 多跳节点管理
lan 🏠 局域网代理管理
📦 Subscription:
config show 📡 查看当前订阅
config regen 🔄 更新当前订阅
ls 📜 查看订阅列表
sub 📡 订阅高级管理(启用 / 禁用 / 重命名 / 删除)
🕹️ Control:
clashui 🕹️ 查看 Web 控制台
secret 🔑 查看或设置 Web 密钥
lan on|off|status 🏠 开启 / 关闭局域网代理
🧪 Transparent proxy:
tun 🧪 Tun 模式管理
tun doctor 🩺 诊断环境与运行状态
tun log/logs 📜 查看日志
🚀 Lifecycle:
boot on|off|status 🚦 管理开机代理接管
boot runtime on|off|status 🚦 仅管理内核开机自启
boot proxy on|off|status 📜 仅管理开机代理保持
upgrade 🚀 升级当前或指定内核
update 🔄 更新项目代码
completion bash|zsh 💡 导出 Shell 补全脚本
dev reset 🧪 恢复到安装前状态(保留项目目录和已下载文件)
🩺 Diagnose:
doctor 🩺 诊断环境与运行状态
status 🔍️ 查看状态总览
log/logs 📜 查看日志
completion 💡 导出 Bash / Zsh 补全脚本
📌 Advanced Examples:
clashctl sub list
clashctl sub enable hk
clashctl sub disable hk
clashctl sub rename hk hk-bak
clashctl sub remove hk
clashctl relay add 多跳-示例 节点A 节点B --domain example.com
clashctl relay list
💡 Notes:
当前编译链固定为 active-only
只处理当前 active 主订阅
Tun 模式属于高级能力,开启前建议先执行:clashctl tun doctor
EOF
}
prepare() {
init_project_context "$PROJECT_DIR"
load_env_if_exists
detect_install_scope auto
}
ensure_add_use_prerequisites() {
if [ ! -x "$(yq_bin)" ]; then
die_state "依赖未就绪:缺少 yq($(yq_bin))" \
"请先执行 bash install.sh,或运行 clashctl doctor 查看缺失项"
fi
if [ ! -d "$RUNTIME_DIR" ]; then
die_state "运行环境未初始化:缺少 runtime 目录" \
"请先执行 bash install.sh"
fi
if [ ! -d "$CONFIG_DIR" ]; then
die_state "运行环境未初始化:缺少 config 目录" \
"请先执行 bash install.sh"
fi
}
ensure_runtime_ports_ready() {
local mixed_port controller_port dns_port
local new_mixed_port="" new_controller_port="" new_dns_port=""
local changed="false"
local repair_detail=""
# 运行中不做端口改写,避免把当前已经跑起来的 runtime 搅乱
if status_is_running 2>/dev/null; then
return 0
fi
# 当前没有 runtime/config.yaml 时,不在这里修;后面 generate_config 会生成
[ -s "$RUNTIME_DIR/config.yaml" ] || return 0
mixed_port="$(runtime_config_mixed_port 2>/dev/null || true)"
controller_port="$(runtime_config_controller_port 2>/dev/null || true)"
dns_port="$(runtime_config_dns_port 2>/dev/null || true)"
if [ -n "${mixed_port:-}" ] && [ "$mixed_port" != "null" ] && is_port_in_use "$mixed_port"; then
new_mixed_port="$(resolve_free_port 7890 7999)"
write_env_value "MIXED_PORT" "$new_mixed_port"
write_runtime_value "INSTALL_PLAN_MIXED_PORT" "$new_mixed_port"
write_runtime_value "INSTALL_PLAN_MIXED_PORT_AUTO_CHANGED" "true"
changed="true"
repair_detail="${repair_detail} mixed-port:${mixed_port}->${new_mixed_port};"
info "检测到 mixed-port 冲突:${mixed_port} -> ${new_mixed_port}"
fi
if [ -n "${controller_port:-}" ] && [ "$controller_port" != "null" ] && is_port_in_use "$controller_port"; then
new_controller_port="$(resolve_free_port 9000 9199)"
write_env_value "EXTERNAL_CONTROLLER" "127.0.0.1:${new_controller_port}"
write_runtime_value "INSTALL_PLAN_CONTROLLER" "127.0.0.1:${new_controller_port}"
write_runtime_value "INSTALL_PLAN_CONTROLLER_AUTO_CHANGED" "true"
changed="true"
repair_detail="${repair_detail} external-controller:${controller_port}->${new_controller_port};"
info "检测到 external-controller 冲突:${controller_port} -> ${new_controller_port}"
fi
if [ -n "${dns_port:-}" ] && [ "$dns_port" != "null" ] && is_port_in_use "$dns_port"; then
new_dns_port="$(resolve_free_port 1053 1999)"
write_env_value "CLASH_DNS_PORT" "$new_dns_port"
write_runtime_value "INSTALL_PLAN_DNS_PORT" "$new_dns_port"
write_runtime_value "INSTALL_PLAN_DNS_PORT_AUTO_CHANGED" "true"
changed="true"
repair_detail="${repair_detail} dns:${dns_port}->${new_dns_port};"
info "检测到 DNS 端口冲突:${dns_port} -> ${new_dns_port}"
fi
if [ "$changed" = "true" ]; then
mark_runtime_port_repair_result "true" "$repair_detail"
info "检测到端口占用,正在自动重建运行配置"
regenerate_config
success "端口冲突已自动修复"
else
mark_runtime_port_repair_result "false" "no-change"
fi
}
ensure_on_path_ready() {
load_system_state
if [ "$SUBSCRIPTION_STATE" = "missing" ]; then
die_state "当前没有可用订阅" "clashctl add <订阅链接>"
fi
if [ ! -s "$RUNTIME_DIR/config.yaml" ]; then
info "检测到运行配置缺失,正在自动生成"
regenerate_config || true
load_system_state
fi
if [ ! -s "$RUNTIME_DIR/config.yaml" ] && [ ! -s "$RUNTIME_DIR/config.last.yaml" ]; then
die_state "当前没有可启动的运行配置" "clashctl doctor"
fi
ensure_runtime_ports_ready
}
print_on_feedback() {
ui_blank
echo "🐱 已开启代理环境"
ui_blank
}
cmd_on() {
local relay_switch
local relay_switch_file relay_err_file relay_rc
local system_proxy_rc system_proxy_degraded="false"
trap 'rc=$?; ui_error "开启代理失败:cmd_on 在第 ${LINENO} 行执行失败:${BASH_COMMAND}(返回码:${rc})"; ui_next "clashctl logs service"; exit "$rc"' ERR
prepare
# fast path 1:代理已经完整开启
if status_is_running 2>/dev/null \
&& proxy_controller_reachable 2>/dev/null \
&& [ "$(system_proxy_status 2>/dev/null || echo off)" = "on" ] \
&& system_proxy_matches_runtime 2>/dev/null; then
print_on_feedback
trap - ERR
return 0
fi
# fast path 2:runtime 已运行,只是系统代理被关了
if status_is_running 2>/dev/null \
&& proxy_controller_reachable 2>/dev/null; then
system_proxy_enable || true
print_on_feedback
trap - ERR
return 0
fi
ensure_on_path_ready
if status_is_running 2>/dev/null && ! proxy_controller_reachable 2>/dev/null; then
ui_warn "检测到内核已运行但控制器不可访问,正在重启以加载当前配置"
service_restart || die_state "控制器启动失败:内核重启未完成" "clashctl logs mihomo"
else
service_start || die_state "控制器启动失败:内核启动未完成" "clashctl logs mihomo"
fi
if ! wait_runtime_controller_ready 8; then
ui_warn "控制器未在预期时间内可访问,正在重启内核重试"
service_restart || die_state "控制器启动失败:内核重启未完成" "clashctl logs mihomo"
if ! wait_runtime_controller_ready 8; then
status_is_running 2>/dev/null || die_state "控制器启动失败:内核未运行" "clashctl logs mihomo"
ui_warn "控制器暂不可访问,继续开启本地代理;请稍后执行 clashctl doctor"
fi
fi
if proxy_controller_reachable 2>/dev/null; then
relay_switch=""
relay_switch_file="$RUNTIME_DIR/.relay-switch.$$"
relay_err_file="$LOG_DIR/relay-switch.err"
mkdir -p "$LOG_DIR"
: > "$relay_err_file"
if ensure_default_proxy_group_relay_selected >"$relay_switch_file" 2>"$relay_err_file"; then
if [ -s "$relay_switch_file" ]; then
IFS= read -r relay_switch < "$relay_switch_file" || relay_switch=""
fi
else
relay_rc=$?
relay_switch=""
fi
rm -f "$relay_switch_file" 2>/dev/null || true
fi
if system_proxy_enable; then
system_proxy_degraded="false"
else
system_proxy_rc=$?
system_proxy_degraded="true"
write_runtime_value "RUNTIME_BOOT_PROXY_KEEP" "false" 2>/dev/null || true
if [ "$system_proxy_rc" -eq 2 ]; then
ui_warn "当前环境不支持系统代理持久接管,仅当前 Shell 生效"
ui_next "开机代理保持不可用;如需持久接管,请使用可写的 $(system_proxy_env_file)"
else
ui_warn "系统代理持久接管写入失败,仅当前 Shell 生效"
ui_next "clashctl doctor"
fi
fi
load_system_state
print_on_feedback
if [ "${CLASH_ALIAS_CALL:-0}" != "1" ]; then
if [ "$system_proxy_degraded" = "true" ]; then
ui_warn "当前通过 clashctl 子进程执行,不能修改当前 Shell 代理变量"
else
ui_warn "当前通过 clashctl 子进程执行,已开启系统代理,但不会修改当前终端的 Shell 变量"
fi
ui_next "当前终端如需立即生效:重新打开终端后使用 clashon,或手动 source shell 入口"
ui_blank
fi
if [ "$RUNTIME_STATE" = "degraded" ]; then
ui_warn "代理内核已启动,但控制器暂不可访问"
ui_next "clashctl doctor"
ui_blank
fi
trap - ERR
return 0
}
cmd_off() {
local system_proxy_rc
prepare
if system_proxy_disable; then
:
else
system_proxy_rc=$?
write_runtime_value "RUNTIME_BOOT_PROXY_KEEP" "false" 2>/dev/null || true
if [ "$system_proxy_rc" -eq 2 ]; then
ui_warn "当前环境不支持清理系统代理持久块,已继续关闭运行时"
else
ui_warn "系统代理持久块清理失败,已继续关闭运行时"
ui_next "clashctl doctor"
fi
fi
ui_blank
echo "🧹 系统代理已关闭"
ui_blank
}
ui_internal_url() {
local controller host port
controller="${1:-}"
[ -n "${controller:-}" ] || controller="$(status_read_controller_raw 2>/dev/null || true)"
[ -n "${controller:-}" ] && [ "$controller" != "null" ] || return 1
host="${controller%:*}"
port="${controller##*:}"
if [ "${host:-}" = "0.0.0.0" ]; then
host="127.0.0.1"
fi
host="$(url_host_bracket_if_needed "$host")"
echo "http://${host}:${port}/ui"
}
ui_public_url() {
local ip port
ip="$(ui_public_ip 2>/dev/null || true)"
port="${1:-}"
[ -n "${port:-}" ] || port="$(ui_controller_port 2>/dev/null || true)"
[ -n "${ip:-}" ] || return 1
[ -n "${port:-}" ] || return 1
ip="$(url_host_bracket_if_needed "$ip")"
echo "http://${ip}:${port}/ui"
}
ui_lan_url() {
local ip port
ip="$(ui_lan_ip 2>/dev/null || true)"
port="${1:-}"
[ -n "${port:-}" ] || port="$(ui_controller_port 2>/dev/null || true)"
[ -n "${ip:-}" ] || return 1
[ -n "${port:-}" ] || return 1
ip="$(url_host_bracket_if_needed "$ip")"
echo "http://${ip}:${port}/ui"
}
cmd_ui() {
local controller_addr="" controller_raw=""
local internal_url="" lan_url="" public_url=""
local current_secret="" controller_port="" controller_status=""
prepare
runtime_config_exists || die "🧩 运行时配置不存在,请先生成配置"
controller_raw="$(status_read_controller_raw 2>/dev/null || true)"
controller_addr="$(display_controller_local_addr "$controller_raw" 2>/dev/null || echo "$controller_raw")"
[ -n "${controller_addr:-}" ] && [ "$controller_addr" != "null" ] || die_state "未解析到控制器地址" "clashctl doctor"
controller_port="${controller_raw##*:}"
internal_url="$(ui_internal_url "$controller_raw" 2>/dev/null || true)"
if controller_externally_reachable 2>/dev/null; then
lan_url="$(ui_lan_url "$controller_port" 2>/dev/null || true)"
public_url="$(ui_public_url "$controller_port" 2>/dev/null || true)"
fi
current_secret="$(controller_secret 2>/dev/null || true)"
if [ -z "${current_secret:-}" ] || [ "$current_secret" = "null" ]; then
current_secret="未设置"
fi
if status_is_running; then
if proxy_controller_reachable 2>/dev/null; then
controller_status="可访问"
else
controller_status="不可访问"
fi
else
controller_status="未运行"
fi
cmd_ui_box \
"$controller_status" \
"$internal_url" \
"$lan_url" \
"$public_url" \
"$current_secret" \
"$controller_port"
cmd_ui_help_summary
if [ "${CLASH_UI_BOX_ONLY:-0}" = "1" ]; then
return 0
fi
case "$controller_status" in
可访问)
return 0
;;
不可访问)
ui_warn "控制器当前不可访问"
ui_next "clashctl doctor"
ui_blank
return 1
;;
未运行)
ui_warn "当前代理未启动"
ui_next "clashon"
ui_blank
return 1
;;
*)
ui_warn "控制器状态未知"
ui_next "clashctl doctor"
ui_blank
return 1
;;
esac
}
status_build_active_source() {
read_build_value "BUILD_ACTIVE_SOURCE" 2>/dev/null || true
}
status_build_active_sources() {
local value
value="$(read_build_value "BUILD_ACTIVE_SOURCES" 2>/dev/null || true)"
if [ -n "${value:-}" ]; then
echo "$value"
return 0
fi
# 兼容历史 build.env
read_build_value "BUILD_INCLUDED_SOURCES" 2>/dev/null || true
}
status_build_failed_active_sources() {
local value
value="$(read_build_value "BUILD_FAILED_ACTIVE_SOURCES" 2>/dev/null || true)"
if [ -n "${value:-}" ]; then
echo "$value"
return 0
fi
# 兼容历史 build.env
read_build_value "BUILD_FAILED_SOURCES" 2>/dev/null || true
}
status_build_last_status() {
read_build_value "BUILD_LAST_STATUS" 2>/dev/null || true
}
status_build_last_time() {
read_build_value "BUILD_LAST_TIME" 2>/dev/null || true
}
status_runtime_last_active_switch_from() {
read_runtime_event_value "RUNTIME_LAST_ACTIVE_SWITCH_FROM" 2>/dev/null || true
}
status_runtime_last_active_switch_to() {
read_runtime_event_value "RUNTIME_LAST_ACTIVE_SWITCH_TO" 2>/dev/null || true
}
status_runtime_last_active_switch_time() {
read_runtime_event_value "RUNTIME_LAST_ACTIVE_SWITCH_TIME" 2>/dev/null || true
}
status_runtime_last_build_block_reason() {
read_runtime_event_value "RUNTIME_LAST_BUILD_BLOCK_REASON" 2>/dev/null || true
}
status_runtime_last_build_block_time() {
read_runtime_event_value "RUNTIME_LAST_BUILD_BLOCK_TIME" 2>/dev/null || true
}
status_runtime_config_source() {
read_runtime_event_value "RUNTIME_LAST_CONFIG_SOURCE" 2>/dev/null || true
}
status_runtime_config_source_time() {
read_runtime_event_value "RUNTIME_LAST_CONFIG_SOURCE_TIME" 2>/dev/null || true
}
status_runtime_build_applied() {
read_runtime_event_value "RUNTIME_LAST_BUILD_APPLIED" 2>/dev/null || true
}
status_runtime_build_applied_time() {
read_runtime_event_value "RUNTIME_LAST_BUILD_APPLIED_TIME" 2>/dev/null || true
}
status_runtime_build_applied_reason() {
read_runtime_event_value "RUNTIME_LAST_BUILD_APPLIED_REASON" 2>/dev/null || true
}
status_tun_enabled() {
tun_enabled 2>/dev/null || echo false
}
status_tun_stack() {
tun_stack 2>/dev/null || echo system
}
status_tun_container_mode() {
tun_container_mode 2>/dev/null || echo unknown
}
status_tun_container_mode_text() {
tun_container_mode_text 2>/dev/null || echo 未知
}
status_tun_kernel_support_level() {
tun_kernel_support_level 2>/dev/null || echo unknown
}
status_tun_kernel_support_text() {
tun_kernel_support_text 2>/dev/null || echo 未知
}
status_tun_last_verify_result() {
read_tun_last_verify_result 2>/dev/null || true
}
status_tun_last_verify_reason() {
read_tun_last_verify_reason 2>/dev/null || true
}
status_tun_last_verify_time() {
read_tun_last_verify_time 2>/dev/null || true
}
status_tun_last_action() {
read_tun_last_action 2>/dev/null || true
}
status_tun_last_action_result() {
read_tun_last_action_result 2>/dev/null || true
}
status_tun_last_action_reason() {
read_tun_last_action_reason 2>/dev/null || true
}
status_tun_last_action_time() {
read_tun_last_action_time 2>/dev/null || true
}
status_tun_effective_status() {
local enabled result
enabled="$(status_tun_enabled)"
if [ "$enabled" != "true" ]; then
echo "off"
return 0
fi
result="$(tun_effective_check 2>/dev/null || true)"
case "${result:-unknown}" in
ok)
echo "effective"
;;
*)
echo "ineffective"
;;
esac
}
status_tun_effective_text() {
local s
s="$(status_tun_effective_status)"
case "$s" in
effective) echo "🐱 已生效" ;;
*) echo "❗ 未生效" ;;
esac
}
system_state_build_status() {
local last_status
local block_reason
last_status="$(status_build_last_status 2>/dev/null || true)"
block_reason="$(status_runtime_last_build_block_reason 2>/dev/null || true)"
if [ -n "${block_reason:-}" ]; then
echo "blocked"
return 0
fi
case "${last_status:-}" in
success) echo "success" ;;
failed) echo "failed" ;;
*) echo "unknown" ;;
esac
}
status_build_effective_status() {
system_state_build_status 2>/dev/null || true
}
runtime_mixed_port_bind_failure_line() {
local log_file="$LOG_DIR/mihomo.out.log"
local mixed_port line
runtime_config_exists 2>/dev/null || return 1
status_is_running 2>/dev/null && return 1
[ -f "$log_file" ] || return 1
mixed_port="$(status_read_mixed_port 2>/dev/null || true)"
if [ -n "${mixed_port:-}" ] && [ "$mixed_port" != "null" ]; then
line="$(grep -Ei "Start Mixed.*server error: listen tcp .*:${mixed_port}:.*(operation not permitted|permission denied|address already in use)" "$log_file" 2>/dev/null | tail -n 1 || true)"
fi
if [ -z "${line:-}" ]; then
line="$(grep -Ei 'Start Mixed.*server error: listen tcp .*:.*(operation not permitted|permission denied|address already in use)' "$log_file" 2>/dev/null | tail -n 1 || true)"
fi
[ -n "${line:-}" ] || return 1
printf '%s\n' "$line"
}
runtime_mixed_port_bind_failure_kind() {
local line lower
line="$(runtime_mixed_port_bind_failure_line 2>/dev/null || true)"
[ -n "${line:-}" ] || return 1
lower="$(printf '%s' "$line" | tr '[:upper:]' '[:lower:]')"
case "$lower" in
*"address already in use"*)
echo "address_in_use"
;;
*"operation not permitted"*|*"permission denied"*)
echo "bind_denied"
;;
*)
echo "bind_failed"
;;
esac
}
runtime_mixed_port_bind_failure_text() {
case "$(runtime_mixed_port_bind_failure_kind 2>/dev/null || true)" in
address_in_use) echo "mixed-port 端口被占用" ;;
bind_denied) echo "mixed-port 绑定被拒绝" ;;
bind_failed) echo "mixed-port 绑定失败" ;;
*) return 1 ;;
esac
}
runtime_mixed_port_bind_failure_port() {
local port line
port="$(status_read_mixed_port 2>/dev/null || true)"
if [ -n "${port:-}" ] && [ "$port" != "null" ]; then
echo "$port"
return 0
fi
line="$(runtime_mixed_port_bind_failure_line 2>/dev/null || true)"
[ -n "${line:-}" ] || return 1
printf '%s\n' "$line" | grep -Eo ':[0-9]+:' | tail -n 1 | tr -d ':'
}
runtime_mihomo_log_port_listened() {
local keyword="$1"
local port="$2"
local log_file="$LOG_DIR/mihomo.out.log"
local line
[ -n "${keyword:-}" ] || return 1
[ -n "${port:-}" ] || return 1
[ -f "$log_file" ] || return 1
line="$(grep -Ei "(${keyword}).*(listen|listening|start|started).*[:.]${port}([^0-9]|$)|[:.]${port}([^0-9]|$).*(listen|listening|start|started).*(${keyword})" "$log_file" 2>/dev/null | tail -n 1 || true)"
[ -n "${line:-}" ]
}
runtime_mixed_port_controller_listening() {
local port
proxy_controller_reachable 2>/dev/null && return 0
port="$(runtime_config_controller_port 2>/dev/null || true)"
[ -n "${port:-}" ] || return 1
runtime_mihomo_log_port_listened "RESTful API|controller" "$port"
}
runtime_mixed_port_dns_listening() {
local port
port="$(runtime_config_dns_port 2>/dev/null || true)"
[ -n "${port:-}" ] || return 1
runtime_mihomo_log_port_listened "DNS|dns" "$port" && return 0
status_is_running 2>/dev/null && is_port_in_use "$port"
}
mixed_port_bind_environment_text() {
local scope backend container
scope="$(install_env_scope 2>/dev/null || true)"
[ -n "${scope:-}" ] || scope="${INSTALL_SCOPE:-unknown}"
backend="$(runtime_backend 2>/dev/null || true)"
[ -n "${backend:-}" ] || backend="$(install_plan_backend 2>/dev/null || true)"
[ -n "${backend:-}" ] || backend="unknown"
container="$(container_env_type 2>/dev/null || true)"
[ -n "${container:-}" ] || container="$(install_env_container 2>/dev/null || true)"
[ -n "${container:-}" ] || container="unknown"
echo "安装范围 ${scope},后端 ${backend},容器环境 ${container}"
}
mixed_port_bind_observation_line() {
local port dns_port
port="$(runtime_mixed_port_bind_failure_port 2>/dev/null || status_read_mixed_port 2>/dev/null || true)"
dns_port="$(runtime_config_dns_port 2>/dev/null || true)"
if runtime_mixed_port_controller_listening 2>/dev/null; then
echo "配置已加载,控制器已启动,仅代理端口 ${port:-unknown} 绑定失败;不是订阅/配置主链失败"
fi
if [ -n "${dns_port:-}" ] && runtime_mixed_port_dns_listening 2>/dev/null; then
echo "DNS 端口 ${dns_port} 已监听,可先聚焦 mixed-port"
fi
}
mixed_port_bind_recommendation_emit() {
local style="$1"
local number="$2"
local text="$3"
case "$style" in
numbered)
echo "${number}. ${text}"
;;
icon)
echo "💡 ${text}"
;;
*)
echo "$text"
;;
esac
}
mixed_port_bind_recommendation_lines() {
local style="${1:-icon}"
local kind port env_text n line
kind="$(runtime_mixed_port_bind_failure_kind 2>/dev/null || true)"
[ -n "${kind:-}" ] || return 1
port="$(runtime_mixed_port_bind_failure_port 2>/dev/null || status_read_mixed_port 2>/dev/null || true)"
env_text="$(mixed_port_bind_environment_text 2>/dev/null || echo "当前环境")"
n=1
while IFS= read -r line; do
[ -n "${line:-}" ] || continue
mixed_port_bind_recommendation_emit "$style" "$n" "$line"
n=$((n + 1))
done <<EOF
$(mixed_port_bind_observation_line 2>/dev/null || true)
EOF
case "$kind" in
bind_denied)
mixed_port_bind_recommendation_emit "$style" "$n" "当前 ${env_text} 对端口 ${port:-unknown} 的可绑定性受限;优先修改 .env 中 MIXED_PORT 后执行 clashctl config regen"
n=$((n + 1))
;;
address_in_use)
mixed_port_bind_recommendation_emit "$style" "$n" "mixed-port ${port:-unknown} 存在端口冲突;先检查占用进程,或修改 .env 中 MIXED_PORT 后执行 clashctl config regen"
n=$((n + 1))
;;
*)
mixed_port_bind_recommendation_emit "$style" "$n" "mixed-port ${port:-unknown} 绑定失败;先查看内核日志确认端口错误"
n=$((n + 1))
;;
esac
mixed_port_bind_recommendation_emit "$style" "$n" "保留日志线索:clashctl logs mihomo"
}
mixed_port_bind_next_action() {
local kind port
kind="$(runtime_mixed_port_bind_failure_kind 2>/dev/null || true)"
port="$(runtime_mixed_port_bind_failure_port 2>/dev/null || status_read_mixed_port 2>/dev/null || true)"
case "$kind" in
bind_denied)
echo "修改 .env 中 MIXED_PORT 后执行 clashctl config regen"
;;
address_in_use)
echo "检查 ${port:-mixed-port} 占用,或修改 .env 中 MIXED_PORT 后执行 clashctl config regen"
;;
bind_failed)
echo "clashctl logs mihomo"
;;
*)
return 1
;;
esac
}
system_state_runtime_status() {
if status_is_running; then
if proxy_controller_reachable 2>/dev/null; then
echo "running"
else
echo "degraded"
fi
else
echo "stopped"
fi
}
system_state_subscription_status() {
local active
active="$(active_subscription_name 2>/dev/null || true)"
if [ -z "${active:-}" ]; then
echo "missing"
return 0
fi
if ! subscription_exists "$active"; then
echo "invalid"
return 0
fi
if ! subscription_enabled "$active"; then
echo "disabled"
return 0
fi
case "$(subscription_health_status "$active" 2>/dev/null || echo unknown)" in
success) echo "healthy" ;;
failed) echo "degraded" ;;
*) echo "unknown" ;;
esac
}
system_state_risk_level() {
local risk
if runtime_mixed_port_bind_failure_kind >/dev/null 2>&1; then
echo "high"
return 0
fi
if status_is_running 2>/dev/null && ! proxy_controller_reachable 2>/dev/null; then
echo "high"
return 0
fi
if ! runtime_config_exists 2>/dev/null; then
echo "high"
return 0
fi
risk="$(calculate_runtime_risk_level 2>/dev/null || true)"
case "${risk:-unknown}" in
low|medium|high|critical)
echo "$risk"
;;
*)
echo "unknown"
;;
esac
}
system_state_summary() {
local runtime_status build_status subscription_status risk_level
local config_source fallback_used build_applied
local tun_enabled tun_effective tun_container_mode tun_kernel_support
local bind_failure_kind overall
runtime_status="$(system_state_runtime_status)"
build_status="$(system_state_build_status)"
subscription_status="$(system_state_subscription_status)"
risk_level="$(system_state_risk_level)"
config_source="$(status_runtime_config_source)"
fallback_used="$(runtime_last_fallback_used 2>/dev/null || true)"
build_applied="$(status_runtime_build_applied)"
bind_failure_kind="$(runtime_mixed_port_bind_failure_kind 2>/dev/null || true)"
tun_enabled="$(status_tun_enabled)"
tun_effective="$(status_tun_effective_status)"
tun_container_mode="$(status_tun_container_mode)"
tun_kernel_support="$(status_tun_kernel_support_level)"
if [ "$runtime_status" = "running" ] \
&& [ "$build_status" = "success" ] \
&& [ "$subscription_status" = "healthy" ]; then
overall="ready"
elif [ "$runtime_status" = "stopped" ] && [ -n "${bind_failure_kind:-}" ]; then
overall="broken"
elif [ "$runtime_status" = "stopped" ]; then
overall="stopped"
elif [ "$build_status" = "failed" ] || [ "$build_status" = "blocked" ] || [ "$subscription_status" = "invalid" ] || [ "$subscription_status" = "missing" ]; then
overall="broken"
else
overall="degraded"
fi
cat <<EOF
SYSTEM_STATE=$overall
RUNTIME_STATE=$runtime_status
BUILD_STATE=$build_status
SUBSCRIPTION_STATE=$subscription_status
RISK_LEVEL=$risk_level
BIND_FAILURE_STATE=${bind_failure_kind:-none}
CONFIG_SOURCE=${config_source:-unknown}
FALLBACK_USED=${fallback_used:-false}
BUILD_APPLIED=${build_applied:-unknown}
TUN_ENABLED=${tun_enabled:-false}
TUN_EFFECTIVE=${tun_effective:-unknown}
TUN_CONTAINER_MODE=${tun_container_mode:-unknown}
TUN_KERNEL_SUPPORT=${tun_kernel_support:-unknown}
EOF
}
load_system_state() {
eval "$(system_state_summary)"
}
system_state_connectivity_text() {
load_system_state
case "$SYSTEM_STATE" in
ready)
echo "可用(本地代理已就绪)"
;;
degraded)
if [ "$RUNTIME_STATE" = "running" ]; then
echo "异常(内核已运行,但部分能力异常)"
else
echo "异常(系统处于降级状态)"
fi
;;
stopped)
echo "未连接(代理未启动)"
;;
broken)
if [ "${BIND_FAILURE_STATE:-none}" != "none" ]; then
echo "不可用(mixed-port 绑定失败)"
else
echo "不可用(配置或订阅异常)"
fi
;;
*)
echo "未知"
;;
esac
}
system_state_risk_text() {
load_system_state
case "$RISK_LEVEL" in
low) echo "🐱 低" ;;
medium) echo "🟡 中" ;;
high) echo "🟠 高" ;;
critical) echo "❗ 严重" ;;
*) echo "⚪ 未知" ;;
esac
}
system_state_default_action() {
load_system_state
case "$SYSTEM_STATE" in
stopped)
if [ "${BIND_FAILURE_STATE:-none}" != "none" ]; then
mixed_port_bind_next_action 2>/dev/null || echo "clashctl logs mihomo"
return 0
fi
if [ "$SUBSCRIPTION_STATE" = "missing" ]; then
echo "clashctl add <订阅链接>"
else
echo "clashon"
fi
;;
broken)
if [ "${BIND_FAILURE_STATE:-none}" != "none" ]; then
mixed_port_bind_next_action 2>/dev/null || echo "clashctl logs mihomo"
return 0
fi
if [ "$SUBSCRIPTION_STATE" = "missing" ]; then
echo "clashctl add <订阅链接>"
else
echo "clashctl doctor"
fi
;;
degraded)
if [ "$RUNTIME_STATE" = "degraded" ]; then
echo "clashctl doctor"
else
echo "clashctl status --verbose"
fi
;;
ready)
echo "clashctl select"
;;
*)
echo "clashctl status --verbose"
;;
esac
}
system_state_problem_lines() {
local mixed_port
load_system_state
mixed_port="$(runtime_mixed_port_bind_failure_port 2>/dev/null || status_read_mixed_port 2>/dev/null || true)"
case "$SYSTEM_STATE" in
ready)
return 0
;;
stopped)
if [ "${BIND_FAILURE_STATE:-none}" != "none" ]; then
echo "• $(runtime_mixed_port_bind_failure_text)${mixed_port:+:${mixed_port}}"
elif [ "$SUBSCRIPTION_STATE" = "missing" ]; then
echo "• 当前没有可用订阅"
else
echo "• 代理内核未启动"
fi
;;
degraded)
[ "$RUNTIME_STATE" = "degraded" ] && echo "• 代理内核已启动,但控制器不可访问"
[ "$SUBSCRIPTION_STATE" = "degraded" ] && echo "• 当前主订阅健康异常"
;;
broken)
[ "${BIND_FAILURE_STATE:-none}" != "none" ] && echo "• $(runtime_mixed_port_bind_failure_text)${mixed_port:+:${mixed_port}}"
[ "$BUILD_STATE" = "failed" ] && echo "• 最近一次编译失败"
[ "$SUBSCRIPTION_STATE" = "missing" ] && echo "• 当前没有主订阅"
[ "$SUBSCRIPTION_STATE" = "invalid" ] && echo "• 当前主订阅无效"
[ "$SUBSCRIPTION_STATE" = "disabled" ] && echo "• 当前主订阅已禁用"
[ "$BUILD_STATE" = "blocked" ] && echo "• 最近一次编译被阻断"
;;
esac
}
system_state_recommendation_lines() {
load_system_state
if [ "${BIND_FAILURE_STATE:-none}" != "none" ]; then
mixed_port_bind_recommendation_lines numbered
return 0
fi
case "$SYSTEM_STATE" in
ready)
echo "1. clashctl select"
echo "2. clashctl ls"
;;
stopped)
if [ "$SUBSCRIPTION_STATE" = "missing" ]; then
echo "1. clashctl add <订阅链接>"
else
echo "1. clashon"
echo "2. clashctl status"
fi
;;
degraded)
echo "1. clashctl doctor"
echo "2. clashctl status --verbose"
;;
broken)
if [ "$SUBSCRIPTION_STATE" = "missing" ]; then
echo "1. clashctl add <订阅链接>"
else
echo "1. clashctl doctor"
echo "2. clashctl ls"
fi
;;
*)
echo "1. clashctl status --verbose"
;;
esac
}
ui_box_width=47
box_text_width() {
local text="${1:-}"
if command -v python3 >/dev/null 2>&1; then
python3 -c 'import sys, unicodedata
s = sys.argv[1]
w = 0
for ch in s:
if unicodedata.combining(ch):
continue
if unicodedata.category(ch)[0] == "C":
continue
w += 2 if unicodedata.east_asian_width(ch) in ("F", "W") else 1
print(w)' "$text" 2>/dev/null && return 0
fi
if command -v python >/dev/null 2>&1; then
python -c 'import sys, unicodedata
s = sys.argv[1]
w = 0
for ch in s:
if unicodedata.combining(ch):
continue
if unicodedata.category(ch)[0] == "C":
continue
w += 2 if unicodedata.east_asian_width(ch) in ("F", "W") else 1
print(w)' "$text" 2>/dev/null && return 0
fi
if command -v od >/dev/null 2>&1 && command -v tr >/dev/null 2>&1; then
local width=0 byte skip=0
while IFS= read -r byte; do
[ -n "${byte:-}" ] || continue
if [ "$skip" -gt 0 ]; then
skip=$((skip - 1))
continue
fi
if [ "$byte" -lt 32 ] || [ "$byte" -eq 127 ]; then
continue
elif [ "$byte" -lt 128 ]; then
width=$((width + 1))
elif [ "$byte" -ge 240 ]; then
width=$((width + 2))
skip=3
elif [ "$byte" -ge 224 ]; then
width=$((width + 2))
skip=2
elif [ "$byte" -ge 192 ]; then
width=$((width + 1))
skip=1
fi
done < <(printf '%s' "$text" | od -An -t u1 | tr -s ' ' '\n')
echo "$width"
return 0
fi
echo "${#text}"
}
compute_box_width() {
local max_len=0
local line line_width
for line in "$@"; do
line_width="$(box_text_width "$line")"
case "${line_width:-}" in
''|*[!0-9]*) line_width="${#line}" ;;
esac
[ "$line_width" -gt "$max_len" ] && max_len="$line_width"
done
ui_box_width=$((max_len + 14))
}
box_border_top() {
local i
printf "╔"
for ((i = 0; i < ui_box_width - 2; i++)); do
printf "═"
done
printf "╗\n"
}
box_border_mid() {
local i
printf "╠"
for ((i = 0; i < ui_box_width - 2; i++)); do
printf "═"
done
printf "╣\n"
}
box_border_bottom() {
local i
printf "╚"
for ((i = 0; i < ui_box_width - 2; i++)); do
printf "═"
done
printf "╝\n"
}
box_center_line() {
local text="$1"
local inner_width=$((ui_box_width - 2))
local text_len
text_len="$(box_text_width "$text")"
local left_pad=$(( (inner_width - text_len) / 2 ))
local right_pad=$(( inner_width - left_pad - text_len ))
printf "║%*s%s%*s║\n" "$left_pad" "" "$text" "$right_pad" ""
}
box_empty() {
printf "║%*s║\n" $((ui_box_width-2)) ""
}
box_section_line() {
local text="$1"
local inner_width=$((ui_box_width - 2))
local content=" $text"
local content_len
content_len="$(box_text_width "$content")"
local right_pad=$((inner_width - content_len))
[ "$right_pad" -lt 0 ] && right_pad=0
printf "║%s%*s║\n" "$content" "$right_pad" ""
}
box_title_line() {
box_center_line "$1"
}
status_is_running() {
local backend pid
backend="$(runtime_backend)"
case "$backend" in
systemd)
systemctl is-active --quiet "$(service_unit_name)"
;;
systemd-user)
systemctl --user is-active --quiet "$(service_unit_name)"
;;
script)
if [ -f "$RUNTIME_DIR/mihomo.pid" ]; then
pid="$(cat "$RUNTIME_DIR/mihomo.pid" 2>/dev/null || true)"
[ -n "${pid:-}" ] && kill -0 "$pid" 2>/dev/null
else
return 1
fi
;;
*)
return 1
;;
esac
}
apply_runtime_change_after_config_mutation() {
if status_is_running; then
info "检测到代理正在运行,正在自动重启以应用新配置"
service_restart
success "新配置已自动重启生效"
else
info "新配置已写入,当前代理未运行,下次启动自动生效"
fi
}
print_config_regen_feedback() {
echo
echo "🧩 配置已重新生成"
echo
}
print_config_apply_feedback() {
local build_status build_applied config_source next_action
build_status="$(status_build_effective_status 2>/dev/null || true)"
build_applied="$(status_runtime_build_applied 2>/dev/null || true)"
config_source="$(status_runtime_config_source 2>/dev/null || true)"
next_action="$(system_state_default_action 2>/dev/null || echo 'clashctl status')"
echo
echo "🧩 配置变更已处理"
case "${build_status:-unknown}" in
success)
echo "🐱 构建结果:success"
;;
failed)
echo "❗ 构建结果:failed"
;;
blocked)
echo "🚨 构建结果:blocked"
;;
*)
echo "⚪ 构建结果:unknown"
;;
esac
case "${build_applied:-unknown}" in
true)
echo "🐱 是否应用:true"
;;
false)
echo "🚨 是否应用:false"
;;
*)
echo "⚪ 是否应用:unknown"
;;
esac
echo "🧩 配置来源:${config_source:-unknown}"
echo "👉 下一步:$next_action"
echo
}
main_feedback_runtime_state() {
if status_is_running; then
ui_kv "🐱" "运行状态" "已运行"
else
ui_kv "❗" "运行状态" "未运行"
fi
}
main_feedback_build_mode() {
ui_kv "🧩" "编译模式" "active-only"
}
main_feedback_active_subscription() {
local active
active="$(active_subscription_name 2>/dev/null || true)"
if [ -n "${active:-}" ]; then
ui_kv "📡" "当前订阅" "$active"
fi
}
main_feedback_subscription_selected_state() {
local name="$1"
local active
[ -n "${name:-}" ] || return 0
active="$(active_subscription_name 2>/dev/null || true)"
if [ "$name" = "$active" ]; then
echo "✅ 参与状态:当前主订阅"
else
echo "📦 参与状态:已保存,但当前未启用"
fi
}
print_add_feedback() {
local name="$1"
local url="${2:-}"
echo "✔ 已添加订阅并设为当前主订阅:$name"
[ -n "${url:-}" ] && echo "📡 URL:$url"
echo "📋 最新订阅列表(clashctl ls):"
}
print_use_context() {
local active recommended
active="$(active_subscription_name 2>/dev/null || true)"
recommended="$(recommended_subscription_name 2>/dev/null || true)"
ui_title "🔁 切换当前订阅"
if [ -n "${active:-}" ]; then
ui_kv "🚩" "当前主订阅" "$active"
else
ui_kv "🚩" "当前主订阅" "未设置"
fi
if [ -n "${recommended:-}" ] && [ "${recommended:-}" != "${active:-}" ]; then
ui_kv "💡" "推荐订阅" "$recommended"
else
ui_kv "💡" "推荐订阅" "保持当前"
fi
ui_blank
}
print_use_feedback() {
local name="$1" health fail_count enabled_text
ui_title "🔁 订阅切换完成"
if [ -n "${name:-}" ]; then
if subscription_enabled "$name"; then
enabled_text="enabled"
else
enabled_text="disabled"
fi
health="$(subscription_health_status "$name" 2>/dev/null || echo "unknown")"
fail_count="$(subscription_fail_count "$name" 2>/dev/null || echo "0")"
ui_kv "🚩" "当前主订阅" "$name"
ui_kv "❤️" "订阅状态" "$enabled_text / $health / fail=$fail_count"
fi
main_feedback_build_mode
main_feedback_runtime_state
ui_next "clashctl select 选择节点"
ui_blank
}
print_select_context() {
local current_proxy
if ! status_is_running; then
ui_kv "❗" "代理状态" "未运行"
ui_next "clashon"
ui_blank
return 1
fi
if ! proxy_controller_reachable 2>/dev/null; then
ui_kv "❗" "当前节点" "控制器不可访问"
ui_next "clashctl doctor"
ui_blank
return 1
fi
if [ "$(proxy_group_count 2>/dev/null || echo 0)" -le 0 ]; then
ui_kv "❗" "当前节点" "暂无可切换策略组"
ui_next "clashctl status --verbose"
ui_blank
return 1
fi
current_proxy="$(status_current_proxy_brief 2>/dev/null || true)"
if [ -n "${current_proxy:-}" ]; then
ui_kv "🚀" "当前节点" "$current_proxy"
else
ui_kv "🚀" "当前节点" "未知"
fi
ui_next "请选择要切换的策略组和节点"
ui_blank
return 0
}
print_select_feedback() {
local group="$1"
local current
current="$(proxy_group_current_display "$group" 2>/dev/null || true)"
ui_title "🚀 节点切换完成"
if [ -n "${group:-}" ]; then
ui_kv "📦" "已切换策略组" "$group"
fi
if [ -n "${current:-}" ]; then
ui_kv "🚀" "当前节点" "$current"
fi
main_feedback_runtime_state
ui_next "clashctl status"
ui_blank
}
print_sub_enable_feedback() {
local name="$1"
local health fail_count
health="$(subscription_health_status "$name" 2>/dev/null || echo "unknown")"
fail_count="$(subscription_fail_count "$name" 2>/dev/null || echo "0")"
ui_title "📡 订阅已启用"
ui_kv "📡" "订阅名称" "$name"
ui_kv "❤️" "当前健康" "$health / fail=$fail_count"
main_feedback_build_mode
main_feedback_runtime_state
ui_next "clashctl health ${name}"
ui_blank
}
print_sub_disable_feedback() {
local name="$1"
local active
active="$(active_subscription_name 2>/dev/null || true)"
ui_title "📡 订阅已禁用"
ui_kv "📡" "订阅名称" "$name"
if [ "$name" = "$active" ]; then
ui_kv "🚨" "当前主订阅" "已被禁用"
ui_next "clashctl use"
else
ui_kv "🧩" "当前主订阅" "${active:-未设置}"
ui_next "clashctl status"
fi
main_feedback_build_mode
main_feedback_runtime_state
ui_blank
}
print_sub_rename_feedback() {
local old_name="$1"
local new_name="$2"
ui_title "📡 订阅已重命名"
ui_kv "📡" "原名称" "$old_name"
ui_kv "📡" "新名称" "$new_name"
main_feedback_build_mode
main_feedback_runtime_state
ui_next "clashctl ls"
ui_blank
}
print_sub_remove_feedback() {
local name="$1"
local active
active="$(active_subscription_name 2>/dev/null || true)"
ui_title "📡 订阅已删除"
ui_kv "📡" "已删除" "$name"
ui_kv "🚩" "当前主订阅" "${active:-未设置}"
main_feedback_build_mode
main_feedback_runtime_state
ui_next "clashctl ls"
ui_blank
}
print_config_kernel_feedback() {
local kernel="$1"
ui_title "🚀 运行内核已切换"
ui_kv "🚀" "当前内核" "$kernel"
main_feedback_runtime_state
ui_next "clashctl status"
ui_blank
}
tun_container_runtime_hint_lines() {
local env_type os_variant cap_check printed="false"
env_type="$(container_env_type 2>/dev/null || echo unknown)"
os_variant="$(install_env_os_variant 2>/dev/null || true)"
cap_check="$(has_cap_net_admin; echo $?)"
if [ "$env_type" = "docker" ]; then
if ! tun_device_exists 2>/dev/null || [ "$cap_check" != "0" ]; then
echo "👉 当前问题来自容器启动参数;仅在容器内执行 sudo 或切换到 root,无法补齐缺失的 device / capability"
printed="true"
fi
fi
if ! tun_device_exists 2>/dev/null; then
if [ "$env_type" = "docker" ]; then
echo "👉 请重新创建 Docker 容器并挂载 Tun 设备:--device /dev/net/tun"
else
echo "👉 请在容器启动时映射 Tun 设备;Docker 示例:--device /dev/net/tun"
fi
printed="true"
fi
case "$cap_check" in
0)
;;
2)
if [ "$env_type" = "docker" ]; then
echo "👉 当前容器内缺少 capsh,无法确认 CAP_NET_ADMIN;请重点检查启动参数是否包含:--cap-add NET_ADMIN"
else
echo "👉 无法确认 CAP_NET_ADMIN(缺少 capsh);请检查容器启动参数是否授予了该 capability"
fi
printed="true"
;;
*)
if [ "$env_type" = "docker" ]; then
echo "👉 请重新创建 Docker 容器并增加能力:--cap-add NET_ADMIN"
else
echo "👉 请在容器启动时授予网络管理能力;Docker 示例:--cap-add NET_ADMIN"
fi
printed="true"
;;
esac
if ! has_ip_command 2>/dev/null; then
case "$os_variant" in
openwrt)
echo "👉 当前环境缺少 ip 命令;OpenWrt 可安装:opkg update && opkg install ip-full"
;;
*)
echo "👉 当前环境缺少 ip 命令;Debian/Ubuntu 可安装:apt update && apt install -y iproute2"
echo "👉 其他发行版请安装提供 ip 命令的 iproute / iproute2 软件包"
;;
esac
printed="true"
fi
[ "$printed" = "true" ]
}
print_tun_container_gate_feedback() {
local mode="$1"
local reason="${2:-}"
echo
ui_title "🧪 Tun 裁决"
ui_kv "🚀" "当前内核" "$(runtime_kernel_type 2>/dev/null || echo unknown)"
ui_kv "🧩" "Tun 支持等级" "$(tun_kernel_support_text 2>/dev/null || echo 未知)"
case "$mode" in
host)
ui_kv "💻" "环境模式" "主机环境"
ui_kv "🐱" "容器裁决" "允许正常开启"
;;
container-safe)
ui_kv "💻" "环境模式" "容器环境"
ui_kv "🚨" "容器裁决" "允许开启,但属于保守通过"
[ -n "${reason:-}" ] && ui_kv "🚨" "注意事项" "$reason"
;;
container-risky)
ui_kv "💻" "环境模式" "容器环境"
ui_kv "❗" "容器裁决" "高风险,已阻断开启"
[ -n "${reason:-}" ] && ui_kv "❗" "阻断原因" "$reason"
tun_container_runtime_hint_lines | sed 's/^/ /' || true
ui_next "查看完整诊断:clashctl tun doctor"
ui_blank
;;
*)
ui_kv "⚪" "容器裁决" "未知"
[ -n "${reason:-}" ] && ui_kv "🚨" "原因" "$reason"
;;
esac
if ! tun_kernel_is_recommended 2>/dev/null; then
ui_warn "$(tun_kernel_support_reason 2>/dev/null || echo '当前内核不适合作为 Tun 主支持内核')"
ui_next "如需最稳妥 Tun 体验,先执行:clashctl config kernel mihomo"
fi
if [ "$mode" != "container-risky" ]; then
ui_next "clashctl tun doctor"
ui_blank
fi
}
print_tun_on_feedback() {
local verify_result kernel stack auto_route auto_redirect
local status_icon status_text detail
verify_result="$1"
kernel="$(runtime_kernel_type 2>/dev/null || echo unknown)"
stack="$(runtime_config_tun_stack 2>/dev/null || tun_stack 2>/dev/null || echo unknown)"
auto_route="$(runtime_config_tun_auto_route 2>/dev/null || echo false)"
auto_redirect="$(runtime_config_tun_auto_redirect 2>/dev/null || echo false)"
case "$verify_result" in
ok)
status_icon="🐱"
status_text="已生效"
detail="已通过 Tun 即时验证"
;;
policy-routing-likely-effective)
status_icon="🐱"
status_text="已生效"
if tun_log_tun_source_line >/dev/null 2>&1; then
detail="已检测到 Tun 适配器、policy routing 与 Tun 源地址流量"
else
detail="已检测到 Tun 适配器与 policy routing,建议用 tun doctor 查看完整证据"
fi
;;
disabled-in-state|disabled-in-runtime-config|tun-disabled|runtime-not-running|controller-unreachable)
status_icon="❗"
status_text="未生效"
detail="即时验证未通过:$verify_result"
;;
*)
status_icon="🟡"
status_text="已开启,仍在确认"
detail="Tun 已开启,但即时验证仍需结合完整证据确认:$verify_result"
;;
esac
ui_title "🧪 Tun 模式已开启"
ui_kv "🚀" "当前内核" "${kernel:-unknown}"
ui_kv "🔧 " "Tun stack" "${stack:-unknown}"
ui_kv "📜" "auto-route" "${auto_route:-false}"
ui_kv "📜" "auto-redirect" "${auto_redirect:-false}"
ui_kv "$status_icon" "当前状态" "$status_text"
echo "💡 $detail"
ui_next "查看完整证据:clashctl tun doctor"
ui_blank
}
print_tun_off_feedback() {
local verify_result route_dev
verify_result="$1"
route_dev="$(default_route_dev 2>/dev/null || true)"
ui_title "🧪 Tun 模式已关闭"
ui_kv "🧪" "目标状态" "关闭"
case "$verify_result" in
ok)
ui_kv "🐱" "验证结果" "Tun 已关闭并完成回滚检查"
[ -n "${route_dev:-}" ] && ui_kv "🌐" "当前默认路由设备" "$route_dev"
;;
*)
ui_kv "🚨" "验证结果" "Tun 关闭后仍存在残留或运行异常"
ui_kv "🚨" "原因" "$verify_result"
;;
esac
ui_next "clashctl tun doctor"
ui_blank
}
tun_on_verify_result() {
local result auto_redirect
result="${1:-unknown}"
case "$result" in
ok)
echo "ok"
return 0
;;
disabled-in-state|disabled-in-runtime-config|tun-disabled|runtime-not-running|controller-unreachable)
echo "$result"
return 0
;;
esac
auto_redirect="$(runtime_config_tun_auto_redirect 2>/dev/null || echo false)"
if [ "${auto_redirect:-false}" = "true" ] && tun_has_policy_routing_evidence 2>/dev/null; then
echo "policy-routing-likely-effective"
return 0
fi
echo "$result"
}
status_subscription_health_summary() {
local active health fail_count auto_disabled
active="$(active_subscription_name 2>/dev/null || true)"
[ -n "${active:-}" ] || return 0
health="$(subscription_health_status "$active" 2>/dev/null || echo "unknown")"
fail_count="$(subscription_fail_count "$active" 2>/dev/null || echo "0")"
if subscription_auto_disabled "$active"; then
auto_disabled="yes"
else
auto_disabled="no"
fi
echo "❤️ 健康状态:$health"
echo "🚨 失败次数:$fail_count"
echo "🚨 阈值命中:$auto_disabled"
if [ -n "$(subscription_last_success "$active" 2>/dev/null || true)" ]; then
echo "🕒 最近成功:$(subscription_last_success "$active" 2>/dev/null || true)"
fi
if [ -n "$(subscription_last_failure "$active" 2>/dev/null || true)" ]; then
echo "🕒 最近失败:$(subscription_last_failure "$active" 2>/dev/null || true)"
fi
}
status_proxy_summary_lines() {
print_proxy_groups_status 2>/dev/null | head -n 5 || true
}
status_user_risk_text() {
system_state_risk_text
}
status_risk_reason_lines() {
local build_status active controller_ok
local last_risk_name last_risk_reason last_risk_fail_count last_risk_threshold
local build_block_reason build_block_time
local fallback_used fallback_time fallback_reason
local tun_enabled tun_effective tun_container_mode tun_kernel_support tun_verify_reason
local tun_action_result tun_action_reason tun_action_time
local bind_failure_text mixed_port
build_status="$(status_build_last_status 2>/dev/null || true)"
active="$(active_subscription_name 2>/dev/null || true)"
controller_ok="false"
proxy_controller_reachable 2>/dev/null && controller_ok="true"
bind_failure_text="$(runtime_mixed_port_bind_failure_text 2>/dev/null || true)"
mixed_port="$(runtime_mixed_port_bind_failure_port 2>/dev/null || status_read_mixed_port 2>/dev/null || true)"
build_block_reason="$(status_runtime_last_build_block_reason 2>/dev/null || true)"
build_block_time="$(status_runtime_last_build_block_time 2>/dev/null || true)"
fallback_used="$(runtime_last_fallback_used 2>/dev/null || true)"
fallback_time="$(runtime_last_fallback_time 2>/dev/null || true)"
fallback_reason="$(runtime_last_fallback_reason 2>/dev/null || true)"
tun_enabled="$(status_tun_enabled 2>/dev/null || echo false)"
tun_effective="$(status_tun_effective_status 2>/dev/null || echo unknown)"
tun_container_mode="$(status_tun_container_mode 2>/dev/null || echo unknown)"
tun_kernel_support="$(status_tun_kernel_support_level 2>/dev/null || echo unknown)"
tun_verify_reason="$(status_tun_last_verify_reason 2>/dev/null || true)"
tun_action_result="$(status_tun_last_action_result 2>/dev/null || true)"
tun_action_reason="$(status_tun_last_action_reason 2>/dev/null || true)"
tun_action_time="$(status_tun_last_action_time 2>/dev/null || true)"
if ! status_is_running; then
if [ -n "${bind_failure_text:-}" ]; then
echo "• ${bind_failure_text}${mixed_port:+:${mixed_port}}"
mixed_port_bind_observation_line | sed 's/^/• /'
else
echo "• 代理内核未启动"
fi
fi
if [ "$controller_ok" = "false" ] && status_is_running; then
echo "• 控制器不可访问"
fi
if [ "${build_status:-}" = "failed" ]; then
echo "• 最近一次编译失败"
fi
if [ -n "${build_block_reason:-}" ]; then
if [ -n "${build_block_time:-}" ]; then
echo "• 最近一次编译被阻断:${build_block_reason} @ ${build_block_time}"
else
echo "• 最近一次编译被阻断:${build_block_reason}"
fi
fi
if [ "${fallback_used:-false}" = "true" ]; then
if [ -n "${fallback_time:-}" ]; then
echo "• 最近一次启动触发配置回退:${fallback_time}"
else
echo "• 最近一次启动触发配置回退"
fi
[ -n "${fallback_reason:-}" ] && echo "• 回退原因:${fallback_reason}"
fi
if [ -n "${active:-}" ] && ! active_subscription_enabled 2>/dev/null; then
echo "• 当前主订阅已禁用或不可用"
fi
last_risk_name="$(read_runtime_event_value "RUNTIME_LAST_SUBSCRIPTION_RISK_NAME" 2>/dev/null || true)"
last_risk_reason="$(read_runtime_event_value "RUNTIME_LAST_SUBSCRIPTION_RISK_REASON" 2>/dev/null || true)"
last_risk_fail_count="$(read_runtime_event_value "RUNTIME_LAST_SUBSCRIPTION_RISK_FAIL_COUNT" 2>/dev/null || true)"
last_risk_threshold="$(read_runtime_event_value "RUNTIME_LAST_SUBSCRIPTION_RISK_THRESHOLD" 2>/dev/null || true)"
if [ -n "${last_risk_name:-}" ] && [ "${last_risk_reason:-}" = "fail-threshold-reached" ]; then
echo "• 订阅 ${last_risk_name} 连续失败 ${last_risk_fail_count:-?} 次(阈值 ${last_risk_threshold:-?})"
fi
if [ "${tun_action_result:-}" = "failed" ]; then
if [ -n "${tun_action_time:-}" ]; then
echo "• 最近一次 Tun 配置同步失败:${tun_action_reason:-unknown} @ ${tun_action_time}"
else
echo "• 最近一次 Tun 配置同步失败:${tun_action_reason:-unknown}"
fi
fi
if [ "$tun_enabled" = "true" ] && [ "$tun_effective" != "effective" ]; then
echo "• Tun 未生效"
fi
if [ "$tun_container_mode" = "container-risky" ]; then
echo "• 当前 Tun 运行环境属于高风险容器场景"
fi
if [ "$tun_enabled" = "true" ] && [ "$tun_kernel_support" = "limited" ]; then
echo "• 当前 Tun 运行在 clash 内核上,仅按降级支持处理"
fi
}
status_recommendation_lines() {
system_state_recommendation_lines
}
status_current_proxy_brief() {
local default_group default_current fallback_group fallback_current
local bind_failure_text
if ! status_is_running; then
bind_failure_text="$(runtime_mixed_port_bind_failure_text 2>/dev/null || true)"
if [ -n "${bind_failure_text:-}" ]; then
echo "$bind_failure_text"
return 0
fi
echo "未启动"
return 0
fi
if ! proxy_controller_reachable 2>/dev/null; then
echo "控制器不可访问"
return 0
fi
default_group="$(default_proxy_group_name 2>/dev/null || true)"
default_current="$(default_proxy_group_current 2>/dev/null || true)"
if [ -n "${default_group:-}" ]; then
[ -n "${default_current:-}" ] || default_current="<unknown>"
echo "${default_group} -> ${default_current}"
return 0
fi
fallback_group="$(proxy_group_list 2>/dev/null | sed -n '1p')"
if [ -n "${fallback_group:-}" ]; then
fallback_current="$(proxy_group_current_display "$fallback_group" 2>/dev/null || true)"
[ -n "${fallback_current:-}" ] || fallback_current="<unknown>"
echo "${fallback_group} -> ${fallback_current}"
return 0
fi
echo "暂无可切换策略组"
}
system_proxy_supported_state() {
if system_proxy_supported; then
echo "true"
else
echo "false"
fi
}
persistent_system_proxy_text() {
local status
status="$(system_proxy_status 2>/dev/null || echo off)"
if [ "$status" = "on" ]; then
if system_proxy_matches_runtime 2>/dev/null; then
echo "on"
else
echo "mismatch"
fi
return 0
fi
if ! system_proxy_supported; then
echo "unsupported"
return 0
fi
echo "off"
}
status_local_proxy_http_url() {
local port
port="$(status_read_mixed_port 2>/dev/null || true)"
[ -n "${port:-}" ] && [ "$port" != "null" ] || return 1
echo "http://127.0.0.1:${port}"
}
current_process_proxy_env_text() {
local expected value
expected="$(status_local_proxy_http_url 2>/dev/null || true)"
[ -n "${expected:-}" ] || {
echo "unknown"
return 0
}
value="${http_proxy:-${HTTP_PROXY:-${https_proxy:-${HTTPS_PROXY:-}}}}"
[ -n "${value:-}" ] || value="${all_proxy:-${ALL_PROXY:-}}"
if [ -z "${value:-}" ]; then
echo "off"
elif [ "$value" = "$expected" ]; then
echo "on"
else
echo "mismatch"
fi
}
connectivity_issue_code() {
local active group_count
local bind_failure_kind
if ! status_is_running; then
bind_failure_kind="$(runtime_mixed_port_bind_failure_kind 2>/dev/null || true)"
case "${bind_failure_kind:-}" in
bind_denied)
echo "mixed_port_bind_denied"
return 0
;;
address_in_use)
echo "mixed_port_address_in_use"
return 0
;;
bind_failed)
echo "mixed_port_bind_failed"
return 0
;;
esac
echo "runtime_stopped"
return 0
fi
if ! proxy_controller_reachable 2>/dev/null; then
echo "controller_unreachable"
return 0
fi
case "$(system_state_build_status 2>/dev/null || echo unknown)" in
failed|blocked)
echo "config_invalid"
return 0
;;
esac
case "$(system_state_subscription_status 2>/dev/null || echo unknown)" in
missing|invalid|disabled|degraded)
echo "subscription_unhealthy"
return 0
;;
esac
group_count="$(proxy_group_count 2>/dev/null || echo 0)"
case "${group_count:-0}" in
''|*[!0-9]*) group_count=0 ;;
esac
if [ "$group_count" -le 0 ]; then
echo "proxy_control_broken"
return 0
fi
case "$(persistent_system_proxy_text)" in
on) echo "local_proxy_ready_system_proxy_on" ;;
unsupported) echo "local_proxy_ready_system_proxy_unsupported" ;;
mismatch) echo "local_proxy_ready_system_proxy_mismatch" ;;
off) echo "local_proxy_ready_system_proxy_off" ;;
*) echo "local_proxy_ready_system_proxy_off" ;;
esac
}
connectivity_issue_text() {
local issue port
issue="$(connectivity_issue_code)"
port="$(runtime_mixed_port_bind_failure_port 2>/dev/null || status_read_mixed_port 2>/dev/null || true)"
case "$issue" in
ok|local_proxy_ready_system_proxy_on) echo "可用(本地代理已就绪,系统代理已接管)" ;;
mixed_port_bind_denied) echo "不可用(mixed-port ${port:-unknown} 绑定被拒绝)" ;;
mixed_port_address_in_use) echo "不可用(mixed-port ${port:-unknown} 端口被占用)" ;;
mixed_port_bind_failed) echo "不可用(mixed-port ${port:-unknown} 绑定失败)" ;;
runtime_stopped) echo "不可用(代理内核未启动)" ;;
controller_unreachable) echo "异常(内核已运行,但控制器不可访问)" ;;
config_invalid) echo "异常(当前运行配置不可用)" ;;
subscription_unhealthy) echo "异常(当前主订阅不可用)" ;;
proxy_control_broken) echo "异常(当前无可用策略组或节点控制面异常)" ;;
system_proxy_unsupported|local_proxy_ready_system_proxy_unsupported) echo "本地代理可用,系统流量未自动接管(系统代理持久写入不可用)" ;;
system_proxy_off|local_proxy_ready_system_proxy_off) echo "本地代理可用,系统流量未自动接管(系统代理未开启)" ;;
system_proxy_mismatch|local_proxy_ready_system_proxy_mismatch) echo "本地代理可用,但系统代理端口与当前运行端口不一致" ;;
*) echo "未知" ;;
esac
}
connectivity_next_action() {
case "$(connectivity_issue_code)" in
ok|local_proxy_ready_system_proxy_on)
echo "clashctl select"
;;
runtime_stopped)
echo "clashon"
;;
mixed_port_bind_denied|mixed_port_address_in_use|mixed_port_bind_failed)
mixed_port_bind_next_action 2>/dev/null || echo "clashctl logs mihomo"
;;
controller_unreachable)
echo "clashctl doctor"
;;
config_invalid)
echo "clashctl doctor"
;;
subscription_unhealthy)
echo "clashctl ls"
;;
proxy_control_broken)
echo "clashctl status --verbose"
;;
system_proxy_unsupported|local_proxy_ready_system_proxy_unsupported)
echo "浏览器/应用手动设置代理,或在当前 Shell 使用 clashon"
;;
system_proxy_off|local_proxy_ready_system_proxy_off)
echo "浏览器/应用手动设置代理,或在当前 Shell 使用 clashon"
;;
system_proxy_mismatch|local_proxy_ready_system_proxy_mismatch)
echo "clashoff && clashon"
;;
*)
echo "clashctl doctor"
;;
esac
}
connectivity_evidence_lines() {
local runtime_running controller_ok build_status subscription_status
local group_count expected_proxy actual_proxy active config_source
local system_proxy_state system_proxy_supported_text process_proxy_env mixed_port local_proxy_listening
local bind_failure_kind bind_failure_line
if status_is_running; then
runtime_running="true"
else
runtime_running="false"
fi
if proxy_controller_reachable 2>/dev/null; then
controller_ok="true"
else
controller_ok="false"
fi
build_status="$(system_state_build_status 2>/dev/null || echo unknown)"
subscription_status="$(system_state_subscription_status 2>/dev/null || echo unknown)"
group_count="$(proxy_group_count 2>/dev/null || echo 0)"
active="$(active_subscription_name 2>/dev/null || true)"
config_source="$(status_runtime_config_source 2>/dev/null || true)"
expected_proxy="$(status_local_proxy_http_url 2>/dev/null || true)"
mixed_port="$(status_read_mixed_port 2>/dev/null || true)"
local_proxy_listening="false"
if [ -n "${mixed_port:-}" ] && [ "$mixed_port" != "null" ] && is_port_in_use "$mixed_port"; then
local_proxy_listening="true"
fi
actual_proxy="$(system_proxy_http_value 2>/dev/null || true)"
system_proxy_state="$(persistent_system_proxy_text)"
system_proxy_supported_text="$(system_proxy_supported_state)"
process_proxy_env="$(current_process_proxy_env_text)"
bind_failure_kind="$(runtime_mixed_port_bind_failure_kind 2>/dev/null || true)"
bind_failure_line="$(runtime_mixed_port_bind_failure_line 2>/dev/null || true)"
case "${group_count:-0}" in
''|*[!0-9]*) group_count=0 ;;
esac
echo "• runtime_running = ${runtime_running:-false}"
echo "• controller_reachable = ${controller_ok:-false}"
[ -n "${bind_failure_kind:-}" ] && echo "• mixed_port_bind_failure = ${bind_failure_kind}"
[ -n "${bind_failure_line:-}" ] && echo "• mixed_port_bind_log = ${bind_failure_line}"
echo "• build_status = ${build_status:-unknown}"
echo "• subscription_status = ${subscription_status:-unknown}"
echo "• active_subscription = ${active:-unset}"
echo "• config_source = ${config_source:-unknown}"
echo "• proxy_group_count = ${group_count:-0}"
echo "• local_proxy_listening = ${local_proxy_listening:-false}"
[ -n "${expected_proxy:-}" ] && echo "• local_proxy_http = $expected_proxy"
echo "• system_proxy_persistent_supported = ${system_proxy_supported_text:-false}"
echo "• system_proxy_persistent_status = ${system_proxy_state:-off}"
echo "• current_process_proxy_env = ${process_proxy_env:-unknown}"
echo "• runtime_backend = $(runtime_backend 2>/dev/null || echo unknown)"
echo "• boot_runtime_autostart = $(status_service_autostart_text)"
echo "• boot_proxy_keep = $(status_boot_proxy_keep_text)"
[ -n "${actual_proxy:-}" ] && echo "• system_proxy_http = ${actual_proxy}"
echo "• tun_enabled = $(status_tun_enabled 2>/dev/null || echo false)"
echo "• tun_effective = $(status_tun_effective_status 2>/dev/null || echo unknown)"
echo "• tun_container_mode = $(status_tun_container_mode 2>/dev/null || echo unknown)"
echo "• tun_kernel_support = $(status_tun_kernel_support_level 2>/dev/null || echo unknown)"
}
status_runtime_backend_text() {
local backend
backend="$(runtime_backend 2>/dev/null || true)"
[ -n "${backend:-}" ] || backend="$(install_plan_backend 2>/dev/null || true)"
case "${backend:-unknown}" in
systemd) echo "systemd" ;;
systemd-user) echo "systemd-user" ;;
script) echo "script" ;;
*) echo "unknown" ;;
esac
}
status_runtime_backend_reason_text() {
local backend container_type
backend="$(runtime_backend 2>/dev/null || echo unknown)"
container_type="$(install_env_container 2>/dev/null || echo unknown)"
case "$backend" in
systemd)
echo "root + systemd 可用"
;;
systemd-user)
echo "普通用户 + user systemd 可用"
;;
script)
if [ "$container_type" != "host" ] && [ -n "${container_type:-}" ]; then
echo "容器环境默认回退 script"
else
echo "systemd 不可用,自动回退 script"
fi
;;
*)
echo "未知"
;;
esac
}
status_service_autostart_text() {
service_autostart_status 2>/dev/null || echo "unknown"
}
status_boot_proxy_keep_text() {
boot_proxy_keep_status 2>/dev/null || echo "unknown"
}
status_boot_auto_proxy_text() {
local runtime_autostart proxy_keep
runtime_autostart="$(status_service_autostart_text)"
proxy_keep="$(status_boot_proxy_keep_text)"
if [ "$runtime_autostart" = "on" ] && [ "$proxy_keep" = "on" ]; then
echo "on"
else
echo "off"
fi
}
status_boot_boundary_text() {
local backend proxy_keep text
backend="$(runtime_backend 2>/dev/null || echo unknown)"
proxy_keep="$(status_boot_proxy_keep_text)"
case "$backend" in
systemd)
text="systemd 支持内核开机自启;开机代理保持由 /etc/environment 的代理块决定"
;;
systemd-user)
text="systemd-user 支持用户登录后自启;开机代理保持仍由 /etc/environment 的代理块决定"
;;
script)
text="script 后端不支持内核开机自启;只能查看或清理系统代理持久块"
;;
*)
text="未知后端,无法确认开机自启边界"
;;
esac
if [ "$proxy_keep" = "unsupported" ]; then
text="${text};当前环境不可写系统代理持久文件"
fi
echo "$text"
}
status_container_mode_text() {
local container_mode env_container
container_mode="$(install_plan_container_mode 2>/dev/null || true)"
env_container="$(install_env_container 2>/dev/null || true)"
if [ "${container_mode:-}" = "true" ]; then
echo "容器兼容模式"
return 0
fi
case "${env_container:-host}" in
host) echo "主机模式" ;;
"") echo "未知" ;;
*) echo "容器环境(未启用兼容模式)" ;;
esac
}
status_install_verify_brief() {
local runtime_ready controller_ready
local live_runtime="false"
local live_controller="false"
runtime_ready="$(install_verify_runtime_ready 2>/dev/null || true)"
controller_ready="$(install_verify_controller_ready 2>/dev/null || true)"
if status_is_running 2>/dev/null; then
live_runtime="true"
fi
if proxy_controller_reachable 2>/dev/null; then
live_controller="true"
fi
if [ "$live_runtime" = "true" ] && [ "$live_controller" = "true" ]; then
echo "安装验证通过"
return 0
fi
if [ "$runtime_ready" = "true" ] && [ "$controller_ready" = "true" ]; then
echo "安装验证通过"
return 0
fi
if [ "$live_runtime" = "true" ] && [ "$live_controller" != "true" ]; then
echo "运行已就绪,控制器未就绪"
return 0
fi
if [ "$runtime_ready" = "true" ] && [ "$controller_ready" != "true" ]; then
echo "运行已就绪,控制器未就绪"
return 0
fi
if [ "$live_runtime" != "true" ] && [ "$live_controller" = "true" ]; then
echo "控制器异常记录"
return 0
fi
if [ "$runtime_ready" != "true" ] && [ "$controller_ready" = "true" ]; then
echo "控制器异常记录"
return 0
fi
echo "安装验证未完成"
}
status_port_adjustment_brief() {
local changed=0
[ "$(install_plan_mixed_port_auto_changed 2>/dev/null || echo false)" = "true" ] && changed=1
[ "$(install_plan_controller_auto_changed 2>/dev/null || echo false)" = "true" ] && changed=1
[ "$(install_plan_dns_port_auto_changed 2>/dev/null || echo false)" = "true" ] && changed=1
if [ "$changed" -eq 1 ]; then
echo "安装期发生过端口自动避让"
else
echo "安装期端口保持默认"
fi
}
print_status_summary_compact() {
local profile mixed_port controller controller_lan controller_public
local allow_lan lan_proxy
local running_text user_connectivity user_risk current_proxy_brief system_proxy_text
local current_active dashboard_text dashboard_source_text dashboard_policy_text secret_text
local tun_text bind_failure_text next_action
profile="$(show_active_profile 2>/dev/null || true)"
[ -n "${profile:-}" ] || profile="default"
mixed_port="$(status_read_mixed_port 2>/dev/null || true)"
allow_lan="$(runtime_config_allow_lan 2>/dev/null || config_allow_lan 2>/dev/null || echo true)"
controller="$(status_read_controller 2>/dev/null || true)"
controller_lan="$(status_read_controller_lan 2>/dev/null || true)"
controller_public="$(status_read_controller_public 2>/dev/null || true)"
current_active="$(active_subscription_name 2>/dev/null || true)"
tun_text="$(status_tun_effective_text)"
bind_failure_text="$(runtime_mixed_port_bind_failure_text 2>/dev/null || true)"
if status_is_running; then
running_text="🐱 已开启"
elif [ -n "${bind_failure_text:-}" ]; then
running_text="❗ ${bind_failure_text}"
else
running_text="❗ 未开启"
fi
user_connectivity="$(connectivity_issue_text)"
user_risk="$(status_user_risk_text)"
current_proxy_brief="$(status_current_proxy_brief)"
next_action="$(connectivity_next_action 2>/dev/null || system_state_default_action 2>/dev/null || echo 'clashctl status --verbose')"
system_proxy_text="$(persistent_system_proxy_text 2>/dev/null || echo unknown)"
if [ -f "$(runtime_dashboard_dir)/index.html" ]; then
dashboard_text="已部署"
else
dashboard_text="未部署"
fi
dashboard_source_text="$(read_runtime_value "DASHBOARD_ASSET_SOURCE" 2>/dev/null || echo none)"
case "${dashboard_source_text:-none}" in
dir|zip|none) ;;
*) dashboard_source_text="none" ;;
esac
if [ "$dashboard_source_text" = "none" ]; then
dashboard_policy_text="默认策略:Dashboard 资产无效将阻断 install/update"
else
dashboard_policy_text="默认策略:Dashboard 资产需保持可部署"
fi
if [ -n "$(read_env_value "CLASH_CONTROLLER_SECRET" 2>/dev/null || true)" ]; then
secret_text="已设置"
else
secret_text="未设置"
fi
echo
echo "🐱 Clash 状态总览"
echo
echo "【当前结果】"
echo "🐱 代理状态:$running_text"
echo "🌐 当前可用性:$user_connectivity"
echo "📡 当前订阅:${current_active:-未设置}"
echo "🚀 当前节点:$current_proxy_brief"
echo "🚨 当前风险:$user_risk"
if [ "$next_action" = "clashctl select" ]; then
echo "👉 clashctl select 切换节点"
else
echo "👉 下一步:$next_action"
fi
echo
echo "【核心入口】"
echo "🔧 Profile:$profile"
echo "🔧 运行后端:$(status_runtime_backend_text)"
echo "🚦 内核开机自启:$(status_service_autostart_text)"
echo "📜 开机代理保持:$(status_boot_proxy_keep_text)"
echo "🐱 开机代理接管:$(status_boot_auto_proxy_text)"
echo "🧪 环境模式:$(status_container_mode_text)"
echo "🧪 Tun 状态:${tun_text:-未知}"
echo "📜 系统代理持久接管:${system_proxy_text}"
echo "🧩 Dashboard:${dashboard_text}(来源:${dashboard_source_text})"
echo "🧩 Dashboard 策略:${dashboard_policy_text}"
echo "🔐 控制器密钥:${secret_text}"
if [ -n "${mixed_port:-}" ] && [ "$mixed_port" != "null" ]; then
echo "🌐 本地代理:http://127.0.0.1:${mixed_port}"
if [ "$allow_lan" = "true" ]; then
lan_proxy="$(ui_lan_ip 2>/dev/null || true)"
[ -n "${lan_proxy:-}" ] && echo "🏠 局域网代理:http://${lan_proxy}:${mixed_port}"
else
echo "🏠 局域网代理:已关闭"
fi
else
echo "🌐 本地代理:未知"
fi
if [ -n "${controller:-}" ] && [ "$controller" != "null" ]; then
echo "🐱 控制台:http://${controller}/ui"
[ -n "${controller_lan:-}" ] && echo "🏠 局域网:http://${controller_lan}/ui"
if [ -n "${controller_public:-}" ]; then
echo "🌍 公网:http://${controller_public}/ui"
else
echo "🌍 公网:需公网 IP / 端口映射后可访问"
fi
else
echo "🐱 控制台:未知"
fi
echo
echo "🧩 安装验证:$(status_install_verify_brief)"
echo "💡 更多细节:clashctl status --verbose"
echo
}
print_status_summary_verbose() {
local running_text profile mixed_port controller controller_lan controller_public
local allow_lan lan_proxy
local current_active build_active_sources build_failed_active_sources build_status build_time
local build_block_reason build_block_time
local last_switch_from last_switch_to last_switch_time
local controller_ok current_proxy_lines
local user_connectivity user_risk current_proxy_brief
local fallback_used fallback_time fallback_reason
local config_source config_source_time build_applied build_applied_time build_applied_reason
local install_backend_text install_container_text install_verify_text port_adjustment_text
local tun_enabled tun_effective tun_stack tun_container_text tun_kernel_text tun_verify_result tun_verify_reason tun_verify_time
local system_proxy_text dashboard_text dashboard_source_text dashboard_policy_text secret_text
local bind_failure_text next_action
profile="$(show_active_profile 2>/dev/null || true)"
[ -n "${profile:-}" ] || profile="default"
mixed_port="$(status_read_mixed_port 2>/dev/null || true)"
allow_lan="$(runtime_config_allow_lan 2>/dev/null || config_allow_lan 2>/dev/null || echo true)"
controller="$(status_read_controller 2>/dev/null || true)"
controller_lan="$(status_read_controller_lan 2>/dev/null || true)"
controller_public="$(status_read_controller_public 2>/dev/null || true)"
current_active="$(active_subscription_name 2>/dev/null || true)"
build_active_sources="$(status_build_active_sources)"
build_failed_active_sources="$(status_build_failed_active_sources)"
build_status="$(status_build_last_status)"
build_time="$(status_build_last_time)"
build_block_reason="$(status_runtime_last_build_block_reason)"
build_block_time="$(status_runtime_last_build_block_time)"
last_switch_from="$(status_runtime_last_active_switch_from)"
last_switch_to="$(status_runtime_last_active_switch_to)"
last_switch_time="$(status_runtime_last_active_switch_time)"
fallback_used="$(runtime_last_fallback_used 2>/dev/null || true)"
fallback_time="$(runtime_last_fallback_time 2>/dev/null || true)"
fallback_reason="$(runtime_last_fallback_reason 2>/dev/null || true)"
config_source="$(status_runtime_config_source 2>/dev/null || true)"
config_source_time="$(status_runtime_config_source_time 2>/dev/null || true)"
build_applied="$(status_runtime_build_applied 2>/dev/null || true)"
build_applied_time="$(status_runtime_build_applied_time 2>/dev/null || true)"
build_applied_reason="$(status_runtime_build_applied_reason 2>/dev/null || true)"
bind_failure_text="$(runtime_mixed_port_bind_failure_text 2>/dev/null || true)"
if status_is_running; then
running_text="🐱 已开启"
elif [ -n "${bind_failure_text:-}" ]; then
running_text="❗ ${bind_failure_text}"
else
running_text="❗ 未开启"
fi
controller_ok="false"
current_proxy_lines=""
if status_is_running && proxy_controller_reachable 2>/dev/null; then
controller_ok="true"
current_proxy_lines="$(status_proxy_summary_lines)"
fi
user_connectivity="$(connectivity_issue_text)"
user_risk="$(status_user_risk_text)"
current_proxy_brief="$(status_current_proxy_brief)"
next_action="$(connectivity_next_action 2>/dev/null || system_state_default_action 2>/dev/null || echo 'clashctl status --verbose')"
install_backend_text="$(status_runtime_backend_text)"
install_container_text="$(status_container_mode_text)"
install_verify_text="$(status_install_verify_brief)"
port_adjustment_text="$(status_port_adjustment_brief)"
tun_enabled="$(status_tun_enabled 2>/dev/null || echo false)"
tun_effective="$(status_tun_effective_text 2>/dev/null || echo 未知)"
tun_stack="$(status_tun_stack 2>/dev/null || echo system)"
tun_container_text="$(status_tun_container_mode_text 2>/dev/null || echo 未知)"
tun_kernel_text="$(status_tun_kernel_support_text 2>/dev/null || echo 未知)"
tun_verify_result="$(status_tun_last_verify_result 2>/dev/null || true)"
tun_verify_reason="$(status_tun_last_verify_reason 2>/dev/null || true)"
tun_verify_time="$(status_tun_last_verify_time 2>/dev/null || true)"
system_proxy_text="$(persistent_system_proxy_text 2>/dev/null || echo unknown)"
if [ -f "$(runtime_dashboard_dir)/index.html" ]; then
dashboard_text="已部署"
else
dashboard_text="未部署"
fi
dashboard_source_text="$(read_runtime_value "DASHBOARD_ASSET_SOURCE" 2>/dev/null || echo none)"
case "${dashboard_source_text:-none}" in
dir|zip|none) ;;
*) dashboard_source_text="none" ;;
esac
if [ "$dashboard_source_text" = "none" ]; then
dashboard_policy_text="默认策略:Dashboard 资产无效将阻断 install/update"
else
dashboard_policy_text="默认策略:Dashboard 资产需保持可部署"
fi
if [ -n "$(read_env_value "CLASH_CONTROLLER_SECRET" 2>/dev/null || true)" ]; then
secret_text="已设置"
else
secret_text="未设置"
fi
echo
echo "🐱 Clash 状态总览"
echo
echo "【当前结果】"
echo "🐱 代理状态:$running_text"
echo "🌐 当前可用性:$user_connectivity"
echo "📡 当前订阅:${current_active:-未设置}"
echo "🚀 当前节点:$current_proxy_brief"
echo "🚨 当前风险:$user_risk"
if [ "$next_action" = "clashctl select" ]; then
echo "👉 clashctl select 切换节点"
else
echo "👉 下一步:$next_action"
fi
echo
echo "【核心入口】"
echo "🔧 Profile:$profile"
if [ -n "${mixed_port:-}" ] && [ "$mixed_port" != "null" ]; then
echo "🌐 本地代理:http://127.0.0.1:${mixed_port}"
if [ "$allow_lan" = "true" ]; then
lan_proxy="$(ui_lan_ip 2>/dev/null || true)"
[ -n "${lan_proxy:-}" ] && echo "🏠 局域网代理:http://${lan_proxy}:${mixed_port}"
else
echo "🏠 局域网代理:已关闭"
fi
else
echo "🌐 本地代理:未知"
fi
if [ -n "${controller:-}" ] && [ "$controller" != "null" ]; then
echo "🐱 控制台:http://${controller}/ui"
[ -n "${controller_lan:-}" ] && echo "🏠 局域网:http://${controller_lan}/ui"
if [ -n "${controller_public:-}" ]; then
echo "🌍 公网:http://${controller_public}/ui"
else
echo "🌍 公网:需公网 IP / 端口映射后可访问"
fi
else
echo "🐱 控制台:未知"
fi
echo
echo "【安装上下文】"
echo "🔧 运行后端:${install_backend_text:-unknown}"
echo "💡 后端原因:$(status_runtime_backend_reason_text 2>/dev/null || echo unknown)"
echo "🚦 内核开机自启:$(status_service_autostart_text)"
echo "📜 开机代理保持:$(status_boot_proxy_keep_text)"
echo "🐱 开机代理接管:$(status_boot_auto_proxy_text)"
echo "💡 开机边界:$(status_boot_boundary_text)"
echo "🧪 环境模式:${install_container_text:-unknown}"
echo "🧩 安装验证:${install_verify_text:-unknown}"
echo "📜 端口裁决:${port_adjustment_text:-unknown}"
echo "📜 系统代理持久接管:${system_proxy_text}"
echo "🧩 Dashboard:${dashboard_text}(来源:${dashboard_source_text})"
echo "🧩 Dashboard 策略:${dashboard_policy_text}"
echo "🔐 控制器密钥:${secret_text}"
echo
if [ -n "$(install_plan_controller 2>/dev/null || true)" ]; then
echo "🐱 安装期控制器:$(display_controller_local_addr "$(install_plan_controller 2>/dev/null || true)" 2>/dev/null || install_plan_controller 2>/dev/null || true)"
fi
if [ -n "$(install_plan_mixed_port 2>/dev/null || true)" ]; then
echo "🌐 安装期代理端口:$(install_plan_mixed_port 2>/dev/null || true)"
fi
echo
echo "【Tun 状态】"
if [ "$tun_enabled" = "true" ]; then
echo "🐱 Tun 开关:已开启"
else
echo "❗ Tun 开关:未开启"
fi
echo "🧪 Tun 生效:${tun_effective:-未知}"
echo "🔧 Tun stack:${tun_stack:-unknown}"
echo "🐱 容器裁决:${tun_container_text:-未知}"
echo "🚀 内核支持:${tun_kernel_text:-未知}"
echo
echo "【编译结果】"
echo "🧩 编译模式:active-only"
[ -n "$(status_build_active_source 2>/dev/null || true)" ] && echo "📦 编译主订阅:$(status_build_active_source 2>/dev/null || true)"
if [ -n "${build_status:-}" ]; then
echo "🧩 最近编译:${build_status} @ ${build_time:-unknown}"
else
echo "🧩 最近编译:未知"
fi
[ -n "${build_active_sources:-}" ] && echo "🐱 实际参与编译:$build_active_sources"
[ -n "${build_failed_active_sources:-}" ] && echo "❌ 编译失败源:$build_failed_active_sources"
echo
echo "【当前订阅健康】"
if status_subscription_health_summary | grep -q .; then
status_subscription_health_summary | sed 's/^/ /'
else
echo " 暂无健康数据"
fi
echo
echo "【系统事件】"
echo "🧩 当前配置来源:${config_source:-unknown}${config_source_time:+ @ ${config_source_time}}"
case "${build_applied:-}" in
true)
echo "🐱 最近构建应用:true @ ${build_applied_time:-unknown}"
;;
false)
echo "🚨 最近构建应用:false @ ${build_applied_time:-unknown}"
[ -n "${build_applied_reason:-}" ] && echo "未应用原因:${build_applied_reason}"
;;
*)
echo "⚪ 最近构建应用:unknown"
;;
esac
if [ -n "${build_block_reason:-}" ]; then
echo "🚨 最近阻断:${build_block_reason} @ ${build_block_time:-unknown}"
else
echo "🚨 最近阻断:无"
fi
if [ -n "${last_switch_to:-}" ]; then
echo "🤖 订阅切换建议记录:${last_switch_from:-unknown} -> ${last_switch_to} @ ${last_switch_time:-unknown}"
else
echo "🤖 订阅切换建议记录:无"
fi
if [ "${fallback_used:-false}" = "true" ]; then
echo "🚨 最近回退:true @ ${fallback_time:-unknown}"
[ -n "${fallback_reason:-}" ] && echo "回退原因:${fallback_reason}"
else
echo "🚨 最近回退:false"
fi
echo
echo "【风险原因】"
if status_risk_reason_lines | grep -q .; then
status_risk_reason_lines | sed 's/^/ /'
else
echo " 无明显风险原因"
fi
echo
echo "【网络闭环】"
echo "📜 问题裁决:$(connectivity_issue_text)"
echo "🔍 关键证据:"
connectivity_evidence_lines | sed 's/^/ /'
echo
echo "【策略组摘要】"
if [ "$controller_ok" = "true" ] && [ -n "${current_proxy_lines:-}" ]; then
printf '%s\n' "$current_proxy_lines" | sed 's/^/ /'
else
echo " 控制器不可访问"
fi
echo
}
cmd_status() {
prepare
case "${1:-}" in
--verbose|-v)
print_status_summary_verbose
;;
"")
print_status_summary_compact
;;
*)
die_usage "未知的 status 参数:$1" "clashctl status [--verbose]"
;;
esac
}
cmd_status_next() {
prepare
system_state_default_action
}
boot_usage() {
cat <<EOF
📜 用法:
clashctl boot status
clashctl boot on
clashctl boot off
clashctl boot runtime on|off|status
clashctl boot proxy on|off|status
EOF
}
print_boot_status() {
ui_title "🚦 开机代理接管"
ui_kv "🔧" "运行后端" "$(runtime_backend 2>/dev/null || echo unknown)"
ui_kv "🚦" "内核开机自启" "$(status_service_autostart_text)"
ui_kv "📜" "开机代理保持" "$(status_boot_proxy_keep_text)"
ui_kv "🐱" "开机代理接管" "$(status_boot_auto_proxy_text)"
ui_kv "💡" "后端边界" "$(status_boot_boundary_text)"
ui_blank
}
cmd_boot_runtime() {
case "${1:-status}" in
on)
if ! service_autostart_supported; then
die_state "当前后端不支持内核开机自启:$(runtime_backend 2>/dev/null || echo unknown)" \
"如需开机自启,请使用 systemd / systemd-user 后端"
fi
service_autostart_enable || die_state "内核开机自启开启失败" "clashctl doctor"
success "内核开机自启已开启"
;;
off)
if ! service_autostart_supported; then
write_runtime_value "RUNTIME_BOOT_AUTOSTART" "false"
write_runtime_value "RUNTIME_BOOT_AUTOSTART_EXPLICIT" "true"
ui_warn "当前后端不支持内核开机自启,已记录为关闭:$(runtime_backend 2>/dev/null || echo unknown)"
else
service_autostart_disable || die_state "内核开机自启关闭失败" "clashctl doctor"
success "内核开机自启已关闭"
fi
;;
status)
echo "$(status_service_autostart_text)"
return 0
;;
*)
die_usage "未知的 boot runtime 参数:$1" "clashctl boot runtime on|off|status"
;;
esac
print_boot_status
}
cmd_boot_proxy() {
local backend
backend="$(runtime_backend 2>/dev/null || echo unknown)"
case "${1:-status}" in
on)
if [ "$backend" = "script" ]; then
ui_warn "script 后端不会开机启动内核;仅保持系统代理变量可能在重启后指向未运行的本地端口"
fi
boot_proxy_keep_enable || die_state "开机代理保持开启失败:当前环境不支持写入 $(system_proxy_env_file)" \
"请检查权限,或执行 clashctl doctor"
success "开机代理保持已开启"
;;
off)
boot_proxy_keep_disable || die_state "开机代理保持关闭失败:无法清理 $(system_proxy_env_file)" \
"请检查权限,或执行 clashctl doctor"
success "开机代理保持已关闭"
;;
status)
echo "$(status_boot_proxy_keep_text)"
return 0
;;
*)
die_usage "未知的 boot proxy 参数:$1" "clashctl boot proxy on|off|status"
;;
esac
print_boot_status
}
cmd_boot() {
prepare
case "${1:-status}" in
on)
if ! service_autostart_supported; then
die_state "当前后端不支持开机自动进入代理状态:$(runtime_backend 2>/dev/null || echo unknown)" \
"script 后端只能手动启动;可执行 clashctl boot proxy off 清理开机代理保持"
fi
boot_proxy_keep_enable || die_state "开机代理保持开启失败:当前环境不支持写入 $(system_proxy_env_file)" \
"请检查权限,或执行 clashctl doctor"
if ! service_autostart_enable; then
boot_proxy_keep_disable >/dev/null 2>&1 || true
die_state "内核开机自启开启失败" "clashctl doctor"
fi
success "开机代理接管已开启"
print_boot_status
;;
off)
if service_autostart_supported; then
service_autostart_disable || die_state "内核开机自启关闭失败" "clashctl doctor"
else
write_runtime_value "RUNTIME_BOOT_AUTOSTART" "false"
write_runtime_value "RUNTIME_BOOT_AUTOSTART_EXPLICIT" "true"
ui_warn "当前后端不支持内核开机自启,跳过服务 disable:$(runtime_backend 2>/dev/null || echo unknown)"
fi
boot_proxy_keep_disable || die_state "开机代理保持关闭失败:无法清理 $(system_proxy_env_file)" \
"请检查权限,或执行 clashctl doctor"
success "开机代理接管已关闭"
print_boot_status
;;
status)
print_boot_status
;;
runtime)
shift || true
cmd_boot_runtime "$@"
;;
proxy)
shift || true
cmd_boot_proxy "$@"
;;
-h|--help|help)
boot_usage
;;
*)
die_usage "未知的 boot 子命令:$1" "clashctl boot on|off|status"
;;
esac
}
url_host_bracket_if_needed() {
local host="$1"
[ -n "${host:-}" ] || return 1
case "$host" in
\[*\])
echo "$host"
;;
*:*)
echo "[$host]"
;;
*)
echo "$host"
;;
esac
}
url_host_bracket_if_needed() {
local host="$1"
[ -n "${host:-}" ] || return 1
case "$host" in
\[*\])
echo "$host"
;;
*:*)
echo "[$host]"
;;
*)
echo "$host"
;;
esac
}
ui_controller_port() {
local controller
controller="$(status_read_controller_raw 2>/dev/null || true)"
[ -n "${controller:-}" ] && [ "$controller" != "null" ] || return 1
echo "${controller##*:}"
}
ui_lan_ip() {
ip route get 1.1.1.1 2>/dev/null | awk '
{
for (i = 1; i <= NF; i++) {
if ($i == "src") {
print $(i+1)
exit
}
}
}
'
}
ui_public_ip() {
local ip=""
ip="$(
env \
-u http_proxy \
-u https_proxy \
-u HTTP_PROXY \
-u HTTPS_PROXY \
-u all_proxy \
-u ALL_PROXY \
curl -4 -fsSL --connect-timeout 2 --max-time 3 https://api.ipify.org 2>/dev/null || true
)"
if [ -n "${ip:-}" ]; then
echo "$ip"
return 0
fi
ip="$(
env \
-u http_proxy \
-u https_proxy \
-u HTTP_PROXY \
-u HTTPS_PROXY \
-u all_proxy \
-u ALL_PROXY \
curl -6 -fsSL --connect-timeout 2 --max-time 3 https://api64.ipify.org 2>/dev/null || true
)"
[ -n "${ip:-}" ] && echo "$ip"
}
cmd_ui_box() {
local line0="" line1="" line2="" line3="" line4="" line5=""
local secret="${5:-}"
line0="🔓 注意放行端口:${6:-unknown}"
[ -n "${1:-}" ] && line1="📶 状态:$1"
[ -n "${3:-}" ] && line2="🏠 内网:$3"
line3="📡 公共:http://board.zash.run.place"
[ -n "${4:-}" ] && line4="🌏 公网:$4"
[ -n "${secret:-}" ] && line5="🔑 密钥:$secret"
compute_box_width \
"🐱 Web 控制台" \
"$line0" "$line1" "$line2" "$line3" "$line4" "$line5"
echo
box_border_top
box_title_line "🐱 Web 控制台"
box_border_mid
box_empty
[ -n "$line0" ] && box_section_line "$line0"
[ -n "$line1" ] && box_section_line "$line1"
[ -n "$line2" ] && box_section_line "$line2"
[ -n "$line3" ] && box_section_line "$line3"
[ -n "$line4" ] && box_section_line "$line4"
[ -n "$line5" ] && box_section_line "$line5"
box_empty
box_border_bottom
echo
}
cmd_ui_help_summary() {
echo "〽️ 常用命令"
printf ' %-18s %s\n' "clashon" "🚀 开启代理"
printf ' %-18s %s\n' "clashoff" "⛔ 关闭代理"
printf ' %-18s %s\n' "clashctl select" "💫 选择节点"
echo "🕹️ 控制台"
printf ' %-18s %s\n' "clashui" "🕹️ 查看 Web 控制台"
printf ' %-18s %s\n' "clashsecret" "🔑 查看或设置 Web 密钥"
echo "📦 订阅"
printf ' %-18s %s\n' "clashctl add" "➕ 添加订阅"
printf ' %-18s %s\n' "clashctl add local" "➕ 从 runtime/subscriptions 导入本地订阅"
printf ' %-18s %s\n' "clashctl use" "💱 切换订阅"
printf ' %-18s %s\n' "clashctl ls" "📜 查看订阅列表"
echo "📌 高级"
printf ' %-18s %s\n' "clashctl lan" "🏠 局域网代理管理"
printf ' %-18s %s\n' "clashctl tun" "🧪 Tun 模式管理"
printf ' %-18s %s\n' "clashctl mixin" "🧩 Mixin 配置管理"
printf ' %-18s %s\n' "clashctl sub" "🧩 订阅高级管理(启用 / 禁用 / 重命名 / 删除)"
printf ' %-18s %s\n' "clashctl upgrade" "🚀 升级当前或指定内核"
printf ' %-18s %s\n' "clashctl update" "🔄 更新项目代码"
printf ' %-18s %s\n' "clashctl completion" "💡 导出 Bash / Zsh 补全脚本"
echo "📜 日志"
printf ' %-18s %s\n' "clashctl doctor" "🩺 诊断面板"
printf ' %-18s %s\n' "clashctl log/logs" "📜 查看日志"
echo
echo "💡 显示更多帮助命令:clashctl -h"
}
logs_mihomo() {
if [ ! -f "$LOG_DIR/mihomo.out.log" ]; then
echo "Mihomo 日志文件不存在"
return 0
fi
tail -n 200 "$LOG_DIR/mihomo.out.log"
}
logs_subconverter() {
local file
file="$(subconverter_log_file)"
if [ ! -f "$file" ]; then
echo "subconverter 日志文件不存在"
return 0
fi
tail -n 200 "$file"
}
logs_service() {
local backend
backend="$(runtime_backend)"
case "$backend" in
systemd)
systemd_service_logs
;;
systemd-user)
systemd_user_service_logs
;;
script)
echo "当前为脚本运行模式,服务日志即 Mihomo 日志"
logs_mihomo
;;
*)
die "未知运行后端:$backend"
;;
esac
}
cmd_logs() {
prepare
case "${1:-mihomo}" in
mihomo)
logs_mihomo
;;
subconverter)
logs_subconverter
;;
service)
logs_service
;;
*)
die "用法:clashctl log|logs [mihomo|subconverter|service]"
;;
esac
}
doctor_print_title() {
echo
echo "🧰【$1】"
}
doctor_ok() {
echo " ✔ $1"
}
doctor_warn() {
echo " ⚠ $1"
}
doctor_fail() {
echo " ✘ $1"
}
doctor_yq_available() {
local yq_path
yq_path="$(yq_bin 2>/dev/null || true)"
[ -n "${yq_path:-}" ] && [ -x "$yq_path" ]
}
doctor_warn_skip_yq_parse() {
doctor_warn "缺少 yq,跳过配置解析类检查"
}
doctor_install_context() {
doctor_print_title "安装环境检查"
if [ -n "$(install_env_os 2>/dev/null || true)" ]; then
doctor_ok "操作系统:$(install_env_os 2>/dev/null || echo unknown)"
else
doctor_warn "未记录安装期操作系统"
fi
if [ -n "$(install_env_arch 2>/dev/null || true)" ]; then
doctor_ok "系统架构:$(install_env_arch 2>/dev/null || echo unknown)"
else
doctor_warn "未记录安装期系统架构"
fi
if [ -n "$(install_env_scope 2>/dev/null || true)" ]; then
doctor_ok "安装范围:$(install_env_scope 2>/dev/null || echo unknown)"
else
doctor_warn "未记录安装范围"
fi
if [ -n "$(install_env_container 2>/dev/null || true)" ]; then
doctor_ok "容器环境:$(install_env_container 2>/dev/null || echo unknown)"
else
doctor_warn "未记录容器环境"
fi
if [ -n "$(install_plan_backend 2>/dev/null || true)" ]; then
doctor_ok "安装期选择后端:$(install_plan_backend 2>/dev/null || echo unknown)"
else
doctor_warn "未记录安装期运行后端"
fi
if [ -n "$(install_plan_container_mode 2>/dev/null || true)" ]; then
doctor_ok "容器兼容模式:$(install_plan_container_mode 2>/dev/null || echo unknown)"
else
doctor_warn "未记录容器兼容模式"
fi
if [ -n "$(install_plan_port_policy 2>/dev/null || true)" ]; then
doctor_ok "端口策略:$(install_plan_port_policy 2>/dev/null || echo unknown)"
else
doctor_warn "未记录端口策略"
fi
}
doctor_container_tun() {
doctor_print_title "容器与 Tun 检查"
case "$(install_env_container 2>/dev/null || echo unknown)" in
host)
doctor_ok "当前运行在主机环境"
;;
docker)
doctor_warn "检测到 Docker 环境"
;;
container)
doctor_warn "检测到容器环境"
;;
*)
doctor_warn "容器环境未知"
;;
esac
case "$(runtime_backend 2>/dev/null || echo unknown)" in
systemd)
doctor_ok "当前运行后端:systemd"
;;
systemd-user)
doctor_ok "当前运行后端:systemd-user"
;;
script)
doctor_warn "当前运行后端:script"
;;
*)
doctor_warn "当前运行后端未知"
;;
esac
if tun_device_exists; then
doctor_ok "/dev/net/tun 存在"
else
doctor_warn "/dev/net/tun 不存在"
fi
if tun_device_readable; then
doctor_ok "/dev/net/tun 可读写"
else
doctor_warn "/dev/net/tun 不可直接读写"
fi
case "$(install_env_tun_safe 2>/dev/null || true)" in
true)
doctor_ok "安装期判定:Tun 可安全管理"
;;
false)
doctor_warn "安装期判定:Tun 不可安全管理"
;;
*)
doctor_warn "安装期未记录 Tun 安全性"
;;
esac
if has_ip_command; then
doctor_ok "ip 命令可用"
else
doctor_warn "缺少 ip 命令,Tun/路由能力受限"
fi
case "$(tun_enabled 2>/dev/null || echo false)" in
true)
doctor_warn "当前 Tun 状态:开启"
;;
*)
doctor_ok "当前 Tun 状态:关闭"
;;
esac
}
doctor_dependencies() {
local dashboard_source
doctor_print_title "依赖检查"
[ -x "$(mihomo_bin)" ] && doctor_ok "Mihomo 已安装:$(mihomo_bin)" || doctor_fail "Mihomo 缺失:$(mihomo_bin)"
[ -x "$(subconverter_bin)" ] && doctor_ok "subconverter 已安装:$(subconverter_bin)" || doctor_fail "subconverter 缺失:$(subconverter_bin)"
[ -x "$(yq_bin)" ] && doctor_ok "yq 已安装:$(yq_bin)" || doctor_fail "yq 缺失:$(yq_bin)"
dashboard_source="$(dashboard_asset_source)"
case "$dashboard_source" in
dir)
doctor_ok "Dashboard 来源:dir(当前部署不依赖 unzip)"
;;
zip)
if command -v unzip >/dev/null 2>&1; then
if dashboard_archive_valid; then
doctor_ok "Dashboard 来源:zip(unzip 可用,压缩包可解压)"
else
doctor_fail "Dashboard 来源:zip(压缩包损坏或不可解压,将阻断 install/update)"
fi
else
doctor_fail "Dashboard 来源:zip(缺少 unzip,无法部署,将阻断 install/update)"
fi
;;
*)
doctor_warn "Dashboard 来源:none(dist/ 与 dist.zip 均不可用,将阻断 install/update)"
;;
esac
if command -v openssl >/dev/null 2>&1; then
doctor_ok "Secret 生成:openssl 可用"
elif [ -r /dev/urandom ] && command -v od >/dev/null 2>&1 && command -v tr >/dev/null 2>&1 && command -v head >/dev/null 2>&1; then
doctor_ok "Secret 生成:fallback 可用(/dev/urandom + od/tr/head)"
else
doctor_fail "Secret 生成:缺少 openssl 且 fallback 不可用"
fi
}
doctor_config() {
local config_file active_profile mixed_port controller controller_secret_value external_ui_path dashboard_source
doctor_print_title "配置检查"
config_file="$RUNTIME_DIR/config.yaml"
if [ -s "$config_file" ]; then
doctor_ok "运行配置存在:$config_file"
else
doctor_fail "运行配置缺失:$config_file"
return 0
fi
active_profile="$(show_active_profile 2>/dev/null || true)"
[ -n "${active_profile:-}" ] || active_profile="default"
doctor_ok "当前 Profile:$active_profile"
if ! doctor_yq_available; then
doctor_warn_skip_yq_parse
if test_runtime_config "$config_file" >/dev/null 2>&1; then
doctor_ok "配置校验通过"
else
doctor_fail "配置校验失败"
fi
return 0
fi
mixed_port="$("$(yq_bin)" eval '.["mixed-port"] // .port // ""' "$config_file" 2>/dev/null | head -n 1)"
controller="$("$(yq_bin)" eval '.["external-controller"] // ""' "$config_file" 2>/dev/null | head -n 1)"
if [ -n "${mixed_port:-}" ] && [ "$mixed_port" != "null" ]; then
doctor_ok "代理端口:$mixed_port"
else
doctor_warn "未解析到代理端口"
fi
if [ -n "${controller:-}" ] && [ "$controller" != "null" ]; then
doctor_ok "控制器地址:$(display_controller_local_addr "$controller" 2>/dev/null || echo "$controller")"
else
doctor_warn "未解析到控制器地址"
fi
controller_secret_value="$("$(yq_bin)" eval '.secret // ""' "$config_file" 2>/dev/null | head -n 1)"
if [ -n "${controller_secret_value:-}" ] && [ "$controller_secret_value" != "null" ]; then
doctor_ok "控制器密钥:已设置"
else
doctor_fail "控制器密钥:未设置"
fi
external_ui_path="$("$(yq_bin)" eval '.["external-ui"] // ""' "$config_file" 2>/dev/null | head -n 1)"
dashboard_source="$(read_runtime_value "DASHBOARD_ASSET_SOURCE" 2>/dev/null || echo none)"
case "${dashboard_source:-none}" in
dir|zip|none) ;;
*) dashboard_source="none" ;;
esac
if [ -n "${external_ui_path:-}" ] && [ "$external_ui_path" != "null" ] && [ -f "${external_ui_path%/}/index.html" ]; then
doctor_ok "Dashboard 已接入:${external_ui_path}(来源:${dashboard_source})"
else
doctor_warn "Dashboard 未接入或目录无效(来源:${dashboard_source})"
fi
if test_runtime_config "$config_file" >/dev/null 2>&1; then
doctor_ok "配置校验通过"
else
doctor_fail "配置校验失败"
fi
}
doctor_subscription() {
local active file total enabled convert_count auto_disabled_count name
doctor_print_title "订阅检查"
file="$(subscriptions_file 2>/dev/null || true)"
if [ -z "${file:-}" ] || [ ! -f "$file" ]; then
doctor_warn "订阅配置文件不存在"
return 0
fi
doctor_ok "订阅策略:active-only"
if ! doctor_yq_available; then
doctor_warn_skip_yq_parse
return 0
fi
active="$(active_subscription_name 2>/dev/null || true)"
total="$("$(yq_bin)" eval '.sources | keys | length' "$file" 2>/dev/null || echo 0)"
enabled="$("$(yq_bin)" eval '[.sources[] | select(.enabled == true)] | length' "$file" 2>/dev/null || echo 0)"
convert_count="$("$(yq_bin)" eval '[.sources[] | select(.type == "convert")] | length' "$file" 2>/dev/null || echo 0)"
auto_disabled_count="$(
while IFS= read -r name; do
[ -n "${name:-}" ] || continue
if subscription_auto_disabled "$name"; then
echo 1
fi
done < <("$(yq_bin)" eval '.sources | keys | .[]' "$file")
)"
auto_disabled_count="$(printf '%s\n' "${auto_disabled_count:-}" | awk 'NF{c++} END{print c+0}')"
[ -n "${active:-}" ] && doctor_ok "当前主订阅:$active" || doctor_warn "当前主订阅为空"
doctor_ok "订阅源总数:$total"
doctor_ok "已启用订阅源:$enabled"
doctor_ok "convert 订阅源:$convert_count"
doctor_ok "自动禁用订阅源:$auto_disabled_count"
if [ -n "${active:-}" ] && subscription_exists "$active"; then
if subscription_enabled "$active"; then
doctor_ok "当前主订阅可用:$active"
else
doctor_warn "当前主订阅已禁用:$active"
fi
if subscription_auto_disabled "$active"; then
doctor_warn "当前主订阅曾被自动禁用:$active"
fi
fi
}
doctor_build() {
local active active_sources failed_active_sources last_status last_time
local block_reason block_time
local error_summary error_detail error_stage
doctor_print_title "编译状态检查"
active="$(read_build_value "BUILD_ACTIVE_SOURCE" 2>/dev/null || true)"
active_sources="$(status_build_active_sources 2>/dev/null || true)"
failed_active_sources="$(status_build_failed_active_sources 2>/dev/null || true)"
last_status="$(read_build_value "BUILD_LAST_STATUS" 2>/dev/null || true)"
last_time="$(read_build_value "BUILD_LAST_TIME" 2>/dev/null || true)"
error_summary="$(read_build_value "BUILD_LAST_ERROR_SUMMARY" 2>/dev/null || true)"
error_detail="$(read_build_value "BUILD_LAST_ERROR_DETAIL" 2>/dev/null || true)"
error_stage="$(read_build_value "BUILD_LAST_ERROR_STAGE" 2>/dev/null || true)"
block_reason="$(read_runtime_event_value "RUNTIME_LAST_BUILD_BLOCK_REASON" 2>/dev/null || true)"
block_time="$(read_runtime_event_value "RUNTIME_LAST_BUILD_BLOCK_TIME" 2>/dev/null || true)"
if [ -z "${active:-}${active_sources:-}${failed_active_sources:-}${last_status:-}${last_time:-}${error_summary:-}${error_stage:-}${block_reason:-}" ]; then
doctor_warn "未找到编译元数据(build.env)"
return 0
fi
doctor_ok "编译模式:active-only"
if [ -n "${active:-}" ]; then
doctor_ok "当前主订阅:$active"
else
doctor_warn "当前主订阅为空"
fi
if [ -n "${active_sources:-}" ]; then
doctor_ok "实际参与编译:$active_sources"
else
doctor_warn "没有任何订阅参与编译"
fi
if [ -n "${failed_active_sources:-}" ]; then
doctor_warn "失败订阅源:$failed_active_sources"
else
doctor_ok "失败订阅源:无"
fi
if [ -n "${last_status:-}" ]; then
if [ "$last_status" = "success" ]; then
doctor_ok "最近一次编译状态:$last_status"
else
doctor_warn "最近一次编译状态:$last_status"
fi
else
doctor_warn "最近一次编译状态未知"
fi
if [ -n "${last_time:-}" ]; then
doctor_ok "最近一次编译时间:$last_time"
else
doctor_warn "最近一次编译时间未知"
fi
if [ -n "${block_reason:-}" ]; then
if [ -n "${block_time:-}" ]; then
doctor_warn "最近一次编译阻断原因:${block_reason} @ ${block_time}"
else
doctor_warn "最近一次编译阻断原因:$block_reason"
fi
fi
if [ -n "${error_summary:-}" ]; then
doctor_warn "最近一次错误摘要:$error_summary"
fi
if [ -n "${error_stage:-}" ]; then
doctor_warn "最近一次错误阶段:$error_stage"
fi
if [ -n "${error_detail:-}" ]; then
echo " 详细错误:"
printf '%s\n' "$error_detail" | sed 's/^/ /'
fi
}
doctor_service() {
local backend
doctor_print_title "服务检查"
backend="$(runtime_backend)"
doctor_ok "运行后端:$backend"
doctor_ok "内核开机自启:$(status_service_autostart_text)"
doctor_ok "开机代理保持:$(status_boot_proxy_keep_text)"
doctor_ok "开机代理接管:$(status_boot_auto_proxy_text)"
doctor_ok "开机边界:$(status_boot_boundary_text)"
case "$backend" in
systemd)
if systemctl is-active --quiet "$(service_unit_name)"; then
doctor_ok "systemd 服务运行中"
systemctl show "$(service_unit_name)" --property MainPID --value 2>/dev/null | awk '{print " 进程号:" $1}'
else
doctor_warn "systemd 服务未运行"
fi
;;
systemd-user)
if systemctl --user is-active --quiet "$(service_unit_name)"; then
doctor_ok "用户级 systemd 服务运行中"
systemctl --user show "$(service_unit_name)" --property MainPID --value 2>/dev/null | awk '{print " 进程号:" $1}'
else
doctor_warn "用户级 systemd 服务未运行"
fi
;;
script)
if [ -f "$RUNTIME_DIR/mihomo.pid" ]; then
local pid
pid="$(cat "$RUNTIME_DIR/mihomo.pid" 2>/dev/null || true)"
if [ -n "${pid:-}" ] && kill -0 "$pid" 2>/dev/null; then
doctor_ok "脚本模式进程运行中"
echo " 进程号:$pid"
else
doctor_warn "脚本模式 PID 文件存在,但进程未运行"
fi
else
doctor_warn "脚本模式当前未运行"
fi
;;
*)
doctor_fail "未知运行后端:$backend"
;;
esac
}
doctor_ports() {
local config_file mixed_port controller controller_port dns_port bind_failure_kind
doctor_print_title "端口检查"
config_file="$RUNTIME_DIR/config.yaml"
if [ ! -s "$config_file" ]; then
doctor_warn "运行配置不存在,跳过端口检查"
return 0
fi
if ! doctor_yq_available; then
doctor_warn_skip_yq_parse
if [ -x "$(subconverter_bin)" ]; then
if is_port_in_use "$(subconverter_port)"; then
doctor_ok "subconverter 端口已监听:$(subconverter_port)"
else
doctor_warn "subconverter 端口未监听:$(subconverter_port)"
fi
fi
return 0
fi
mixed_port="$("$(yq_bin)" eval '.["mixed-port"] // .port // ""' "$config_file" 2>/dev/null | head -n 1)"
controller="$("$(yq_bin)" eval '.["external-controller"] // ""' "$config_file" 2>/dev/null | head -n 1)"
controller_port="${controller##*:}"
dns_port="$(runtime_config_dns_port 2>/dev/null || true)"
bind_failure_kind="$(runtime_mixed_port_bind_failure_kind 2>/dev/null || true)"
if [ -n "${mixed_port:-}" ] && [ "$mixed_port" != "null" ]; then
if [ "$bind_failure_kind" = "bind_denied" ]; then
doctor_fail "mixed-port 绑定被拒绝:$mixed_port"
elif [ "$bind_failure_kind" = "address_in_use" ]; then
doctor_fail "mixed-port 端口被占用:$mixed_port"
elif [ -n "${bind_failure_kind:-}" ]; then
doctor_fail "mixed-port 绑定失败:$mixed_port"
elif is_port_in_use "$mixed_port"; then
doctor_ok "代理端口已监听:$mixed_port"
else
doctor_warn "代理端口未监听:$mixed_port"
fi
else
doctor_warn "无法检查代理端口"
fi
if [ -n "${controller_port:-}" ] && [ "$controller_port" != "$controller" ] && [ "$controller_port" != "null" ]; then
if is_port_in_use "$controller_port"; then
doctor_ok "控制器端口已监听:$controller_port"
else
doctor_warn "控制器端口未监听:$controller_port"
fi
else
doctor_warn "无法检查控制器端口"
fi
if [ -n "${dns_port:-}" ] && [ "$dns_port" != "null" ]; then
if is_port_in_use "$dns_port"; then
doctor_ok "DNS 端口已监听:$dns_port"
else
doctor_warn "DNS 端口未监听:$dns_port"
fi
else
doctor_warn "无法检查 DNS 端口"
fi
if [ -x "$(subconverter_bin)" ]; then
if is_port_in_use "$(subconverter_port)"; then
doctor_ok "subconverter 端口已监听:$(subconverter_port)"
else
doctor_warn "subconverter 端口未监听:$(subconverter_port)"
fi
fi
}
doctor_install_ports() {
doctor_print_title "安装端口裁决检查"
if [ -n "$(install_plan_mixed_port 2>/dev/null || true)" ]; then
doctor_ok "安装期代理端口:$(install_plan_mixed_port 2>/dev/null || echo unknown)"
if [ "$(install_plan_mixed_port_auto_changed 2>/dev/null || echo false)" = "true" ]; then
doctor_warn "代理端口在安装期发生自动避让"
fi
else
doctor_warn "未记录安装期代理端口"
fi
if [ -n "$(install_plan_controller 2>/dev/null || true)" ]; then
doctor_ok "安装期控制器:$(install_plan_controller 2>/dev/null || echo unknown)"
if [ "$(install_plan_controller_auto_changed 2>/dev/null || echo false)" = "true" ]; then
doctor_warn "控制器端口在安装期发生自动避让"
fi
else
doctor_warn "未记录安装期控制器地址"
fi
if [ -n "$(install_plan_dns_port 2>/dev/null || true)" ]; then
doctor_ok "安装期 DNS 端口:$(install_plan_dns_port 2>/dev/null || echo unknown)"
if [ "$(install_plan_dns_port_auto_changed 2>/dev/null || echo false)" = "true" ]; then
doctor_warn "DNS 端口在安装期发生自动避让"
fi
else
doctor_warn "未记录安装期 DNS 端口"
fi
}
doctor_runtime_events() {
local fallback_used fallback_time fallback_reason risk_level
local config_source build_applied build_applied_time build_applied_reason
local system_proxy_text dashboard_status dashboard_source secret_status
doctor_print_title "运行事件检查"
fallback_used="$(runtime_last_fallback_used)"
fallback_time="$(runtime_last_fallback_time)"
fallback_reason="$(runtime_last_fallback_reason)"
risk_level="$(doctor_risk_level)"
config_source="$(status_runtime_config_source 2>/dev/null || true)"
build_applied="$(status_runtime_build_applied 2>/dev/null || true)"
build_applied_time="$(status_runtime_build_applied_time 2>/dev/null || true)"
build_applied_reason="$(status_runtime_build_applied_reason 2>/dev/null || true)"
system_proxy_text="$(persistent_system_proxy_text 2>/dev/null || echo unknown)"
if [ -f "$(runtime_dashboard_dir)/index.html" ]; then
dashboard_status="已部署"
else
dashboard_status="未部署"
fi
dashboard_source="$(read_runtime_value "DASHBOARD_ASSET_SOURCE" 2>/dev/null || echo none)"
case "${dashboard_source:-none}" in
dir|zip|none) ;;
*) dashboard_source="none" ;;
esac
if [ -n "$(read_env_value "CLASH_CONTROLLER_SECRET" 2>/dev/null || true)" ]; then
secret_status="已设置"
else
secret_status="未设置"
fi
doctor_ok "当前风险等级:${risk_level:-unknown}"
doctor_ok "当前配置来源:${config_source:-unknown}"
if [ "$system_proxy_text" = "unsupported" ]; then
doctor_warn "系统代理持久接管:unsupported(当前用户不可写 $(system_proxy_env_file 2>/dev/null || echo /etc/environment))"
else
doctor_ok "系统代理持久接管:${system_proxy_text}"
fi
doctor_ok "Dashboard 运行目录:${dashboard_status}(来源:${dashboard_source})"
doctor_ok ".env 控制器密钥:${secret_status}"
case "${build_applied:-}" in
true)
doctor_ok "最近一次构建已应用"
[ -n "${build_applied_time:-}" ] && echo " 应用时间:$build_applied_time"
;;
false)
doctor_warn "最近一次构建未应用"
[ -n "${build_applied_time:-}" ] && echo " 记录时间:$build_applied_time"
[ -n "${build_applied_reason:-}" ] && echo " 未应用原因:$build_applied_reason"
;;
*)
doctor_warn "最近一次构建应用状态未知"
;;
esac
if [ "${fallback_used:-}" = "true" ]; then
doctor_warn "最近一次启动触发了配置回退"
[ -n "${fallback_time:-}" ] && echo " 回退时间:$fallback_time"
[ -n "${fallback_reason:-}" ] && echo " 回退原因:$fallback_reason"
else
doctor_ok "最近一次启动未触发配置回退"
fi
}
doctor_install_verify() {
local live_runtime="false"
local live_controller="false"
doctor_print_title "安装验证检查"
case "$(install_verify_command_ready 2>/dev/null || true)" in
true) doctor_ok "clashctl 命令入口可用" ;;
false) doctor_fail "clashctl 命令入口不可用" ;;
*) doctor_warn "未记录命令入口验证结果" ;;
esac
case "$(install_verify_config_ready 2>/dev/null || true)" in
true) doctor_ok "安装后配置已就绪" ;;
false) doctor_fail "安装后配置未就绪" ;;
*) doctor_warn "未记录配置验证结果" ;;
esac
if status_is_running 2>/dev/null; then
live_runtime="true"
fi
if proxy_controller_reachable 2>/dev/null; then
live_controller="true"
fi
if [ "$live_runtime" = "true" ]; then
doctor_ok "安装后运行态已就绪"
else
case "$(install_verify_runtime_ready 2>/dev/null || true)" in
true) doctor_ok "安装后运行态已就绪" ;;
false) doctor_warn "安装后运行态未就绪" ;;
*) doctor_warn "未记录运行态验证结果" ;;
esac
fi
if [ "$live_controller" = "true" ]; then
doctor_ok "安装后控制器可访问"
else
case "$(install_verify_controller_ready 2>/dev/null || true)" in
true) doctor_ok "安装后控制器可访问" ;;
false) doctor_warn "安装后控制器不可访问" ;;
*) doctor_warn "未记录控制器验证结果" ;;
esac
fi
}
doctor_active_switch() {
local from to at reason
doctor_print_title "主订阅自动切换检查"
from="$(read_runtime_event_value "RUNTIME_LAST_ACTIVE_SWITCH_FROM" 2>/dev/null || true)"
to="$(read_runtime_event_value "RUNTIME_LAST_ACTIVE_SWITCH_TO" 2>/dev/null || true)"
at="$(read_runtime_event_value "RUNTIME_LAST_ACTIVE_SWITCH_TIME" 2>/dev/null || true)"
reason="$(read_runtime_event_value "RUNTIME_LAST_ACTIVE_SWITCH_REASON" 2>/dev/null || true)"
if [ -n "${to:-}" ]; then
doctor_warn "检测到最近一次主订阅自动切换"
[ -n "${from:-}" ] && echo " from : $from"
echo " to : $to"
[ -n "${at:-}" ] && echo " time : $at"
[ -n "${reason:-}" ] && echo " reason : $reason"
else
doctor_ok "最近未发生主订阅自动切换"
fi
}
doctor_controller() {
local controller group_count current_examples bind_failure_text
doctor_print_title "控制器检查"
controller="$(status_read_controller 2>/dev/null || true)"
bind_failure_text="$(runtime_mixed_port_bind_failure_text 2>/dev/null || true)"
if [ -z "${controller:-}" ] || [ "$controller" = "null" ]; then
doctor_fail "未解析到 external-controller"
return 0
fi
doctor_ok "控制器地址:$(display_controller_local_addr "$controller" 2>/dev/null || echo "$controller")"
if ! status_is_running; then
if [ -n "${bind_failure_text:-}" ]; then
doctor_warn "${bind_failure_text},无法检查控制器 API"
else
doctor_warn "内核未运行,无法检查控制器 API"
fi
return 0
fi
if proxy_controller_reachable 2>/dev/null; then
doctor_ok "控制器 API 可访问"
group_count="$(proxy_group_count 2>/dev/null || echo 0)"
doctor_ok "可切换策略组数量:$group_count"
if [ "${group_count:-0}" -gt 0 ]; then
echo " 当前策略组摘要:"
print_proxy_groups_status | head -n 5 | sed 's/^/ /'
fi
else
doctor_fail "控制器 API 不可访问"
fi
}
cmd_doctor() {
prepare
load_system_state
ui_title "📜 系统诊断"
ui_section "总体结论"
doctor_primary_conclusion
ui_kv "🚨" "风险等级" "$(doctor_risk_text)"
ui_blank
ui_section "发现的问题"
if doctor_problem_lines | grep -q .; then
doctor_problem_lines | sed 's/^/ /'
else
echo " 🐱 未发现明显问题"
fi
ui_blank
ui_section "关键证据"
doctor_evidence_lines | sed 's/^/ /'
ui_blank
ui_section "修复建议"
doctor_recommendation_lines | sed 's/^/ /'
ui_blank
ui_section "详细检查"
doctor_install_context
doctor_dependencies
doctor_container_tun
doctor_config
doctor_subscription
doctor_build
doctor_service
doctor_ports
doctor_install_ports
doctor_runtime_events
doctor_install_verify
doctor_active_switch
doctor_controller
}
cmd_dev() {
prepare
case "${1:-}" in
reset)
echo
echo "🧪 正在恢复到安装前状态(保留项目目录与下载文件) ..."
echo
bash "$PROJECT_DIR/uninstall.sh" --dev-reset
echo
echo "🐱 开发重置完成"
echo "🧩 保留内容:项目目录、已下载依赖、调试环境"
echo "👉 下一步:重新执行 install.sh 或 clashctl status"
echo
;;
"")
echo "📜 用法:clashctl dev reset"
;;
*)
die_usage "未知的 dev 子命令:$1" "clashctl dev reset"
;;
esac
}
cmd_config_show() {
local active kernel build_status build_time config_source
prepare
active="$(active_subscription_name 2>/dev/null || true)"
kernel="$(runtime_kernel_type 2>/dev/null || echo mihomo)"
build_status="$(status_build_last_status 2>/dev/null || true)"
build_time="$(status_build_last_time 2>/dev/null || true)"
config_source="$(status_runtime_config_source 2>/dev/null || true)"
ui_title "🧩 配置编译管理"
ui_kv "🚩" "当前主订阅" "${active:-未设置}"
ui_kv "🚀" "当前内核" "${kernel:-mihomo}"
ui_kv "🧩" "编译模式" "active-only"
ui_kv "🐱" "最近构建" "${build_status:-unknown}${build_time:+ @ ${build_time}}"
ui_kv "🧩" "配置来源" "${config_source:-unknown}"
ui_blank
ui_next "clashctl status"
ui_blank
}
cmd_config() {
prepare
case "${1:-}" in
show)
cmd_config_show
;;
explain)
print_build_explain
;;
regen)
regenerate_config
apply_runtime_change_after_config_mutation
print_config_regen_feedback
print_config_apply_feedback
;;
kernel)
shift || true
[ -n "${1:-}" ] || die_usage "内核类型不能为空" "clashctl config kernel <mihomo|clash>"
write_runtime_kernel_type "$1"
resolve_runtime_kernel
regenerate_config
apply_runtime_change_after_config_mutation
print_config_kernel_feedback "$(runtime_kernel_type)"
print_config_apply_feedback
;;
"")
ui_title "🧩 配置编译管理"
echo "📜 用法:"
echo " clashctl config show"
echo " clashctl config explain"
echo " clashctl config regen"
echo " clashctl config kernel <mihomo|clash>"
echo
echo "🧩 说明:"
echo " 当前编译链固定为 active-only"
echo " 只处理当前 active 主订阅"
echo
ui_next "clashctl config show"
ui_blank
;;
*)
die_usage "未知的 config 子命令:$1" "clashctl config"
;;
esac
}
print_lan_status() {
local allow_lan mixed_port lan_ip
allow_lan="$(runtime_config_allow_lan 2>/dev/null || config_allow_lan 2>/dev/null || echo true)"
mixed_port="$(status_read_mixed_port 2>/dev/null || runtime_config_mixed_port 2>/dev/null || true)"
lan_ip="$(ui_lan_ip 2>/dev/null || true)"
ui_title "🏠 局域网代理"
if [ "$allow_lan" = "true" ]; then
ui_kv "📶" "状态" "已开启"
if [ -n "${lan_ip:-}" ] && [ -n "${mixed_port:-}" ] && [ "$mixed_port" != "null" ]; then
ui_kv "🏠" "局域网代理" "http://${lan_ip}:${mixed_port}"
else
ui_kv "🏠" "局域网代理" "等待运行配置生成"
fi
else
ui_kv "📶" "状态" "已关闭"
fi
ui_kv "🔧" "配置项" "allow-lan: ${allow_lan}"
ui_blank
}
cmd_lan() {
local action
prepare
action="${1:-status}"
case "$action" in
on|enable)
set_config_allow_lan true
regenerate_config
apply_runtime_change_after_config_mutation
success "局域网代理已开启"
print_lan_status
print_config_apply_feedback
;;
off|disable)
set_config_allow_lan false
regenerate_config
apply_runtime_change_after_config_mutation
success "局域网代理已关闭"
print_lan_status
print_config_apply_feedback
;;
status)
print_lan_status
ui_next "clashctl lan on"
ui_blank
;;
-h|--help|help|"")
echo "📜 用法:"
echo " clashctl lan status"
echo " clashctl lan on"
echo " clashctl lan off"
;;
*)
die_usage "未知的 lan 子命令:$action" "clashctl lan on|off|status"
;;
esac
}
print_profile_use_feedback() {
local profile="$1"
ui_title "🔧 Profile 已切换"
ui_kv "🔧" "当前 Profile" "$profile"
main_feedback_runtime_state
ui_next "clashctl status"
ui_blank
}
cmd_profile() {
prepare
echo "⚠ 当前版本未启用 Profile 功能"
return 0
}
mixin_config_file() {
ensure_mixin_file
echo "$(mixin_file)"
}
active_subscription_runtime_raw_file() {
local name="$1"
echo "$RUNTIME_DIR/tmp/source-${name}.yaml"
}
ensure_mixin_runtime_prepared() {
local active source_file
active="$(active_subscription_name 2>/dev/null || true)"
[ -n "${active:-}" ] || die "当前没有活动订阅"
subscription_exists "$active" || die "当前活动订阅不存在:$active"
source_file="$(active_subscription_runtime_raw_file "$active")"
mkdir -p "$RUNTIME_DIR/tmp"
if [ -s "$source_file" ]; then
echo "$source_file"
return 0
fi
if fetch_subscription_source "$active" "$source_file"; then
echo "$source_file"
return 0
fi
rm -f "$source_file" 2>/dev/null || true
die "无法获取当前活动订阅原始配置:$active"
}
open_editor_for_file() {
local file="$1"
local editor
[ -n "${file:-}" ] || die "文件路径不能为空"
touch "$file"
if [ -n "${EDITOR:-}" ]; then
editor="$EDITOR"
elif command -v nano >/dev/null 2>&1; then
editor="nano"
elif command -v vim >/dev/null 2>&1; then
editor="vim"
elif command -v vi >/dev/null 2>&1; then
editor="vi"
else
die "未找到可用编辑器,请先设置 EDITOR 环境变量"
fi
"$editor" "$file"
}
cmd_mixin_show() {
local file
prepare
ensure_config_files
file="$(mixin_config_file)"
ui_title "🧩 Mixin 配置"
ui_kv "🧩" "作用" "对原始订阅配置进行补充、覆盖或前后置合并"
ui_kv "🔧" "配置文件" "$file"
ui_blank
if mixin_config_is_empty "$file"; then
print_mixin_template_example
else
cat "$file"
if mixin_config_has_secret_override "$file"; then
ui_warn "检测到 override.secret:该字段已忽略,请改用 clashctl secret <密钥>"
fi
fi
ui_blank
ui_next "clashctl mixin edit"
ui_blank
}
mixin_config_is_empty() {
local file="$1"
local compact noop
[ -s "$file" ] || return 0
if [ -x "$(yq_bin)" ]; then
noop="$("$(yq_bin)" eval '
((.override // {}) == {}) and
(((.prepend.proxies // []) | length) == 0) and
(((.prepend["proxy-groups"] // []) | length) == 0) and
(((.prepend.rules // []) | length) == 0) and
(((.append.proxies // []) | length) == 0) and
(((.append["proxy-groups"] // []) | length) == 0) and
(((.append.rules // []) | length) == 0)
' "$file" 2>/dev/null | head -n 1 || true)"
[ "$noop" = "true" ] && return 0
fi
compact="$(tr -d '[:space:]' < "$file" 2>/dev/null || true)"
case "$compact" in
""|"{}")
return 0
;;
esac
return 1
}
mixin_config_has_secret_override() {
local file="$1"
local exists
[ -s "$file" ] || return 1
[ -x "$(yq_bin)" ] || return 1
exists="$("$(yq_bin)" eval '
(.override // {}) as $override |
(($override | type) == "!!map" and ($override | has("secret")))
' "$file" 2>/dev/null | head -n 1 || true)"
[ "$exists" = "true" ]
}
print_mixin_template_example() {
cat <<'EOF'
当前 mixin 还没有实际补丁。可按这个结构填写:
override:
mixed-port: 7890
dns:
enable: true
prepend:
proxies: []
proxy-groups: []
rules:
- DOMAIN-SUFFIX,example.com,DIRECT
append:
proxies: []
proxy-groups: []
rules:
- MATCH,节点选择
说明:
override 会覆盖同名字段
override.secret 会被忽略,控制器密钥只从 .env 的 CLASH_CONTROLLER_SECRET 读取
prepend 会把数组内容放到原始订阅前面
append 会把数组内容放到原始订阅后面
EOF
}
cmd_mixin_edit() {
local file
prepare
ensure_config_files
file="$(mixin_config_file)"
open_editor_for_file "$file"
echo
echo "ℹ️ 正在重新生成配置 ..."
regenerate_config
if status_is_running; then
service_restart
echo "🐱 Mixin 已生效(已自动重启)"
else
echo "🟡 Mixin 已写入(下次启动生效)"
fi
echo "👉 下一步:clashctl mixin runtime"
echo
}
cmd_mixin_raw() {
local file
prepare
file="$(ensure_mixin_runtime_prepared)"
echo
echo "📡 原始订阅配置"
echo
echo "📡 说明:这是当前活动订阅拉取到的原始配置,不包含 Mixin 最终合并结果"
echo "🔧 来源文件:$file"
echo
cat "$file"
echo
echo "👉 下一步:clashctl mixin runtime"
echo
}
cmd_mixin_runtime() {
local file
prepare
file="$RUNTIME_DIR/config.yaml"
[ -s "$file" ] || die "🧩 运行时配置不存在:$file"
echo
echo "🧩 运行时配置"
echo
echo "🧩 说明:这是当前内核真正加载的最终配置"
echo "🔧 配置文件:$file"
echo
cat "$file"
echo
echo "👉 下一步:clashctl status"
echo
}
cmd_mixin() {
case "${1:-}" in
"")
cmd_mixin_show
;;
edit|-e|--edit)
cmd_mixin_edit
;;
raw|-c|--raw)
cmd_mixin_raw
;;
runtime|-r|--runtime)
cmd_mixin_runtime
;;
help|-h|--help)
ui_title "🧩 Mixin 配置管理"
echo "📜 用法:"
echo " clashctl mixin"
echo " clashctl mixin edit"
echo " clashctl mixin raw"
echo " clashctl mixin runtime"
echo
echo "🧩 说明:"
echo " mixin 用于补充、覆盖或前后置合并原始订阅配置"
echo " runtime 展示当前内核真正加载的最终配置"
echo
echo "💡 常用动作:"
echo " clashctl mixin"
echo " clashctl mixin edit"
echo " clashctl mixin runtime"
echo
echo "⚡ 快捷参数:"
echo " clashctl mixin -e"
echo " clashctl mixin -c"
echo " clashctl mixin -r"
echo
ui_next "clashctl mixin"
ui_blank
;;
*)
die_usage "未知的 mixin 子命令:$1" "clashctl mixin"
;;
esac
}
ensure_relay_mixin_file() {
local file
ensure_config_files
file="$(mixin_config_file)"
[ -s "$file" ] || cat > "$file" <<'EOF'
override: {}
prepend:
proxies: []
proxy-groups: []
rules: []
append:
proxies: []
proxy-groups: []
rules: []
EOF
"$(yq_bin)" eval -i '
.override = (.override // {}) |
.prepend = (.prepend // {}) |
.prepend.proxies = (.prepend.proxies // []) |
.prepend["proxy-groups"] = (.prepend["proxy-groups"] // []) |
.prepend.rules = (.prepend.rules // []) |
.append = (.append // {}) |
.append.proxies = (.append.proxies // []) |
.append["proxy-groups"] = (.append["proxy-groups"] // []) |
.append.rules = (.append.rules // [])
' "$file"
echo "$file"
}
relay_apply_mixin_change() {
echo
echo "ℹ️ 正在重新生成配置 ..."
regenerate_config
if status_is_running; then
service_restart
echo "🐱 多跳配置已生效(已自动重启)"
else
echo "🟡 多跳配置已写入(下次启动生效)"
fi
}
cmd_relay_add() {
local name rule="" domain="" match_mode="false" file node
local nodes=()
prepare
[ -x "$(yq_bin)" ] || die_state "依赖未就绪:缺少 yq($(yq_bin))" "请先执行 bash install.sh"
name="${1:-}"
[ -n "${name:-}" ] || die_usage "缺少多跳名称" "clashctl relay add <名称> <节点A> <节点B> [更多节点] [--domain 域名|--match]"
shift || true
while [ $# -gt 0 ]; do
case "$1" in
--domain)
shift || true
[ -n "${1:-}" ] || die_usage "--domain 缺少域名" "clashctl relay add <名称> <节点A> <节点B> --domain example.com"
domain="$1"
;;
--match)
match_mode="true"
;;
--)
shift || true
while [ $# -gt 0 ]; do
nodes+=("$1")
shift
done
break
;;
--*)
die_usage "未知 relay add 参数:$1" "clashctl relay add <名称> <节点A> <节点B> [--domain 域名|--match]"
;;
*)
nodes+=("$1")
;;
esac
shift || true
done
[ "${#nodes[@]}" -ge 2 ] || die_usage "多跳至少需要两个节点" "clashctl relay add <名称> <节点A> <节点B>"
if [ -n "${domain:-}" ] && [ "$match_mode" = "true" ]; then
die_usage "--domain 与 --match 只能选择一个" "clashctl relay add <名称> <节点A> <节点B> [--domain 域名|--match]"
fi
if [ -n "${domain:-}" ]; then
rule="DOMAIN-SUFFIX,${domain},${name}"
elif [ "$match_mode" = "true" ]; then
rule="MATCH,${name}"
fi
file="$(ensure_relay_mixin_file)"
RELAY_NAME="$name" "$(yq_bin)" eval -i '
.append["proxy-groups"] = ((.append["proxy-groups"] // []) | map(select(.name != strenv(RELAY_NAME)))) |
.append["proxy-groups"] += [{"name": strenv(RELAY_NAME), "type": "relay", "proxies": []}]
' "$file"
for node in "${nodes[@]}"; do
RELAY_NAME="$name" RELAY_NODE="$node" "$(yq_bin)" eval -i '
(.append["proxy-groups"][] | select(.name == strenv(RELAY_NAME)).proxies) += [strenv(RELAY_NODE)]
' "$file"
done
if [ -n "${rule:-}" ]; then
RELAY_RULE="$rule" "$(yq_bin)" eval -i '
.prepend.rules = ([strenv(RELAY_RULE)] + (.prepend.rules // []) | unique)
' "$file"
fi
ui_title "🔗 多跳配置已更新"
ui_kv "🔧" "配置文件" "$file"
ui_kv "🔗" "多跳名称" "$name"
ui_kv "🧩" "节点链路" "$(printf '%s\n' "${nodes[@]}" | awk 'BEGIN{out=""} {out=(out?out" -> ":"")$0} END{print out}')"
[ -n "${rule:-}" ] && ui_kv "📜" "新增规则" "$rule"
relay_apply_mixin_change
echo "👉 下一步:clashctl relay list"
echo
}
cmd_relay_list() {
local file output
prepare
[ -x "$(yq_bin)" ] || die_state "依赖未就绪:缺少 yq($(yq_bin))" "请先执行 bash install.sh"
file="$(ensure_relay_mixin_file)"
output="$("$(yq_bin)" eval '
(.append["proxy-groups"] // [])[] |
select(.type == "relay") |
" " + .name + ": " + ((.proxies // []) | join(" -> "))
' "$file" 2>/dev/null || true)"
ui_title "🔗 多跳节点"
ui_kv "🔧" "配置文件" "$file"
ui_blank
if [ -n "${output:-}" ]; then
printf '%s\n' "$output"
else
echo "当前没有通过 mixin 配置的多跳组"
ui_next "clashctl relay add 多跳-示例 节点A 节点B --domain example.com"
fi
ui_blank
}
cmd_relay_remove() {
local name file
prepare
[ -x "$(yq_bin)" ] || die_state "依赖未就绪:缺少 yq($(yq_bin))" "请先执行 bash install.sh"
name="${1:-}"
[ -n "${name:-}" ] || die_usage "缺少多跳名称" "clashctl relay remove <名称>"
file="$(ensure_relay_mixin_file)"
RELAY_NAME="$name" "$(yq_bin)" eval -i '
.append["proxy-groups"] = ((.append["proxy-groups"] // []) | map(select(.name != strenv(RELAY_NAME)))) |
.prepend.rules = ((.prepend.rules // []) | map(select((. | endswith("," + strenv(RELAY_NAME))) | not))) |
.append.rules = ((.append.rules // []) | map(select((. | endswith("," + strenv(RELAY_NAME))) | not)))
' "$file"
ui_title "🔗 多跳配置已删除"
ui_kv "🔧" "配置文件" "$file"
ui_kv "🔗" "多跳名称" "$name"
relay_apply_mixin_change
echo "👉 下一步:clashctl relay list"
echo
}
cmd_relay() {
case "${1:-}" in
add)
shift || true
cmd_relay_add "$@"
;;
list|ls|"")
cmd_relay_list
;;
remove|rm|delete)
shift || true
cmd_relay_remove "$@"
;;
help|-h|--help)
ui_title "🔗 多跳节点管理"
echo "📜 用法:"
echo " clashctl relay add <名称> <节点A> <节点B> [更多节点] [--domain 域名|--match]"
echo " clashctl relay list"
echo " clashctl relay remove <名称>"
echo
echo "示例:"
echo " clashctl relay add 多跳-示例 节点A 节点B --domain example.com"
echo " clashctl relay add 全局多跳 节点A 节点B --match"
echo
echo "说明:"
echo " add 会写入 runtime/mixin.yaml(兼容读取 config/mixin.yaml),并重新生成运行配置"
echo " --domain 用于小范围测试,--match 会让所有未提前命中的流量走多跳"
echo " 节点名称必须与订阅生成的节点名完全一致"
;;
*)
die_usage "未知的 relay 子命令:$1" "clashctl relay help"
;;
esac
}
runtime_config_exists() {
[ -s "$RUNTIME_DIR/config.yaml" ]
}
doctor_risk_level() {
local running controller_ok config_ok
running="false"
controller_ok="false"
config_ok="false"
status_is_running && running="true"
proxy_controller_reachable 2>/dev/null && controller_ok="true"
runtime_config_exists && config_ok="true"
if runtime_mixed_port_bind_failure_kind >/dev/null 2>&1; then
echo "high"
return
fi
if [ "$running" = "true" ] && [ "$controller_ok" = "true" ] && [ "$config_ok" = "true" ]; then
echo "low"
return
fi
if [ "$running" = "false" ]; then
echo "medium"
return
fi
if [ "$controller_ok" = "false" ]; then
echo "high"
return
fi
echo "medium"
}
doctor_risk_text() {
case "$(doctor_risk_level)" in
low) echo "🐱 低" ;;
medium) echo "🟡 中" ;;
high) echo "❗ 高" ;;
*) echo "⚪ 未知" ;;
esac
}
doctor_problem_lines() {
local active_sub bind_failure_text mixed_port
active_sub="$(active_subscription_name 2>/dev/null || true)"
bind_failure_text="$(runtime_mixed_port_bind_failure_text 2>/dev/null || true)"
mixed_port="$(runtime_mixed_port_bind_failure_port 2>/dev/null || status_read_mixed_port 2>/dev/null || true)"
if ! runtime_config_exists; then
echo "🚨 运行配置缺失"
fi
if [ -n "${bind_failure_text:-}" ]; then
echo "🚨 ${bind_failure_text}${mixed_port:+:${mixed_port}}"
elif ! status_is_running; then
echo "🚨 代理内核未运行"
fi
if status_is_running && ! proxy_controller_reachable 2>/dev/null; then
echo "🚨 控制器不可访问"
fi
if [ -n "${active_sub:-}" ] && ! active_subscription_enabled 2>/dev/null; then
echo "🚨 当前主订阅不可用"
fi
if [ "$(status_build_last_status 2>/dev/null || true)" = "failed" ]; then
echo "🚨 最近一次编译失败"
fi
}
doctor_primary_conclusion() {
if ! runtime_config_exists; then
echo "❗ 当前不可用:缺少运行配置"
return 0
fi
if ! status_is_running; then
case "$(runtime_mixed_port_bind_failure_kind 2>/dev/null || true)" in
bind_denied)
echo "❗ 当前不可用:mixed-port 绑定被拒绝"
mixed_port_bind_observation_line | sed -n '1p' | sed 's/^/ /'
return 0
;;
address_in_use)
echo "❗ 当前不可用:mixed-port 端口被占用"
mixed_port_bind_observation_line | sed -n '1p' | sed 's/^/ /'
return 0
;;
bind_failed)
echo "❗ 当前不可用:mixed-port 绑定失败"
mixed_port_bind_observation_line | sed -n '1p' | sed 's/^/ /'
return 0
;;
esac
echo "🟡 当前未连接:代理内核未启动"
return 0
fi
if ! proxy_controller_reachable 2>/dev/null; then
echo "❗ 当前异常:内核已启动,但控制器不可访问"
return 0
fi
echo "🐱 当前基本可用:代理内核与控制器均正常"
}
doctor_recommendation_lines() {
local active_sub bind_failure_kind system_proxy_text mixed_port
active_sub="$(active_subscription_name 2>/dev/null || true)"
bind_failure_kind="$(runtime_mixed_port_bind_failure_kind 2>/dev/null || true)"
system_proxy_text="$(persistent_system_proxy_text 2>/dev/null || echo unknown)"
mixed_port="$(status_read_mixed_port 2>/dev/null || true)"
if ! runtime_config_exists; then
if [ -n "$(subscription_url 2>/dev/null || true)" ]; then
echo "💡 clashctl config regen"
else
echo "💡 clashctl add <订阅链接>"
fi
return 0
fi
if [ -n "${bind_failure_kind:-}" ]; then
mixed_port_bind_recommendation_lines icon
return 0
elif ! status_is_running; then
echo "💡 clashon"
return 0
fi
if ! proxy_controller_reachable 2>/dev/null; then
echo "💡 clashctl logs service"
echo "💡 clashctl off && clashon"
return 0
fi
if [ -n "${active_sub:-}" ] && ! active_subscription_enabled 2>/dev/null; then
echo "💡 clashctl use"
echo "💡 clashctl ls"
return 0
fi
if [ "$(status_build_last_status 2>/dev/null || true)" = "failed" ]; then
echo "💡 clashctl config regen"
echo "💡 clashctl doctor"
return 0
fi
case "$system_proxy_text" in
unsupported|off)
if [ -n "${mixed_port:-}" ] && [ "$mixed_port" != "null" ]; then
echo "💡 浏览器/应用手动设置 HTTP 代理:http://127.0.0.1:${mixed_port}"
fi
echo "💡 当前 Shell 可使用 clashon 注入代理变量"
;;
mismatch)
echo "💡 clashoff && clashon"
;;
*)
echo "💡 clashctl status"
echo "💡 clashctl select"
;;
esac
}
doctor_evidence_lines() {
local active_sub mixed_port controller bind_failure_kind bind_failure_line dns_port
local system_proxy_text process_proxy_env
active_sub="$(active_subscription_name 2>/dev/null || true)"
mixed_port="$(runtime_mixed_port_bind_failure_port 2>/dev/null || status_read_mixed_port 2>/dev/null || true)"
controller="$(status_read_controller 2>/dev/null || true)"
bind_failure_kind="$(runtime_mixed_port_bind_failure_kind 2>/dev/null || true)"
bind_failure_line="$(runtime_mixed_port_bind_failure_line 2>/dev/null || true)"
dns_port="$(runtime_config_dns_port 2>/dev/null || true)"
system_proxy_text="$(persistent_system_proxy_text 2>/dev/null || echo unknown)"
process_proxy_env="$(current_process_proxy_env_text 2>/dev/null || echo unknown)"
if runtime_config_exists; then
echo "🔍 运行配置:存在"
else
echo "🔍 运行配置:缺失"
fi
if status_is_running; then
echo "🔍 服务状态:运行中"
else
echo "🔍 服务状态:未运行"
fi
if proxy_controller_reachable 2>/dev/null; then
echo "🔍 控制器状态:可访问"
else
echo "🔍 控制器状态:不可访问"
fi
if [ -n "${bind_failure_kind:-}" ]; then
echo "🔍 mixed-port 绑定错误:${bind_failure_kind}"
echo "🔍 mixed-port 环境:$(mixed_port_bind_environment_text 2>/dev/null || echo unknown)"
if runtime_mixed_port_controller_listening 2>/dev/null; then
echo "🔍 控制器监听:已确认"
else
echo "🔍 控制器监听:未确认"
fi
if [ -n "${dns_port:-}" ]; then
if runtime_mixed_port_dns_listening 2>/dev/null; then
echo "🔍 DNS 监听:${dns_port}(已确认)"
else
echo "🔍 DNS 监听:${dns_port}(未确认)"
fi
fi
[ -n "${bind_failure_line:-}" ] && echo "🔍 日志证据:${bind_failure_line}"
fi
if [ -n "${active_sub:-}" ]; then
if active_subscription_enabled 2>/dev/null; then
echo "🔍 当前订阅:${active_sub}(可用)"
else
echo "🔍 当前订阅:${active_sub}(不可用)"
fi
else
echo "🔍 当前订阅:未设置"
fi
if [ -n "${mixed_port:-}" ] && [ "$mixed_port" != "null" ]; then
if is_port_in_use "$mixed_port"; then
echo "🔍 代理端口:${mixed_port}(已监听)"
else
echo "🔍 代理端口:${mixed_port}(未监听)"
fi
echo "🔍 本地代理:http://127.0.0.1:${mixed_port}"
fi
if [ -n "${controller:-}" ] && [ "$controller" != "null" ]; then
echo "🔍 控制器地址:$(display_controller_local_addr "$controller" 2>/dev/null || echo "$controller")"
fi
echo "🔍 系统代理持久接管:${system_proxy_text}"
echo "🔍 当前命令环境代理:${process_proxy_env}"
}
set_controller_secret() {
local secret="$1"
is_valid_controller_secret "$secret" || die "密钥不能为空"
write_env_value "CLASH_CONTROLLER_SECRET" "$secret"
export CLASH_CONTROLLER_SECRET="$secret"
}
sync_runtime_controller_secret_from_env() {
local file="$RUNTIME_DIR/config.yaml"
local secret
[ -s "$file" ] || return 0
[ -x "$(yq_bin)" ] || return 1
secret="$(ensure_controller_secret)"
SECRET_VALUE="$secret" "$(yq_bin)" eval -i '
.secret = strenv(SECRET_VALUE)
' "$file"
}
show_controller_secret_from_env() {
local current_secret
current_secret="$(read_env_value "CLASH_CONTROLLER_SECRET" 2>/dev/null || true)"
if ! is_valid_controller_secret "$current_secret"; then
current_secret="${CLASH_CONTROLLER_SECRET:-}"
fi
echo
if is_valid_controller_secret "$current_secret"; then
ui_kv "🔑" "当前密钥" "$current_secret"
else
ui_kv "🚨" "当前密钥" "未设置"
fi
ui_kv "🔧" "密钥来源" "$PROJECT_DIR/.env"
ui_blank
}
print_controller_secret_apply_feedback() {
local synced="${1:-true}"
echo
if [ "$synced" = "true" ]; then
if status_is_running; then
service_restart
ui_kv "🐱" "状态" "密钥更新成功,已重启生效"
else
ui_kv "🐱" "状态" "将在下次启动时生效"
fi
else
ui_warn "运行时配置暂未同步:缺少 yq 或写入失败,请稍后执行 clashctl config regen"
ui_kv "🐱" "状态" "密钥已写入 .env,运行时配置同步后生效"
fi
ui_kv "🔧" "密钥来源" "$PROJECT_DIR/.env"
ui_blank
}
cmd_secret() {
local new_secret synced
prepare
if [ "$#" -eq 0 ]; then
show_controller_secret_from_env
return 0
fi
[ "$#" -eq 1 ] || die_usage "secret 参数不合法" "clashctl secret <密钥>"
new_secret="$1"
set_controller_secret "$new_secret"
synced="true"
sync_runtime_controller_secret_from_env || synced="false"
print_controller_secret_apply_feedback "$synced"
}
cmd_tun_status() {
local enabled stack env_type effective_status
prepare
enabled="$(tun_enabled)"
stack="$(tun_stack)"
env_type="$(container_env_type)"
effective_status="$(status_tun_effective_status 2>/dev/null || echo ineffective)"
echo
echo "🧪 Tun 状态"
echo
if [ "$enabled" = "true" ]; then
echo "🐱 当前状态:已开启"
else
echo "❗ 当前状态:未开启"
fi
echo "🔧 Tun stack:$stack"
echo "💻 环境类型:$env_type"
if can_manage_tun_safely; then
echo "🐱 环境检查:满足基础开启条件"
else
echo "🚨 环境检查:当前不满足基础开启条件"
fi
if [ "$effective_status" = "effective" ]; then
echo "🐱 已生效"
else
echo "❗ 未生效"
fi
if [ "$enabled" = "true" ]; then
echo "👉 下一步:clashctl tun doctor"
else
echo "👉 下一步:clashctl tun on"
fi
echo
}
cmd_tun_on() {
local verify_result
local container_mode risk_reason
prepare
container_mode="$(tun_container_mode 2>/dev/null || echo unknown)"
risk_reason="$(tun_container_risk_reason 2>/dev/null || true)"
case "$container_mode" in
host)
;;
container-safe)
ui_warn "当前处于容器环境,Tun 将按保守模式尝试开启"
[ -n "${risk_reason:-}" ] && ui_warn "$risk_reason"
;;
container-risky)
mark_tun_last_action "on" "blocked" "${risk_reason:-container-risky}"
mark_tun_last_verification "blocked" "${risk_reason:-container-risky}"
print_tun_container_gate_feedback "$container_mode" "${risk_reason:-容器环境不满足 Tun 基础条件}"
return 1
;;
*)
ui_warn "无法识别当前 Tun 容器模式,按高风险处理"
mark_tun_last_action "on" "blocked" "unknown-container-mode"
mark_tun_last_verification "blocked" "unknown-container-mode"
print_tun_container_gate_feedback "container-risky" "unknown-container-mode"
return 1
;;
esac
if ! can_manage_tun_safely; then
echo
echo "❗ Tun 模式无法开启"
echo "🚨 原因:当前环境不满足基础 Tun 条件"
echo "👉 下一步:clashctl tun doctor"
echo
return 1
fi
case "$(tun_kernel_support_level 2>/dev/null || echo unknown)" in
full)
;;
limited)
ui_warn "$(tun_kernel_support_reason 2>/dev/null || echo '当前内核仅对 Tun 提供降级支持')"
;;
*)
ui_warn "当前内核的 Tun 支持等级未知,请谨慎开启"
;;
esac
if ! sync_tun_target_state "on" "true"; then
return 1
fi
if status_is_running; then
service_restart
fi
verify_result="$(tun_effective_check 2>/dev/null || true)"
[ -n "${verify_result:-}" ] || verify_result="unknown"
verify_result="$(tun_on_verify_result "$verify_result")"
case "$verify_result" in
ok)
mark_tun_last_action "on" "success" "effective"
mark_tun_last_verification "success" "effective"
;;
policy-routing-likely-effective)
mark_tun_last_action "on" "success" "$verify_result"
mark_tun_last_verification "success" "$verify_result"
;;
*)
mark_tun_last_action "on" "partial" "$verify_result"
mark_tun_last_verification "partial" "$verify_result"
;;
esac
print_tun_on_feedback "$verify_result"
}
cmd_tun_off() {
local verify_result
prepare
if ! sync_tun_target_state "off" "false"; then
return 1
fi
if status_is_running; then
service_restart
fi
verify_result="$(tun_disable_check 2>/dev/null || true)"
[ -n "${verify_result:-}" ] || verify_result="unknown"
if [ "$verify_result" = "ok" ]; then
mark_tun_last_action "off" "success" "rolled-back"
mark_tun_last_verification "success" "disabled"
else
mark_tun_last_action "off" "partial" "$verify_result"
mark_tun_last_verification "partial" "$verify_result"
fi
print_tun_off_feedback "$verify_result"
}
tun_runtime_status_text() {
if [ "$(tun_enabled)" = "true" ]; then
echo "已开启"
else
echo "未开启"
fi
}
doctor_tun_checks() {
local env_type stack runtime_tun_status backend dns_listen_value
local runtime_tun_enabled runtime_tun_stack runtime_tun_auto_route runtime_tun_auto_redirect
local effective_result route_dev primary_reason cap_rc route_takeover
local process_cap_rc
local problem_lines recommendation_lines rc
trap 'rc=$?; tun_doctor_report_failure "doctor_tun_checks" "$rc" "$BASH_COMMAND"; trap - ERR; return "$rc"' ERR
prepare
echo
echo "🐱 Tun 诊断"
echo
runtime_tun_status="$(tun_runtime_status_text)"
stack="$(tun_stack)"
backend="$(runtime_backend)"
env_type="$(container_env_type)"
runtime_tun_enabled="$(runtime_config_tun_enabled 2>/dev/null || echo false)"
runtime_tun_stack="$(runtime_config_tun_stack 2>/dev/null || echo "")"
runtime_tun_auto_route="$(runtime_config_tun_auto_route 2>/dev/null || echo false)"
runtime_tun_auto_redirect="$(runtime_config_tun_auto_redirect 2>/dev/null || echo false)"
route_dev="$(default_route_dev 2>/dev/null || true)"
route_takeover="unknown"
if [ -n "${route_dev:-}" ]; then
if default_route_is_tun_like 2>/dev/null; then
route_takeover="yes"
else
route_takeover="no"
fi
fi
if has_cap_net_admin >/dev/null 2>&1; then
cap_rc=0
else
cap_rc=$?
fi
process_cap_rc=2
case "$backend" in
systemd|systemd-user)
if tun_process_has_cap_net_admin "$backend" >/dev/null 2>&1; then
process_cap_rc=0
else
process_cap_rc=$?
fi
;;
esac
echo "【总体结论】"
if [ "$process_cap_rc" -eq 0 ]; then
echo "🐱 mihomo 进程已检测到 CAP_NET_ADMIN;当前执行环境 capability 不作为主因"
elif [ "$cap_rc" -eq 1 ]; then
echo "❗ 当前执行环境未检测到 CAP_NET_ADMIN;root 不等于一定拥有 CAP_NET_ADMIN"
elif can_manage_tun_safely; then
echo "🐱 当前环境满足基础 Tun 开启条件"
else
echo "❗ 当前环境不满足基础 Tun 开启条件"
fi
echo "🧪 Tun 是否生效要看 mihomo 进程实际拿到的网络管理能力,能力边界可能来自 systemd unit 或容器环境"
echo "🧪 当前 Tun 状态:$runtime_tun_status"
echo "🔧 Tun stack:$stack"
echo "🔧 运行后端:$backend"
echo "🚀 当前内核:$(runtime_kernel_type 2>/dev/null || echo unknown)"
echo "🧩 Tun 支持等级:$(tun_kernel_support_text 2>/dev/null || echo 未知)"
echo "💻 环境类型:$env_type"
echo "📜 容器裁决:$(tun_container_mode_text 2>/dev/null || echo 未知)"
echo "💡 内核说明:$(tun_kernel_support_reason 2>/dev/null || echo 未知)"
if [ "$(tun_container_mode 2>/dev/null || echo unknown)" = "container-risky" ]; then
local gate_reason
gate_reason="$(tun_container_risk_reason 2>/dev/null || true)"
[ -n "${gate_reason:-}" ] && echo "❗ 阻断原因:$gate_reason"
fi
echo
echo "【发现的问题】"
problem_lines="$(tun_problem_lines)" || {
rc=$?
tun_doctor_report_failure "tun_problem_lines" "$rc" "collect problem lines"
trap - ERR
return "$rc"
}
if [ -n "${problem_lines:-}" ]; then
printf '%s\n' "$problem_lines" | sed 's/^/ /'
else
echo " 🐱 未发现明显问题"
fi
echo
echo "【关键证据】"
if is_root_user; then
echo " 🐱 当前执行用户:root"
else
echo " 🚨 当前执行用户:非 root"
fi
echo " 🔧 运行后端:$backend"
echo " 💻 是否容器:$(tun_doctor_container_evidence_text "$env_type")"
if tun_device_exists; then
echo " 🐱 /dev/net/tun:存在"
else
echo " ❗ /dev/net/tun:不存在"
fi
if tun_device_exists; then
if tun_device_readable; then
echo " 🐱 /dev/net/tun:可读写"
else
echo " 🚨 /dev/net/tun:存在但不可正常读写"
fi
fi
case "$cap_rc" in
0)
echo " 🐱 CAP_NET_ADMIN:已检测到"
;;
2)
echo " 🚨 CAP_NET_ADMIN:无法精确判断(缺少 capsh)"
;;
*)
echo " 🚨 CAP_NET_ADMIN:未检测到"
;;
esac
if has_ip_command; then
echo " 🐱 ip 命令:可用"
else
echo " 🚨 ip 命令:缺失"
fi
if runtime_config_exists; then
dns_listen_value="$("$(yq_bin)" eval '.dns.listen // ""' "$RUNTIME_DIR/config.yaml" 2>/dev/null | head -n 1)"
if [ -n "${dns_listen_value:-}" ] && [ "$dns_listen_value" != "null" ]; then
echo " 🐱 DNS 监听:$dns_listen_value"
else
echo " 🚨 DNS 监听:未解析到"
fi
echo " 🧩 runtime tun.enable:${runtime_tun_enabled:-false}"
if [ -n "${runtime_tun_stack:-}" ] && [ "$runtime_tun_stack" != "null" ]; then
echo " 🧩 runtime tun.stack:$runtime_tun_stack"
fi
echo " 📜 runtime tun.auto-route:${runtime_tun_auto_route:-false}"
echo " 📜 runtime tun.auto-redirect:${runtime_tun_auto_redirect:-false}"
if [ "${runtime_tun_auto_redirect:-false}" != "true" ] \
&& [ "$env_type" = "host" ] \
&& [ "$(runtime_kernel_type 2>/dev/null || echo mihomo)" = "mihomo" ]; then
echo " 💡 auto-redirect 建议:裸 TCP 透明接管超时时,设置 CLASH_TUN_AUTO_REDIRECT=true 后重新执行 clashctl tun on"
fi
else
echo " 🚨 运行时配置:不存在"
fi
if [ -n "${route_dev:-}" ]; then
echo " 🌐 默认路由设备:$route_dev"
case "$route_takeover" in
yes)
echo " 🐱 默认路由接管:看起来已接管(设备命中 tun/clash/mihomo 特征)"
;;
no)
echo " 📜 主默认路由:仍指向 $route_dev(仅代表 main table;Linux Tun 可通过 policy routing / rule table 生效)"
;;
*)
echo " 📜 主默认路由:未知"
;;
esac
else
echo " 🚨 默认路由设备:未解析到"
echo " 📜 主默认路由:未知"
fi
echo " 🧪 Tun 状态文件:$runtime_tun_status"
echo " 📜 mihomo 进程能力:$(tun_process_capability_text "$backend")"
echo " 📜 unit capability:$(tun_unit_capability_text "$backend")"
tun_doctor_log_evidence
tun_doctor_policy_evidence
tun_doctor_ip_rule_evidence
tun_doctor_route_table_evidence
echo
echo "【生效验证】"
if [ "$(tun_enabled 2>/dev/null || echo false)" = "true" ]; then
effective_result="$(tun_effective_check 2>/dev/null || true)"
primary_reason="$(tun_doctor_primary_reason "$effective_result" "$backend" "$route_takeover")" || {
rc=$?
tun_doctor_report_failure "tun_doctor_primary_reason" "$rc" "derive primary reason"
trap - ERR
return "$rc"
}
tun_doctor_conclusion_line "$primary_reason" | sed 's/^/ /'
else
primary_reason="$(tun_doctor_primary_reason "tun-disabled" "$backend" "$route_takeover")" || {
rc=$?
tun_doctor_report_failure "tun_doctor_primary_reason" "$rc" "derive primary reason"
trap - ERR
return "$rc"
}
tun_doctor_conclusion_line "$primary_reason" | sed 's/^/ /'
fi
echo
echo "【建议操作】"
recommendation_lines="$(tun_recommendation_lines "$primary_reason")" || {
rc=$?
tun_doctor_report_failure "tun_recommendation_lines" "$rc" "collect recommendation lines"
trap - ERR
return "$rc"
}
printf '%s\n' "$recommendation_lines" | sed 's/^/ /'
echo
trap - ERR
}
tun_doctor_report_failure() {
local function_name="$1"
local rc="$2"
local step="$3"
echo
ui_error "Tun doctor 中途失败:$function_name"
ui_kv "!" "失败步骤" "${step:-unknown}"
ui_kv "!" "返回码" "$rc"
ui_next "请带着上面的失败点重新排查或提交问题"
echo
}
tun_doctor_container_evidence_text() {
case "${1:-unknown}" in
host)
echo "否(host)"
;;
docker|container|lxc)
echo "是($1)"
;;
*)
echo "未知(${1:-unknown})"
;;
esac
}
tun_runtime_pid() {
local backend="${1:-unknown}"
local pid_file pid unit
unit="$(service_unit_name 2>/dev/null || echo clash-for-linux.service)"
case "$backend" in
systemd)
if command -v systemctl >/dev/null 2>&1; then
pid="$(systemctl show "$unit" --property MainPID --value 2>/dev/null || true)"
if [ -n "${pid:-}" ] && [ "$pid" != "0" ]; then
echo "$pid"
return 0
fi
fi
;;
systemd-user)
if command -v systemctl >/dev/null 2>&1; then
pid="$(systemctl --user show "$unit" --property MainPID --value 2>/dev/null || true)"
if [ -n "${pid:-}" ] && [ "$pid" != "0" ]; then
echo "$pid"
return 0
fi
fi
;;
esac
pid_file="$RUNTIME_DIR/mihomo.pid"
if [ -f "$pid_file" ]; then
pid="$(cat "$pid_file" 2>/dev/null || true)"
if [ -n "${pid:-}" ]; then
echo "$pid"
return 0
fi
fi
return 1
}
tun_process_capability_text() {
local backend="${1:-unknown}"
local pid=""
local cap_eff=""
local cap_value=0
local net_admin="no"
local net_raw="no"
pid="$(tun_runtime_pid "$backend" 2>/dev/null || true)"
if [ -z "${pid:-}" ] || [ ! -r "/proc/$pid/status" ]; then
echo "未读取到运行中 mihomo 进程能力"
return 0
fi
cap_eff="$(
sed -nE 's/^CapEff:[[:space:]]*([0-9a-fA-F]+)$/\1/p' "/proc/$pid/status" 2>/dev/null \
| head -n 1
)"
if [ -z "${cap_eff:-}" ]; then
echo "pid=$pid,未读取到 CapEff"
return 0
fi
case "$cap_eff" in
*[!0-9a-fA-F]*)
echo "pid=$pid,CapEff 格式异常:$cap_eff"
return 0
;;
esac
cap_value=$((16#$cap_eff))
if (( cap_value & (1 << 12) )); then
net_admin="yes"
fi
if (( cap_value & (1 << 13) )); then
net_raw="yes"
fi
echo "pid=$pid,CapEff=$cap_eff,CAP_NET_ADMIN=$net_admin,CAP_NET_RAW=$net_raw"
}
tun_process_has_cap_net_admin() {
local backend="${1:-unknown}"
local pid=""
local cap_eff=""
local cap_value=0
pid="$(tun_runtime_pid "$backend" 2>/dev/null || true)"
[ -n "${pid:-}" ] && [ -r "/proc/$pid/status" ] || return 2
cap_eff="$(
sed -nE 's/^CapEff:[[:space:]]*([0-9a-fA-F]+)$/\1/p' "/proc/$pid/status" 2>/dev/null \
| head -n 1
)"
[ -n "${cap_eff:-}" ] || return 2
case "$cap_eff" in
*[!0-9a-fA-F]*)
return 2
;;
esac
cap_value=$((16#$cap_eff))
if (( cap_value & (1 << 12) )); then
return 0
fi
return 1
}
tun_log_tun_adapter_line() {
local log_file="$LOG_DIR/mihomo.out.log"
[ -f "$log_file" ] || return 1
grep -E '\[TUN\].*Tun adapter|Tun adapter listening' "$log_file" 2>/dev/null | tail -n 1
}
tun_log_tun_source_line() {
local log_file="$LOG_DIR/mihomo.out.log"
[ -f "$log_file" ] || return 1
grep -E '(^|[^0-9])28\.0\.0\.[0-9]+:[0-9]+.*-->' "$log_file" 2>/dev/null | tail -n 1
}
tun_log_has_tun_traffic_evidence() {
tun_log_tun_adapter_line >/dev/null 2>&1 || return 1
tun_log_tun_source_line >/dev/null 2>&1 || return 1
}
tun_policy_rule_line() {
has_ip_command 2>/dev/null || return 1
ip rule show 2>/dev/null \
| grep -E '(lookup 2022|iif Meta|not from all iif lo lookup 2022)' \
| head -n 1
}
tun_policy_route_line() {
has_ip_command 2>/dev/null || return 1
ip route show table all 2>/dev/null \
| grep -E '(^|[[:space:]])default .* dev (Meta|tun|utun|mihomo|clash)([[:space:]].*)? table 2022|table 2022 .*default .* dev (Meta|tun|utun|mihomo|clash)' \
| head -n 1
}
tun_has_policy_routing_evidence() {
tun_log_tun_adapter_line >/dev/null 2>&1 || return 1
tun_policy_rule_line >/dev/null 2>&1 || return 1
tun_policy_route_line >/dev/null 2>&1 || return 1
}
tun_doctor_ip_rule_evidence() {
if ! has_ip_command 2>/dev/null; then
echo " 🚨 ip rule:缺少 ip 命令,无法读取"
return 0
fi
echo " 📜 ip rule:"
ip rule show 2>/dev/null | sed 's/^/ /' || echo " 读取失败"
}
tun_doctor_route_table_evidence() {
if ! has_ip_command 2>/dev/null; then
echo " 🚨 ip route table all:缺少 ip 命令,无法读取"
return 0
fi
echo " 📜 ip route show table all:"
ip route show table all 2>/dev/null | sed -n '1,80p' | sed 's/^/ /' || echo " 读取失败"
}
tun_doctor_log_evidence() {
local adapter_line traffic_line
adapter_line="$(tun_log_tun_adapter_line 2>/dev/null || true)"
traffic_line="$(tun_log_tun_source_line 2>/dev/null || true)"
if [ -n "${adapter_line:-}" ]; then
echo " 🐱 Tun adapter 日志:$adapter_line"
else
echo " 🚨 Tun adapter 日志:未在 mihomo.out.log 中找到"
fi
if [ -n "${traffic_line:-}" ]; then
echo " 🐱 Tun 流量日志:$traffic_line"
else
echo " 🚨 Tun 流量日志:未发现 28.0.0.x Tun 源地址流量"
fi
}
tun_doctor_policy_evidence() {
local rule_line route_line
rule_line="$(tun_policy_rule_line 2>/dev/null || true)"
route_line="$(tun_policy_route_line 2>/dev/null || true)"
if [ -n "${rule_line:-}" ]; then
echo " 🐱 Tun policy rule:$rule_line"
else
echo " 🚨 Tun policy rule:未发现 lookup 2022 / iif Meta 相关规则"
fi
if [ -n "${route_line:-}" ]; then
echo " 🐱 Tun policy route:$route_line"
else
echo " 🚨 Tun policy route:未发现 table 2022 默认路由指向 Meta/tun 设备"
fi
}
tun_unit_capability_text() {
local backend="${1:-unknown}"
local unit unit_file content
unit="$(service_unit_name 2>/dev/null || echo clash-for-linux.service)"
case "$backend" in
systemd)
if command -v systemctl >/dev/null 2>&1; then
content="$(systemctl cat "$unit" 2>/dev/null || true)"
fi
unit_file="/etc/systemd/system/$unit"
;;
systemd-user)
if command -v systemctl >/dev/null 2>&1; then
content="$(systemctl --user cat "$unit" 2>/dev/null || true)"
fi
unit_file="$HOME/.config/systemd/user/$unit"
;;
*)
echo "非 systemd 后端,不适用 unit capability 声明"
return 0
;;
esac
if [ -z "${content:-}" ] && [ -f "$unit_file" ]; then
content="$(cat "$unit_file" 2>/dev/null || true)"
fi
if [ -z "${content:-}" ]; then
echo "未读取到 unit 内容,无法判断是否显式声明 capability"
return 0
fi
if printf '%s\n' "$content" | grep -Eq '^(AmbientCapabilities|CapabilityBoundingSet)=.*CAP_NET_(ADMIN|RAW)'; then
printf '%s\n' "$content" \
| grep -E '^(AmbientCapabilities|CapabilityBoundingSet)=' \
| tr '\n' ';' \
| sed 's/;$//;s/^/已显式声明:/'
return 0
fi
if printf '%s\n' "$content" | grep -Eq '^(AmbientCapabilities|CapabilityBoundingSet)='; then
printf '%s\n' "$content" \
| grep -E '^(AmbientCapabilities|CapabilityBoundingSet)=' \
| tr '\n' ';' \
| sed 's/;$//;s/^/已声明 capability,但未看到 CAP_NET_ADMIN 或 CAP_NET_RAW:/'
return 0
fi
echo "未看到 AmbientCapabilities/CapabilityBoundingSet 显式声明"
}
tun_doctor_primary_reason() {
local effective_result backend route_takeover cap_rc process_cap_rc
effective_result="${1:-unknown}"
backend="${2:-unknown}"
route_takeover="${3:-unknown}"
process_cap_rc=2
case "$backend" in
systemd|systemd-user)
if tun_process_has_cap_net_admin "$backend" >/dev/null 2>&1; then
process_cap_rc=0
else
process_cap_rc=$?
fi
;;
esac
if tun_has_policy_routing_evidence 2>/dev/null; then
case "$effective_result" in
ok)
echo "ok"
return 0
;;
disabled-in-state|disabled-in-runtime-config|tun-disabled|runtime-not-running|controller-unreachable)
;;
*)
echo "policy-routing-likely-effective"
return 0
;;
esac
fi
if [ "$process_cap_rc" -eq 0 ]; then
case "$effective_result" in
ok)
echo "ok"
return 0
;;
disabled-in-state|disabled-in-runtime-config|tun-disabled|runtime-not-running|controller-unreachable)
;;
host-ip-unavailable|current-ip-unavailable|traffic-same-as-host)
if [ "$route_takeover" = "no" ]; then
echo "main-route-unchanged-needs-policy-check"
return 0
fi
;;
*)
if [ "$route_takeover" = "no" ]; then
echo "main-route-unchanged-needs-policy-check"
return 0
fi
;;
esac
fi
if tun_log_has_tun_traffic_evidence 2>/dev/null; then
case "$effective_result" in
ok)
echo "ok"
return 0
;;
disabled-in-state|disabled-in-runtime-config|tun-disabled|runtime-not-running|controller-unreachable)
;;
*)
echo "main-route-unchanged-needs-policy-check"
return 0
;;
esac
fi
if has_cap_net_admin >/dev/null 2>&1; then
cap_rc=0
else
cap_rc=$?
fi
if [ "$cap_rc" -eq 1 ] && [ "$process_cap_rc" -ne 0 ]; then
echo "missing-cap-net-admin"
return 0
fi
if ! tun_device_exists 2>/dev/null; then
echo "missing-tun-device"
return 0
fi
if tun_device_exists 2>/dev/null && ! tun_device_readable 2>/dev/null; then
echo "tun-device-not-readable"
return 0
fi
if ! has_ip_command 2>/dev/null; then
echo "missing-ip-command"
return 0
fi
if [ "$route_takeover" = "no" ]; then
case "$effective_result" in
ok|disabled-in-state|disabled-in-runtime-config|tun-disabled|runtime-not-running|controller-unreachable)
;;
*)
echo "main-route-unchanged-needs-policy-check"
return 0
;;
esac
fi
case "$effective_result" in
controller-unreachable|runtime-not-running|disabled-in-state|disabled-in-runtime-config|tun-disabled|host-ip-unavailable|current-ip-unavailable|traffic-same-as-host|default-route-not-tun|main-route-unchanged-needs-policy-check)
echo "$effective_result"
;;
*)
if [ "$route_takeover" = "no" ]; then
echo "main-route-unchanged-needs-policy-check"
return 0
fi
echo "traffic-check-failed"
;;
esac
}
tun_doctor_conclusion_line() {
case "${1:-traffic-check-failed}" in
ok)
if tun_has_policy_routing_evidence 2>/dev/null; then
echo "🐱 最终状态:已生效(Tun + policy routing + 主动流量验证通过)"
else
echo "🐱 最终状态:已生效(主动流量验证通过)"
fi
;;
policy-routing-likely-effective)
if tun_log_tun_source_line >/dev/null 2>&1; then
echo "🐱 最终状态:已生效(Tun + policy routing 已安装,并观察到 Tun 源地址流量)"
else
echo "🟡 最终状态:很可能已生效(Tun + policy routing 已安装;主动流量验证不足 / 未观察到明确 Tun 源地址日志)"
fi
;;
missing-cap-net-admin)
echo "❗ Tun 未生效:当前能力检测未通过(CAP_NET_ADMIN)"
;;
missing-tun-device)
echo "❗ Tun 未生效:缺少 /dev/net/tun"
;;
tun-device-not-readable)
echo "❗ Tun 未生效:/dev/net/tun 不可读写"
;;
missing-ip-command)
echo "❗ Tun 未生效:缺少 ip 命令"
;;
runtime-not-running)
echo "❗ Tun 未生效:代理内核未运行"
;;
controller-unreachable)
echo "❗ Tun 未生效:控制器不可访问"
;;
disabled-in-state|disabled-in-runtime-config)
echo "❗ Tun 未生效:Tun 状态与运行配置不一致"
;;
tun-disabled)
echo "❗ Tun 未生效:Tun 未开启"
;;
host-ip-unavailable|current-ip-unavailable)
echo "❗ Tun 未生效:无法完成公网出口验证"
;;
traffic-same-as-host)
if tun_log_has_tun_traffic_evidence 2>/dev/null; then
echo "🟡 流量出口验证仍与本机公网 IP 一致,但已看到 Tun adapter 与 Tun 源地址流量;需进一步确认是否为策略路由/部分生效"
else
echo "🟡 主动流量验证不足 / 未观察到明确 Tun 源地址日志;需结合 policy routing 证据确认"
fi
;;
default-route-not-tun|main-route-unchanged-needs-policy-check)
if tun_log_has_tun_traffic_evidence 2>/dev/null; then
echo "🟡 主默认路由仍指向物理网卡,但已看到 Tun adapter 与 Tun 源地址流量;需进一步确认是否为策略路由/部分生效"
else
echo "🟡 主默认路由仍指向物理网卡;需结合 ip rule / table all / 日志确认是否为策略路由或部分生效"
fi
;;
*)
echo "🟡 主动流量验证不足 / 未观察到明确 Tun 源地址日志;需结合 policy routing 证据确认"
;;
esac
}
tun_doctor_action_lines() {
local reason backend unit
reason="${1:-traffic-check-failed}"
backend="$(runtime_backend 2>/dev/null || echo unknown)"
unit="$(service_unit_name 2>/dev/null || echo clash-for-linux.service)"
case "$reason" in
missing-cap-net-admin)
if [ "$(container_env_type 2>/dev/null || echo unknown)" != "host" ]; then
tun_container_runtime_hint_lines || true
else
case "$backend" in
systemd)
echo "👉 当前运行后端为 systemd;若关键证据显示 unit 未声明能力,可尝试为服务显式补 CAP_NET_ADMIN / CAP_NET_RAW:"
echo " sudo systemctl edit $unit"
;;
systemd-user)
echo "👉 当前运行后端为 systemd-user;请结合容器与 unit 证据判断,必要时改用系统服务或显式补 CAP_NET_ADMIN / CAP_NET_RAW"
;;
script)
echo "👉 当前运行后端为 script;请确认启动 mihomo 的实际进程具备 CAP_NET_ADMIN / CAP_NET_RAW"
echo "👉 若当前就是主机脚本模式,可尝试提升权限后重新执行:sudo clashctl tun on"
;;
*)
echo "👉 请结合运行后端、容器环境和进程能力证据,确认 mihomo 实际拥有 CAP_NET_ADMIN / CAP_NET_RAW"
;;
esac
fi
;;
missing-tun-device)
if [ "$(container_env_type 2>/dev/null || echo unknown)" != "host" ]; then
tun_container_runtime_hint_lines || true
else
echo "👉 请先挂载或启用 /dev/net/tun"
fi
;;
tun-device-not-readable)
echo "👉 请修复 /dev/net/tun 权限,确保当前运行用户可读写"
;;
missing-ip-command)
if [ "$(container_env_type 2>/dev/null || echo unknown)" != "host" ]; then
tun_container_runtime_hint_lines || true
else
case "$(install_env_os_variant 2>/dev/null || true)" in
openwrt)
echo "👉 当前环境缺少 ip 命令;OpenWrt 可安装:opkg update && opkg install ip-full"
;;
*)
echo "👉 Debian/Ubuntu 可安装:apt update && apt install -y iproute2"
echo "👉 其他发行版请安装提供 ip 命令的 iproute / iproute2 软件包"
;;
esac
fi
;;
runtime-not-running)
echo "👉 请先启动代理:clashon"
;;
controller-unreachable)
echo "👉 请先修复控制器可访问性:clashctl doctor"
;;
disabled-in-state|disabled-in-runtime-config)
echo "👉 请重新同步 Tun 配置:clashctl tun off && clashctl tun on"
;;
tun-disabled)
echo "👉 请开启 Tun:clashctl tun on"
;;
host-ip-unavailable|current-ip-unavailable)
echo "👉 请先确认服务器可访问 https://ip.sb:curl https://ip.sb"
;;
traffic-same-as-host)
if tun_log_has_tun_traffic_evidence 2>/dev/null; then
echo "👉 已看到 Tun 流量证据,但公网出口验证仍未通过;请结合 ip rule、ip route show table all 和内核日志判断是否为策略路由/部分生效"
else
echo "👉 当前流量仍走本机出口,请检查 Tun 路由规则和内核日志:clashctl logs"
fi
;;
default-route-not-tun|main-route-unchanged-needs-policy-check)
echo "👉 主默认路由仍指向物理网卡;请结合 ip rule、ip route show table all 和 Tun 流量日志确认是否为策略路由/部分生效"
;;
policy-routing-likely-effective)
if tun_log_tun_source_line >/dev/null 2>&1; then
echo "👉 已看到 Tun policy routing 与 Tun 源地址流量;如仍有访问异常,请重点检查分流规则和内核日志:clashctl logs"
else
echo "👉 Tun policy routing 已安装;主动流量验证不足,建议发起一次直连外网访问后重新执行:clashctl tun doctor"
fi
;;
*)
echo "👉 请查看运行日志定位流量验证失败原因:clashctl logs"
;;
esac
}
tun_recommendation_lines() {
local enabled env_type can_enable container_mode risk_reason
local effective_result disable_result
local config_tun_enabled auto_route
local kernel_support backend route_takeover route_dev
local primary_reason
primary_reason="${1:-}"
kernel_support="$(tun_kernel_support_level 2>/dev/null || echo unknown)"
backend="$(runtime_backend 2>/dev/null || echo unknown)"
enabled="$(tun_enabled 2>/dev/null || echo false)"
env_type="$(container_env_type 2>/dev/null || echo unknown)"
container_mode="$(tun_container_mode 2>/dev/null || echo unknown)"
risk_reason="$(tun_container_risk_reason 2>/dev/null || true)"
config_tun_enabled="$(runtime_config_tun_enabled 2>/dev/null || echo false)"
auto_route="$(runtime_config_tun_auto_route 2>/dev/null || echo false)"
route_dev="$(default_route_dev 2>/dev/null || true)"
route_takeover="unknown"
if [ -n "${route_dev:-}" ]; then
if default_route_is_tun_like 2>/dev/null; then
route_takeover="yes"
else
route_takeover="no"
fi
fi
can_enable="false"
if can_manage_tun_safely 2>/dev/null; then
can_enable="true"
fi
if [ "$enabled" != "true" ] && [ -n "${primary_reason:-}" ] && [ "$primary_reason" != "tun-disabled" ]; then
tun_doctor_action_lines "$primary_reason"
return 0
fi
if [ "$kernel_support" = "limited" ] && [ "$enabled" != "true" ]; then
echo "1. 当前内核为 clash,Tun 仅按降级支持处理"
echo "2. 如需最稳妥 Tun 体验,建议先执行:clashctl config kernel mihomo"
echo "3. 再开启 Tun:clashctl tun on"
return 0
fi
if [ "$enabled" = "true" ]; then
effective_result="$(tun_effective_check 2>/dev/null || true)"
[ -n "${effective_result:-}" ] || effective_result="unknown"
[ -n "${primary_reason:-}" ] || primary_reason="$(tun_doctor_primary_reason "$effective_result" "$backend" "$route_takeover")"
if [ "$effective_result" != "ok" ]; then
tun_doctor_action_lines "$primary_reason"
return 0
fi
echo "1. Tun 已生效,可继续使用"
echo "2. 如需恢复普通代理模式,执行:clashctl tun off"
if [ "$kernel_support" = "limited" ]; then
echo "3. 当前 Tun 运行在 clash 内核上,如需更稳妥体验可切换:clashctl config kernel mihomo"
fi
return 0
fi
disable_result="$(tun_disable_check 2>/dev/null || true)"
[ -n "${disable_result:-}" ] || disable_result="unknown"
if [ "$disable_result" != "ok" ]; then
echo "1. Tun 关闭后仍有残留,建议执行:clashctl tun off"
echo "2. 如仍异常,执行:clashoff && clashon"
return 0
fi
if [ "$can_enable" != "true" ]; then
case "$container_mode" in
container-risky)
echo "👉 当前容器环境已被裁决为高风险:${risk_reason:-容器条件不足}"
tun_container_runtime_hint_lines || true
echo "👉 条件满足后再执行:clashctl tun on"
;;
*)
echo "1. 当前环境不满足 Tun 基础条件"
echo "2. 优先检查:/dev/net/tun、CAP_NET_ADMIN、ip 命令"
echo "3. 条件满足后再执行:clashctl tun on"
;;
esac
return 0
fi
if [ "${config_tun_enabled:-false}" = "true" ] && [ "$enabled" != "true" ]; then
echo "1. 当前运行配置仍保留 Tun 开启状态"
echo "2. 建议先执行:clashctl tun off"
echo "3. 再观察:clashctl tun doctor"
return 0
fi
case "$container_mode" in
container-safe)
echo "1. 当前容器环境已通过保守裁决,可尝试开启 Tun:clashctl tun on"
echo "2. 开启后立即执行:clashctl tun doctor"
echo "3. 若未生效,优先检查宿主机权限与设备映射"
return 0
;;
container-risky)
echo "👉 当前容器环境属于高风险,不建议直接开启 Tun:${risk_reason:-容器条件不足}"
tun_container_runtime_hint_lines || true
echo "👉 修复后再执行:clashctl tun on"
return 0
;;
esac
echo "1. 当前环境可尝试开启 Tun:clashctl tun on"
echo "2. 开启后建议立即执行:clashctl tun doctor"
}
tun_problem_lines() {
local enabled env_type config_tun_enabled auto_route
local effective_result disable_result route_dev
local container_mode risk_reason
local last_action_result last_action_reason last_action_time
local kernel_support
kernel_support="$(tun_kernel_support_level 2>/dev/null || echo unknown)"
container_mode="$(tun_container_mode 2>/dev/null || echo unknown)"
risk_reason="$(tun_container_risk_reason 2>/dev/null || true)"
last_action_result="$(status_tun_last_action_result 2>/dev/null || true)"
last_action_reason="$(status_tun_last_action_reason 2>/dev/null || true)"
last_action_time="$(status_tun_last_action_time 2>/dev/null || true)"
enabled="$(tun_enabled 2>/dev/null || echo false)"
env_type="$(container_env_type 2>/dev/null || echo unknown)"
config_tun_enabled="$(runtime_config_tun_enabled 2>/dev/null || echo false)"
auto_route="$(runtime_config_tun_auto_route 2>/dev/null || echo false)"
route_dev="$(default_route_dev 2>/dev/null || true)"
if ! tun_device_exists 2>/dev/null; then
echo "• /dev/net/tun 不存在"
fi
if tun_device_exists 2>/dev/null && ! tun_device_readable 2>/dev/null; then
echo "• /dev/net/tun 存在但不可正常读写"
fi
if ! has_ip_command 2>/dev/null; then
echo "• 缺少 ip 命令,无法进行完整路由校验"
fi
if ! can_manage_tun_safely 2>/dev/null; then
echo "• 当前环境不满足 Tun 安全开启条件"
fi
if [ "$env_type" != "host" ]; then
case "$container_mode" in
container-safe)
echo "• 当前处于容器环境,Tun 虽可尝试开启,但建议重点关注流量验证结果"
;;
container-risky)
echo "• 当前处于高风险容器环境:${risk_reason:-容器条件不足}"
;;
*)
echo "• 当前处于容器环境,Tun 状态未知"
;;
esac
fi
case "$kernel_support" in
limited)
echo "• 当前内核为 clash,Tun 仅按降级支持处理,稳定性可能弱于 mihomo"
;;
unknown)
echo "• 当前内核的 Tun 支持等级未知"
;;
esac
if [ "${last_action_result:-}" = "failed" ]; then
if [ -n "${last_action_time:-}" ]; then
echo "• 最近一次 Tun 配置同步失败:${last_action_reason:-unknown} @ ${last_action_time}"
else
echo "• 最近一次 Tun 配置同步失败:${last_action_reason:-unknown}"
fi
fi
if ! runtime_config_exists 2>/dev/null; then
echo "• 运行时配置不存在"
return 0
fi
if [ "$enabled" = "true" ] && [ "${config_tun_enabled:-false}" != "true" ]; then
echo "• Tun 状态文件已开启,但 runtime/config.yaml 未启用 tun.enable"
fi
if [ "$enabled" != "true" ] && [ "${config_tun_enabled:-false}" = "true" ]; then
echo "• Tun 状态文件已关闭,但 runtime/config.yaml 仍启用 tun.enable"
fi
if [ "$enabled" = "true" ]; then
effective_result="$(tun_effective_check 2>/dev/null || true)"
if [ "$effective_result" != "ok" ]; then
if tun_has_policy_routing_evidence 2>/dev/null; then
if tun_log_tun_source_line >/dev/null 2>&1; then
echo "• Tun + policy routing 已安装,并观察到 Tun 源地址流量;主动出口验证仍需结合场景确认"
else
echo "• Tun + policy routing 已安装;主动流量验证不足 / 未观察到明确 Tun 源地址日志"
fi
elif tun_log_has_tun_traffic_evidence 2>/dev/null; then
echo "• 已看到 Tun adapter 与 Tun 源地址流量;需结合 policy routing 证据确认覆盖范围"
else
echo "• 主动流量验证不足 / 未观察到明确 Tun 源地址日志"
fi
fi
else
disable_result="$(tun_disable_check 2>/dev/null || true)"
if [ "$disable_result" != "ok" ]; then
echo "• Tun 当前虽为关闭态,但仍存在残留:${disable_result:-unknown}"
fi
fi
}
sync_tun_target_state() {
local action="$1"
local target="$2"
local previous sync_output rc error_summary
previous="$(tun_enabled 2>/dev/null || echo false)"
set_tun_enabled "$target"
if sync_output="$(regenerate_config 2>&1)"; then
return 0
fi
rc=$?
set_tun_enabled "$previous"
error_summary="$(build_last_error_summary 2>/dev/null || true)"
if [ -z "${error_summary:-}" ]; then
error_summary="$(printf '%s\n' "$sync_output" | sed '/^[[:space:]]*$/d' | tail -n 1)"
fi
error_summary="$(single_line_text "${error_summary:-配置重建失败}")"
[ -n "${error_summary:-}" ] || error_summary="配置重建失败"
mark_tun_last_action "$action" "failed" "$error_summary"
mark_tun_last_verification "failed" "config-sync-failed"
ui_error "Tun 配置同步失败,已回滚状态文件"
ui_error "$error_summary"
ui_next "clashctl tun doctor"
return "$rc"
}
cmd_tun() {
case "${1:-}" in
""|status)
cmd_tun_status
;;
on)
cmd_tun_on
;;
off)
cmd_tun_off
;;
doctor)
doctor_tun_checks
;;
*)
ui_title "🧪 Tun 模式管理"
echo "📜 用法:"
echo " clashctl tun"
echo " clashctl tun status"
echo " clashctl tun on"
echo " clashctl tun off"
echo " clashctl tun doctor"
echo
echo "🧩 说明:"
echo " Tun 属于高级接管能力"
echo " 开启前应确认环境支持 /dev/net/tun、权限与网络能力"
echo
echo "💡 常用动作:"
echo " clashctl tun status"
echo " clashctl tun doctor"
echo " clashtun on"
echo
ui_next "clashctl tun doctor"
ui_blank
;;
esac
}
add_trim_input() {
printf '%s' "${1:-}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
}
add_next_subscription_name() {
local suffix="" letter candidate
while true; do
for letter in a b c d e f g h i j k l m n o p q r s t u v w x y z; do
candidate="${letter}${suffix}"
if ! subscription_name_exists "$candidate"; then
echo "$candidate"
return 0
fi
done
if [ -z "${suffix:-}" ]; then
suffix=1
else
suffix=$((suffix + 1))
fi
done
}
add_prompt_url() {
local input url
while true; do
printf "请输入订阅地址:" >&2
IFS= read -r input || return 1
url="$(add_trim_input "$input")"
if [ -n "${url:-}" ]; then
echo "$url"
return 0
fi
echo "订阅地址不能为空,请重新输入" >&2
done
}
add_prompt_name() {
local input name
while true; do
printf "请输入订阅名称(回车自动生成):" >&2
IFS= read -r input || return 1
name="$(add_trim_input "$input")"
if [ -z "${name:-}" ]; then
add_next_subscription_name
return 0
fi
if subscription_name_exists "$name"; then
echo "⚠ 名称已存在,请重新输入" >&2
continue
fi
echo "$name"
return 0
done
}
add_prompt_local_url() {
local input filename local_path
printf "请输入本地订阅文件名(默认目录:%s/runtime/subscriptions/):" "$PROJECT_DIR" >&2
IFS= read -r input || return 1
filename="$(add_trim_input "$input")"
[ -n "${filename:-}" ] || die "本地订阅文件名不能为空"
local_path="$PROJECT_DIR/runtime/subscriptions/$filename"
[ -f "$local_path" ] || die "本地订阅文件不存在:$local_path"
[ -s "$local_path" ] || die "本地订阅文件为空:$local_path"
echo "file://$local_path"
}
cmd_add() {
local sub_url sub_name sub_fmt
prepare
ensure_add_use_prerequisites
if [ "${1:-}" = "local" ]; then
[ "$#" -eq 1 ] || die_usage "add local 参数不合法" "clashctl add local"
sub_url="$(add_prompt_local_url)"
cmd_add "$sub_url"
return $?
fi
case "$#" in
0)
sub_url="$(add_prompt_url)"
sub_name="$(add_prompt_name)"
;;
1)
sub_url="$(add_trim_input "${1:-}")"
[ -n "${sub_url:-}" ] || die "订阅地址不能为空"
sub_name="$(add_prompt_name)"
;;
*)
sub_url="$(add_trim_input "${1:-}")"
[ -n "${sub_url:-}" ] || die "订阅地址不能为空"
sub_name="${2:-default}"
;;
esac
sub_fmt="$(detect_subscription_format "$sub_url")"
ui_progress_line 5 "正在保存订阅..."
set_subscription "$sub_url" "$sub_fmt" "$sub_name" "false"
ui_progress_line 65 "正在设为当前订阅..."
set_active_subscription "$sub_name"
ui_progress_line 85 "正在应用配置..."
apply_runtime_change_after_config_mutation >/dev/null
ui_progress_done "订阅添加完成"
print_add_feedback "$sub_name" "$sub_url"
cmd_ls
}
cmd_use() {
local recommended active
prepare
ensure_add_use_prerequisites
case "${1:-}" in
--recommend|-r)
recommended="$(recommended_subscription_name 2>/dev/null || true)"
active="$(active_subscription_name 2>/dev/null || true)"
if [ -z "${recommended:-}" ]; then
if [ -n "${active:-}" ]; then
ui_title "🐱 当前无更优推荐,保持当前"
ui_kv "🚩" "当前主订阅" "$active"
ui_next "clashctl select 选择节点"
ui_blank
return 0
fi
ui_title "❗ 当前没有可推荐的订阅"
ui_next "clashctl select 选择节点"
ui_blank
return 1
fi
if [ "${recommended:-}" = "${active:-}" ]; then
ui_title "🐱 当前已是推荐订阅,无需切换"
[ -n "${active:-}" ] && ui_kv "📡" "当前主订阅" "$active"
ui_next "clashctl select 选择节点"
ui_blank
return 0
fi
set_active_subscription "$recommended"
apply_runtime_change_after_config_mutation
print_use_feedback "$recommended"
return 0
;;
"")
print_use_context
use_subscription_interactive
;;
--verbose|-v)
print_use_context
use_subscription_interactive "verbose"
;;
*)
set_active_subscription "$1"
apply_runtime_change_after_config_mutation
print_use_feedback "$1"
;;
esac
}
subscription_pick_index() {
local count="$1"
local input choice
while true; do
printf "> " >&2
IFS= read -r input || return 1
choice="$(printf '%s' "$input" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
case "${choice:-}" in
q|Q)
return 1
;;
esac
if ! printf '%s' "$choice" | grep -Eq '^[0-9]+$'; then
echo "请输入有效编号" >&2
continue
fi
if [ "$choice" -ge 1 ] && [ "$choice" -le "$count" ]; then
echo "$choice"
return 0
fi
echo "请输入有效编号" >&2
done
}
use_subscription_interactive() {
local url_mode="${1:-full}"
local active idx count selected_name
local -a names
active="$(active_subscription_name 2>/dev/null || true)"
while IFS= read -r selected_name; do
[ -n "${selected_name:-}" ] || continue
names+=("$selected_name")
done < <("$(yq_bin)" eval '.sources | keys | .[]' "$(subscriptions_file)" 2>/dev/null)
count="${#names[@]}"
[ "$count" -gt 0 ] || die "当前没有任何订阅"
echo
echo "🐱 请选择订阅"
echo
if [ -n "${active:-}" ] && subscription_exists "$active"; then
echo "当前主订阅:$active"
echo
fi
print_subscription_table_header "true"
idx=1
for selected_name in "${names[@]}"; do
print_subscription_pick_line "$idx" "$selected_name" "$url_mode" "true"
idx=$((idx + 1))
done
echo
echo " q) 退出"
echo
idx="$(subscription_pick_index "$count")" || return 0
selected_name="${names[$((idx - 1))]}"
set_active_subscription "$selected_name"
apply_runtime_change_after_config_mutation
print_use_feedback "$selected_name"
}
health_overview_lines() {
local active recommended active_health active_fail active_enabled
active="$(active_subscription_name 2>/dev/null || true)"
recommended="$(recommended_subscription_name 2>/dev/null || true)"
if [ -n "${active:-}" ]; then
if subscription_enabled "$active"; then
active_enabled="enabled"
else
active_enabled="disabled"
fi
active_health="$(subscription_health_status "$active" 2>/dev/null || echo "unknown")"
active_fail="$(subscription_fail_count "$active" 2>/dev/null || echo "0")"
echo "🚩 当前主订阅:$active"
echo "❤️ 当前健康状态:$active_health"
echo "🚨 当前失败次数:$active_fail"
echo "🔧 当前启用状态:$active_enabled"
else
echo "🚩 当前主订阅:未设置"
fi
if [ -n "${recommended:-}" ] && [ "${recommended:-}" != "${active:-}" ]; then
echo "💡 推荐订阅:$recommended"
elif [ -n "${active:-}" ]; then
echo "💡 推荐订阅:保持当前"
else
echo "💡 推荐订阅:暂无"
fi
}
health_recommendation_lines() {
load_system_state
case "$SYSTEM_STATE" in
ready)
echo "💡 clashctl status"
echo "💡 clashctl select"
;;
stopped)
if [ "$SUBSCRIPTION_STATE" = "missing" ]; then
echo "💡 clashctl add <订阅链接>"
else
echo "💡 clashon"
fi
;;
degraded|broken)
echo "💡 clashctl doctor"
echo "💡 clashctl status --verbose"
;;
*)
echo "💡 clashctl status"
;;
esac
}
cmd_ls() {
prepare
case "${1:-}" in
"") ;;
*)
die_usage "ls 参数不合法" "clashctl ls"
;;
esac
ui_title "📡 订阅列表"
subscription_list_overview_lines | sed 's/^/ /'
ui_blank
list_subscriptions
ui_blank
ui_section "下一步建议"
subscription_list_recommendation_lines | sed 's/^/ /'
ui_blank
}
cmd_health() {
local verbose_mode="false"
local target_name=""
prepare
while [ $# -gt 0 ]; do
case "$1" in
--verbose|-v)
verbose_mode="true"
;;
*)
if [ -z "${target_name:-}" ]; then
target_name="$1"
else
die_usage "health 参数不合法" "clashctl health [名称] [--verbose]"
fi
;;
esac
shift
done
if [ "$verbose_mode" = "true" ]; then
if [ -n "${target_name:-}" ]; then
ui_section "单订阅详情"
print_subscription_health_one "$target_name" | sed 's/^/ /'
ui_blank
ui_section "建议操作"
echo " 💡 clashctl use ${target_name}"
echo " 💡 clashctl ls"
ui_blank
return 0
fi
ui_info "订阅健康已收敛到 clashctl ls"
cmd_ls
return $?
fi
if [ -n "${target_name:-}" ]; then
ui_section "单订阅详情"
print_subscription_health_one "$target_name" | sed 's/^/ /'
ui_blank
ui_section "建议操作"
echo " 💡 clashctl use ${target_name}"
echo " 💡 clashctl ls"
ui_blank
return 0
fi
ui_info "订阅健康已收敛到 clashctl ls"
cmd_ls
}
cmd_select() {
prepare
if [ -z "${1:-}" ]; then
print_select_context || return $?
proxy_select_interactive_guarded
return 0
fi
proxy_select_direct "$@"
}
cmd_proxy_groups() {
local group current type found="false"
prepare
if ! status_is_running; then
die_state "代理内核未运行" "clashon"
fi
if ! proxy_controller_reachable 2>/dev/null; then
die_state "控制器不可访问" "clashctl doctor"
fi
ui_title "📦 策略组列表"
echo " 📦 名称 类型 当前节点"
while IFS= read -r group; do
[ -n "${group:-}" ] || continue
found="true"
type="$(proxy_group_type "$group" 2>/dev/null || echo "unknown")"
current="$(proxy_group_current_display "$group" 2>/dev/null || true)"
[ -n "${current:-}" ] || current="<unknown>"
printf ' 📦 %-20s %-12s %s\n' "$group" "$type" "$current"
done < <(proxy_group_list)
if [ "$found" != "true" ]; then
echo " 📭 暂无可切换策略组"
fi
ui_blank
ui_next "clashctl select"
ui_blank
}
cmd_proxy_current() {
local group current found="false"
prepare
if ! status_is_running; then
die_state "代理内核未运行" "clashon"
fi
if ! proxy_controller_reachable 2>/dev/null; then
die_state "控制器不可访问" "clashctl doctor"
fi
if [ -n "${1:-}" ]; then
group="$1"
current="$(proxy_group_current_display "$group" 2>/dev/null || true)"
[ -n "${current:-}" ] || current="<unknown>"
ui_title "🚀 当前节点"
ui_kv "📦" "策略组" "$group"
ui_kv "🚀" "当前节点" "${current:-未知}"
ui_blank
return 0
fi
ui_title "🚀 当前节点总览"
while IFS= read -r group; do
[ -n "${group:-}" ] || continue
found="true"
current="$(proxy_group_current_display "$group" 2>/dev/null || true)"
[ -n "${current:-}" ] || current="<unknown>"
printf ' 🚀 %-20s %s\n' "$group" "$current"
done < <(proxy_group_list)
if [ "$found" != "true" ]; then
echo " 📭 暂无可切换策略组"
fi
ui_blank
}
cmd_proxy_nodes() {
local group="$1"
local current node found="false"
prepare
[ -n "${group:-}" ] || die "请使用 clashctl select 切换节点"
if ! status_is_running; then
die_state "代理内核未运行" "clashon"
fi
if ! proxy_controller_reachable 2>/dev/null; then
die_state "控制器不可访问" "clashctl doctor"
fi
current="$(proxy_group_current_display "$group" 2>/dev/null || true)"
ui_title "🚀 候选节点列表"
ui_kv "📦" "策略组" "$group"
[ -n "${current:-}" ] && ui_kv "🚀" "当前节点" "$current"
ui_blank
while IFS= read -r node; do
[ -n "${node:-}" ] || continue
found="true"
if [ "$node" = "$current" ]; then
printf ' * 🚀 %s\n' "$node"
else
printf ' 🚀 %s\n' "$node"
fi
done < <(proxy_group_selectable_nodes "$group")
if [ "$found" != "true" ]; then
echo " 📭 当前策略组没有候选节点"
fi
ui_blank
ui_next "clashctl select"
ui_blank
}
proxy_pick_index() {
local count="$1"
local input
while true; do
printf "> " >&2
IFS= read -r input || return 1
case "${input:-}" in
q|Q)
return 1
;;
esac
if printf '%s' "$input" | grep -Eq '^[0-9]+$' && [ "$input" -ge 1 ] && [ "$input" -le "$count" ]; then
echo "$input"
return 0
fi
echo "🚨 请输入 1-$count 之间的编号,或输入 q 退出" >&2
done
}
proxy_pick_group_interactive() {
local idx count group current type
local -a groups=()
local -a ordered_groups=()
while IFS= read -r group; do
[ -n "${group:-}" ] || continue
groups+=("$group")
done < <(proxy_group_display_list)
for group in "节点选择" "自动选择"; do
if printf '%s\n' "${groups[@]}" | grep -Fxq "$group"; then
ordered_groups+=("$group")
fi
done
for group in "${groups[@]}"; do
case "$group" in
节点选择|自动选择)
continue
;;
esac
ordered_groups+=("$group")
done
groups=("${ordered_groups[@]}")
count="${#groups[@]}"
[ "$count" -gt 0 ] || die "📭 暂无可切换策略组"
echo "📦 请选择策略组:" >&2
echo "💡 通常优先选择:节点选择" >&2
idx=1
for group in "${groups[@]}"; do
current="$(proxy_group_current "$group" 2>/dev/null || echo "-")"
printf ' %s) 📦 %s -> 🚀 %s\n' "$idx" "$group" "$current" >&2
idx=$((idx + 1))
done
echo " q) 退出" >&2
echo >&2
idx="$(proxy_pick_index "$count")" || return 1
echo "${groups[$((idx - 1))]}"
}
select_total_node_count() {
local file="$RUNTIME_DIR/config.yaml"
local node_count
[ -s "$file" ] || return 1
[ -x "$(yq_bin)" ] || return 1
node_count="$("$(yq_bin)" eval '(.proxies // []) | length' "$file" 2>/dev/null | head -n 1 || true)"
case "${node_count:-}" in
''|*[!0-9]*)
return 1
;;
esac
echo "$node_count"
}
cmd_sub() {
prepare
case "${1:-}" in
list)
shift || true
cmd_ls "$@"
;;
use)
shift || true
[ -n "${1:-}" ] || die "用法:clashctl sub use <名称>"
set_active_subscription "$1"
apply_runtime_change_after_config_mutation
;;
set)
shift || true
[ -n "${1:-}" ] || die "用法:clashctl sub set <url> [name](固定 convert,推荐使用:clashctl add <订阅链接>)"
set_subscription "${1:-}" "convert" "${2:-default}"
apply_runtime_change_after_config_mutation
;;
enable)
shift || true
[ -n "${1:-}" ] || die "📜 用法:clashctl sub enable <名称>"
enable_subscription "$1"
regenerate_config
apply_runtime_change_after_config_mutation
print_sub_enable_feedback "$1"
;;
disable)
shift || true
[ -n "${1:-}" ] || die "📜 用法:clashctl sub disable <名称>"
disable_subscription "$1"
regenerate_config
apply_runtime_change_after_config_mutation
print_sub_disable_feedback "$1"
;;
rename)
shift || true
[ -n "${1:-}" ] || die "📜 用法:clashctl sub rename <旧名称> <新名称>"
[ -n "${2:-}" ] || die "📜 用法:clashctl sub rename <旧名称> <新名称>"
rename_subscription "$1" "$2"
regenerate_config
apply_runtime_change_after_config_mutation
print_sub_rename_feedback "$1" "$2"
;;
remove|rm|del)
shift || true
[ -n "${1:-}" ] || die "📜 用法:clashctl sub remove <名称>"
remove_subscription "$1"
regenerate_config
apply_runtime_change_after_config_mutation
print_sub_remove_feedback "$1"
;;
health)
shift || true
cmd_health "$@"
;;
"")
ui_title "📡 订阅高级管理"
echo "📜 用法:"
echo " clashctl sub list"
echo " clashctl sub use <名称>"
echo " clashctl sub enable <名称>"
echo " clashctl sub disable <名称>"
echo " clashctl sub rename <旧名称> <新名称>"
echo " clashctl sub remove <名称>"
echo " clashctl sub health [名称]"
echo
echo "🧩 说明:"
echo " add / use / ls 属于主路径"
echo " health 保留为多订阅健康审计"
echo " sub 仅用于高级维护操作"
echo
echo "💡 常用动作:"
echo " clashctl sub list"
echo " clashctl sub health"
echo " clashctl sub enable <名称>"
echo
ui_next "clashctl ls"
ui_blank
;;
*)
die_usage "未知的 sub 子命令:$1" "clashctl sub"
;;
esac
}
proxy_select_interactive() {
local group="${1:-}"
local current idx count total_count node selected_node
local -a nodes=()
prepare
if ! status_is_running; then
die_state "代理内核未运行" "clashon"
fi
if ! proxy_controller_reachable 2>/dev/null; then
die_state "控制器不可访问" "clashctl doctor"
fi
if [ -z "${group:-}" ]; then
ui_title "🚀 节点切换"
group="$(proxy_pick_group_interactive)" || return 0
elif ! proxy_group_supports_manual_pick "$group"; then
die "该策略组不支持手动挑节点:$group"
fi
current="$(proxy_group_current_display "$group" 2>/dev/null || true)"
while IFS= read -r node; do
[ -n "${node:-}" ] || continue
nodes+=("$node")
done < <(proxy_group_selectable_nodes "$group")
count="${#nodes[@]}"
[ "$count" -gt 0 ] || die "📭 当前策略组没有候选节点:$group"
total_count="$(select_total_node_count 2>/dev/null || true)"
echo
echo "📦 当前策略组:$group"
[ -n "${current:-}" ] && echo "🚀 当前节点:$current"
echo "📦 当前策略组候选数:$count"
[ -n "${total_count:-}" ] && echo "🔢 全部节点总数:$total_count"
echo "ℹ️ 以下仅显示当前策略组可切换节点"
echo
echo "🚀 请选择节点:"
idx=1
for node in "${nodes[@]}"; do
if [ "$node" = "$current" ]; then
printf ' %s) 🚀 %s [当前]\n' "$idx" "$node"
else
printf ' %s) 🚀 %s\n' "$idx" "$node"
fi
idx=$((idx + 1))
done
echo " q) 退出"
echo
idx="$(proxy_pick_index "$count")" || return 0
selected_node="${nodes[$((idx - 1))]}"
proxy_group_select "$group" "$selected_node"
print_select_feedback "$group"
}
proxy_pick_group_for_select() {
local idx count group current type
local -a groups=()
local -a ordered_groups=()
while IFS= read -r group; do
[ -n "${group:-}" ] || continue
groups+=("$group")
done < <(proxy_group_display_list)
for group in "节点选择" "自动选择"; do
if printf '%s\n' "${groups[@]}" | grep -Fxq "$group"; then
ordered_groups+=("$group")
fi
done
for group in "${groups[@]}"; do
case "$group" in
节点选择|自动选择)
continue
;;
esac
ordered_groups+=("$group")
done
groups=("${ordered_groups[@]}")
count="${#groups[@]}"
[ "$count" -gt 0 ] || die "📭 暂无可切换策略组"
echo "📦 请选择策略组:" >&2
echo "💡 通常优先选择:节点选择" >&2
idx=1
for group in "${groups[@]}"; do
current="$(proxy_group_current_display "$group" 2>/dev/null || true)"
type="$(proxy_group_type_label "$group" 2>/dev/null || echo "unknown")"
[ -n "${current:-}" ] || current="<unknown>"
printf ' %s) 📦 %s [%s] -> 🚀 %s\n' "$idx" "$group" "$type" "$current" >&2
idx=$((idx + 1))
done
echo " q) 退出" >&2
echo >&2
idx="$(proxy_pick_index "$count")" || return 1
echo "${groups[$((idx - 1))]}"
}
print_proxy_group_manual_pick_blocked() {
local group="$1"
local current type message
message="$(proxy_group_manual_pick_error_message "$group" 2>/dev/null || echo "该策略组不支持手动切换:$group")"
current="$(proxy_group_current_display "$group" 2>/dev/null || true)"
type="$(proxy_group_type_label "$group" 2>/dev/null || echo "unknown")"
ui_warn "$message"
ui_kv "📦" "策略组" "$group"
ui_kv "🧭" "策略组类型" "$type"
if [ -n "${current:-}" ]; then
ui_kv "🚀" "当前节点" "$current"
else
ui_kv "🚀" "当前节点" "<unknown>"
fi
ui_blank
}
proxy_select_interactive_guarded() {
local group="${1:-}"
local current idx count total_count node selected_node
local -a nodes=()
prepare
if ! status_is_running; then
die_state "代理内核未运行" "clashon"
fi
if ! proxy_controller_reachable 2>/dev/null; then
die_state "控制器不可访问" "clashctl doctor"
fi
if [ -z "${group:-}" ]; then
ui_title "🚀 节点切换"
group="$(proxy_pick_group_for_select)" || return 0
fi
proxy_group_exists "$group" || die "策略组不存在:$group"
if ! proxy_group_supports_manual_pick "$group"; then
print_proxy_group_manual_pick_blocked "$group"
return 0
fi
current="$(proxy_group_current_display "$group" 2>/dev/null || true)"
while IFS= read -r node; do
[ -n "${node:-}" ] || continue
nodes+=("$node")
done < <(proxy_group_selectable_nodes "$group")
count="${#nodes[@]}"
[ "$count" -gt 0 ] || die "📭 当前策略组没有候选节点:$group"
total_count="$(select_total_node_count 2>/dev/null || true)"
echo
echo "📦 当前策略组:$group"
[ -n "${current:-}" ] && echo "🚀 当前节点:$current"
echo "📦 当前策略组候选数:$count"
[ -n "${total_count:-}" ] && echo "🔢 全部节点总数:$total_count"
echo "ℹ️ 以下仅显示当前策略组可切换节点"
echo
echo "🚀 请选择节点:"
idx=1
for node in "${nodes[@]}"; do
if [ "$node" = "$current" ]; then
printf ' %s) 🚀 %s [当前]\n' "$idx" "$node"
else
printf ' %s) 🚀 %s\n' "$idx" "$node"
fi
idx=$((idx + 1))
done
echo " q) 退出"
echo
idx="$(proxy_pick_index "$count")" || return 0
selected_node="${nodes[$((idx - 1))]}"
proxy_group_select "$group" "$selected_node"
print_select_feedback "$group"
}
cmd_proxy() {
echo "⚠ 当前版本不提供 proxy 子命令"
echo "👉 使用 clashon / clashoff 控制代理"
echo "👉 使用 clashctl select 切换节点"
return 0
}
proxy_env_detected() {
local value
for value in \
"${http_proxy:-}" "${https_proxy:-}" \
"${HTTP_PROXY:-}" "${HTTPS_PROXY:-}" \
"${all_proxy:-}" "${ALL_PROXY:-}"; do
[ -n "${value:-}" ] && return 0
done
if [ "$(system_proxy_status 2>/dev/null || echo off)" = "on" ]; then
return 0
fi
return 1
}
warn_if_no_proxy_env() {
proxy_env_detected && return 0
ui_warn "当前未检测到代理环境"
ui_next "建议先执行 clashon"
ui_info "否则可能下载缓慢或失败"
}
cmd_upgrade() {
local verbose="false"
local target_kernel=""
local current_version target_version actual_version
prepare
while [ $# -gt 0 ]; do
case "$1" in
mihomo|clash)
target_kernel="$1"
;;
-v|--verbose)
verbose="true"
;;
*)
die_usage "upgrade 参数不合法" "clashctl upgrade [mihomo|clash] [-v|--verbose]"
;;
esac
shift
done
warn_if_no_proxy_env
[ -n "${target_kernel:-}" ] || target_kernel="$(runtime_kernel_type)"
target_kernel="$(normalize_kernel_type "$target_kernel")"
current_version="$(kernel_installed_version_text "$target_kernel")"
target_version="$(kernel_target_version "$target_kernel")"
ui_title "🚀 正在升级 ${target_kernel} 内核 ..."
ui_kv "🧩" "升级方式" "升级到当前设定目标版本,不自动追踪官方 latest"
ui_kv "📦" "当前版本" "$current_version"
ui_kv "🎯" "目标版本" "$target_version"
ui_blank
upgrade_runtime_kernel "$target_kernel" "$verbose"
actual_version="$(kernel_installed_version_text "$target_kernel")"
ui_title "🐱 内核升级完成"
ui_kv "🚀" "当前内核" "$(runtime_kernel_type)"
ui_kv "🎯" "目标版本" "$target_version"
ui_kv "🧪" "实际版本" "$actual_version"
ui_kv "🧩" "影响范围" "仅更新代理内核,不更新项目脚本"
ui_next "clashctl status"
ui_blank
}
cmd_update() {
local force_mode="false"
local regenerate_mode="false"
prepare
while [ $# -gt 0 ]; do
case "$1" in
--force)
force_mode="true"
;;
--regenerate)
regenerate_mode="true"
;;
*)
die_usage "update 参数不合法" "clashctl update [--force] [--regenerate]"
;;
esac
shift
done
warn_if_no_proxy_env
ui_title "🔄 正在更新项目代码 ..."
update_project_code "$force_mode" "$regenerate_mode"
ui_title "🐱 项目代码已更新"
ui_kv "🧩" "影响范围" "脚本、CLI、配置处理逻辑可能已变化"
if [ "$regenerate_mode" = "true" ]; then
ui_kv "🧩" "配置状态" "已重新生成"
else
ui_kv "🚨" "配置状态" "未自动重新生成"
fi
ui_next "clashctl status"
ui_blank
}
cmd_start_direct() {
prepare
start_runtime
}
cmd_stop_direct() {
prepare
stop_runtime
}
cmd_restart_direct() {
prepare
stop_runtime || true
start_runtime
}
status_read_mixed_port() {
runtime_config_mixed_port 2>/dev/null || true
}
status_read_controller_raw() {
runtime_config_controller_addr 2>/dev/null || true
}
controller_externally_reachable() {
local controller host
controller="$(status_read_controller_raw 2>/dev/null || true)"
[ -n "${controller:-}" ] && [ "$controller" != "null" ] || return 1
host="${controller%:*}"
case "$host" in
0.0.0.0|::|[::])
return 0
;;
127.0.0.1|localhost|::1|[::1])
return 1
;;
*)
return 0
;;
esac
}
status_read_controller() {
local controller
controller="$(status_read_controller_raw 2>/dev/null || true)"
display_controller_local_addr "$controller" 2>/dev/null || echo "$controller"
}
status_read_controller_lan() {
local controller lan_ip port
controller="$(status_read_controller_raw 2>/dev/null || true)"
[ -n "${controller:-}" ] && [ "$controller" != "null" ] || return 1
controller_externally_reachable || return 1
port="${controller##*:}"
lan_ip="$(ui_lan_ip 2>/dev/null || true)"
[ -n "${lan_ip:-}" ] || return 1
echo "${lan_ip}:${port}"
}
status_read_controller_public() {
local controller public_ip port
controller="$(status_read_controller_raw 2>/dev/null || true)"
[ -n "${controller:-}" ] && [ "$controller" != "null" ] || return 1
controller_externally_reachable || return 1
port="${controller##*:}"
public_ip="$(ui_public_ip 2>/dev/null || true)"
[ -n "${public_ip:-}" ] || return 1
echo "${public_ip}:${port}"
}
print_select_feedback() {
local group="$1"
local current
current="$(proxy_group_current_display "$group" 2>/dev/null || true)"
ui_title "🚀 节点切换完成"
if [ -n "${group:-}" ]; then
ui_kv "📦" "已切换策略组" "$group"
fi
ui_kv "🚀" "当前节点" "${current:-<unknown>}"
main_feedback_runtime_state
ui_next "clashctl status"
ui_blank
}
status_current_proxy_brief() {
local default_group default_current fallback_group fallback_current
local bind_failure_text
if ! status_is_running; then
bind_failure_text="$(runtime_mixed_port_bind_failure_text 2>/dev/null || true)"
if [ -n "${bind_failure_text:-}" ]; then
echo "$bind_failure_text"
return 0
fi
echo "未启动"
return 0
fi
if ! proxy_controller_reachable 2>/dev/null; then
echo "控制器不可访问"
return 0
fi
default_group="$(default_proxy_group_name 2>/dev/null || true)"
if [ -n "${default_group:-}" ]; then
default_current="$(default_proxy_group_current 2>/dev/null || true)"
echo "${default_group} -> ${default_current:-<unknown>}"
return 0
fi
fallback_group="$(proxy_group_list 2>/dev/null | sed -n '1p')"
if [ -n "${fallback_group:-}" ]; then
fallback_current="$(proxy_group_current_display "$fallback_group" 2>/dev/null || true)"
echo "${fallback_group} -> ${fallback_current:-<unknown>}"
return 0
fi
echo "暂无可切换策略组"
}
cmd_proxy_groups() {
local group current type found="false"
prepare
if ! status_is_running; then
die_state "代理内核未运行" "clashon"
fi
if ! proxy_controller_reachable 2>/dev/null; then
die_state "控制器不可访问" "clashctl doctor"
fi
ui_title "📦 策略组列表"
echo " 📦 名称 类型 当前节点"
while IFS= read -r group; do
[ -n "${group:-}" ] || continue
found="true"
type="$(proxy_group_type "$group" 2>/dev/null || echo "unknown")"
current="$(proxy_group_current_display "$group" 2>/dev/null || true)"
printf ' 📦 %-20s %-12s %s\n' "$group" "$type" "${current:-<unknown>}"
done < <(proxy_group_list)
if [ "$found" != "true" ]; then
echo " 📭 暂无可切换策略组"
fi
ui_blank
ui_next "clashctl select"
ui_blank
}
cmd_proxy_current() {
local group current found="false"
prepare
if ! status_is_running; then
die_state "代理内核未运行" "clashon"
fi
if ! proxy_controller_reachable 2>/dev/null; then
die_state "控制器不可访问" "clashctl doctor"
fi
if [ -n "${1:-}" ]; then
group="$1"
current="$(proxy_group_current_display "$group" 2>/dev/null || true)"
ui_title "🚀 当前节点"
ui_kv "📦" "策略组" "$group"
ui_kv "🚀" "当前节点" "${current:-<unknown>}"
ui_blank
return 0
fi
ui_title "🚀 当前节点总览"
while IFS= read -r group; do
[ -n "${group:-}" ] || continue
found="true"
current="$(proxy_group_current_display "$group" 2>/dev/null || true)"
printf ' 🚀 %-20s %s\n' "$group" "${current:-<unknown>}"
done < <(proxy_group_list)
if [ "$found" != "true" ]; then
echo " 📭 暂无可切换策略组"
fi
ui_blank
}
cmd_proxy_nodes() {
local group="$1"
local current node found="false"
prepare
[ -n "${group:-}" ] || die "请使用 clashctl select 切换节点"
if ! status_is_running; then
die_state "代理内核未运行" "clashon"
fi
if ! proxy_controller_reachable 2>/dev/null; then
die_state "控制器不可访问" "clashctl doctor"
fi
current="$(proxy_group_current_display "$group" 2>/dev/null || true)"
ui_title "🚀 候选节点列表"
ui_kv "📦" "策略组" "$group"
ui_kv "🚀" "当前节点" "${current:-<unknown>}"
ui_blank
while IFS= read -r node; do
[ -n "${node:-}" ] || continue
found="true"
if [ "$node" = "$current" ]; then
printf ' * 🚀 %s\n' "$node"
else
printf ' 🚀 %s\n' "$node"
fi
done < <(proxy_group_selectable_nodes "$group")
if [ "$found" != "true" ]; then
echo " 📭 该策略组暂无可切换真实节点"
fi
ui_blank
ui_next "clashctl select"
ui_blank
}
proxy_pick_group_for_select() {
local idx count group current type
local -a groups=()
local -a ordered_groups=()
while IFS= read -r group; do
[ -n "${group:-}" ] || continue
groups+=("$group")
done < <(proxy_group_display_list)
for group in "节点选择" "自动选择"; do
if printf '%s\n' "${groups[@]}" | grep -Fxq "$group"; then
ordered_groups+=("$group")
fi
done
for group in "${groups[@]}"; do
case "$group" in
节点选择|自动选择)
continue
;;
esac
ordered_groups+=("$group")
done
groups=("${ordered_groups[@]}")
count="${#groups[@]}"
[ "$count" -gt 0 ] || die "📭 暂无可切换策略组"
echo "📦 请选择策略组:" >&2
echo "💡 通常优先选择:节点选择" >&2
idx=1
for group in "${groups[@]}"; do
current="$(proxy_group_current_display "$group" 2>/dev/null || true)"
type="$(proxy_group_type_label "$group" 2>/dev/null || echo "unknown")"
printf ' %s) 📦 %s [%s] -> 🚀 %s\n' "$idx" "$group" "$type" "${current:-<unknown>}" >&2
idx=$((idx + 1))
done
echo " q) 退出" >&2
echo >&2
idx="$(proxy_pick_index "$count")" || return 1
echo "${groups[$((idx - 1))]}"
}
proxy_select_interactive_guarded() {
local group="${1:-}"
local current idx count total_count node selected_node
local choose_group="false"
local -a nodes=()
prepare
if ! status_is_running; then
die_state "代理内核未运行" "clashon"
fi
if ! proxy_controller_reachable 2>/dev/null; then
die_state "控制器不可访问" "clashctl doctor"
fi
if [ -z "${group:-}" ]; then
ui_title "🚀 节点切换"
choose_group="true"
fi
while true; do
if [ -z "${group:-}" ]; then
group="$(proxy_pick_group_for_select)" || return 0
fi
proxy_group_exists "$group" || die "策略组不存在:$group"
current="$(proxy_group_current_display "$group" 2>/dev/null || true)"
nodes=()
while IFS= read -r node; do
[ -n "${node:-}" ] || continue
nodes+=("$node")
done < <(proxy_group_selectable_nodes "$group")
count="${#nodes[@]}"
if [ "$count" -le 0 ]; then
ui_warn "该策略组暂无可切换真实节点"
ui_kv "📦" "策略组" "$group"
ui_kv "🚀" "当前节点" "${current:-<unknown>}"
ui_blank
if [ "$choose_group" = "true" ]; then
group=""
continue
fi
return 0
fi
total_count="$(select_total_node_count 2>/dev/null || true)"
echo
echo "📦 当前策略组:$group"
echo "🚀 当前节点:${current:-<unknown>}"
echo "📦 当前策略组候选数:$count"
[ -n "${total_count:-}" ] && echo "🔢 全部节点总数:$total_count"
echo "ℹ️ 以下仅显示当前策略组可切换节点"
echo
echo "🚀 请选择节点:"
idx=1
for node in "${nodes[@]}"; do
if [ "$node" = "$current" ]; then
printf ' %s) 🚀 %s [当前]\n' "$idx" "$node"
else
printf ' %s) 🚀 %s\n' "$idx" "$node"
fi
idx=$((idx + 1))
done
echo " q) 退出"
echo
idx="$(proxy_pick_index "$count")" || return 0
selected_node="${nodes[$((idx - 1))]}"
proxy_node_is_selectable_candidate "$selected_node" || die "节点不是可切换节点:$selected_node"
proxy_group_select "$group" "$selected_node"
print_select_feedback "$group"
return 0
done
}
cmd="${1:-}"
shift || true
case "$cmd" in
add) cmd_add "$@" ;;
use) cmd_use "$@" ;;
ls) cmd_ls "$@" ;;
health) cmd_health "$@" ;;
select) cmd_select "$@" ;;
on) cmd_on "$@" ;;
off) cmd_off "$@" ;;
status) cmd_status "$@" ;;
status-next) cmd_status_next "$@" ;;
boot) cmd_boot "$@" ;;
log|logs) cmd_logs "$@" ;;
doctor) cmd_doctor "$@" ;;
ui) cmd_ui "$@" ;;
secret) cmd_secret "$@" ;;
tun) cmd_tun "$@" ;;
dev) cmd_dev "$@" ;;
config) cmd_config "$@" ;;
lan) cmd_lan "$@" ;;
mixin) cmd_mixin "$@" ;;
relay) cmd_relay "$@" ;;
profile) cmd_profile "$@" ;;
sub) cmd_sub "$@" ;;
proxy) cmd_proxy "$@" ;;
upgrade) cmd_upgrade "$@" ;;
update) cmd_update "$@" ;;
completion) cmd_completion "$@" ;;
start-direct) cmd_start_direct "$@" ;;
stop-direct) cmd_stop_direct "$@" ;;
restart-direct) cmd_restart_direct "$@" ;;
-h|--help|"") usage ;;
help)
case "${1:-}" in
advanced) usage_advanced ;;
*) usage ;;
esac
;;
*) die "未知命令:$cmd" ;;
esac