#!/usr/bin/env bash set -euo pipefail shopt -s nullglob SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" resolve_mihomo_root() { if [[ -n "${MIHOMO_ROOT:-}" ]]; then printf "%s\n" "$MIHOMO_ROOT" elif [[ -x "${PWD}/mihomo" ]]; then printf "%s\n" "$PWD" elif [[ "$SCRIPT_DIR" != "/usr/local/bin" && -x "${SCRIPT_DIR}/mihomo" ]]; then printf "%s\n" "$SCRIPT_DIR" else echo "mh: cannot locate mihomo root; cd into the project dir or set MIHOMO_ROOT=/path/to/mihomo" >&2 exit 1 fi } MIHOMO_ROOT="$(resolve_mihomo_root)" MIHOMO_BIN=${MIHOMO_ROOT}/mihomo # 指向二进制文件 PID_FILE=${MIHOMO_ROOT}/run.pid # 固定pid文件 THEME_FILE=${MIHOMO_ROOT}/theme.current API_HOST="127.0.0.1" API_PORT="9090" MIXED_PORT="7890" # 默认main SOCK_PORT="7891" API_BASE="http://${API_HOST}:${API_PORT}" API_SECRET="" # 这是密钥,用于验证;对应yaml中设置的secret字段,保持一致即可;如果不设置,默认空字符串,表示不验证 # 简单彩色(不喜欢颜色就把这些变量都设为空字符串) urlenc() { python3 - <<'PY' "$1" import sys, urllib.parse print(urllib.parse.quote(sys.argv[1], safe='')) PY } #===============测试小工具,下====================================== theme_current() { local theme="basic" if [[ -f "$THEME_FILE" ]]; then theme="$(tr -d '[:space:]' < "$THEME_FILE")" fi case "$theme" in basic|ganyu|kaomoji|minimal) printf '%s\n' "$theme" ;; *) printf 'basic\n' ;; esac } theme_prefix() { local level="$1" case "$(theme_current):$level" in basic:DEBUG) printf '[debug] ' ;; basic:INFO) printf '[info] ' ;; basic:WARN) printf '[warn] ' ;; basic:ERROR) printf '[error] ' ;; basic:OK) printf '[ok] ' ;; ganyu:DEBUG) printf '(甘雨观察中) ' ;; ganyu:INFO) printf '❄️ 甘雨小秘书:' ;; ganyu:WARN) printf '🍧 甘雨提醒:' ;; ganyu:ERROR) printf '(甘雨皱眉) ' ;; ganyu:OK) printf '🍡 甘雨点头:' ;; kaomoji:DEBUG) printf '(눈_눈) ' ;; kaomoji:INFO) printf '(。・ω・。) ' ;; kaomoji:WARN) printf '(;´д`)ゞ ' ;; kaomoji:ERROR) printf '(╯°□°)╯ ' ;; kaomoji:OK) printf '(๑•̀ㅂ•́)و✧ ' ;; minimal:*) printf '' ;; *) printf '' ;; esac } log() { local level=$1 shift echo "[$(date +%H:%M:%S)] [$level] $(theme_prefix "$level")$*" } debug() { [[ "${DEBUG:-0}" == "1" ]] && log "DEBUG" "$@"; } info() { log "INFO" "$@"; } warn() { log "WARN" "$@"; } error() { log "ERROR" "$@"; } die() { error "$@"; exit 1; } ok() { log "OK" "$@"; } need() { command -v "$1" >/dev/null 2>&1 || die "missing dependency: $1" } test_env() { # 测试一下环境 need ss need yq need jq need curl need find [[ -x "$MIHOMO_BIN" ]] || die "mihomo not executable: $MIHOMO_BIN" echo "环境正确 ; env is ok" } test_port() { local port for port in "$@"; do if ss -lnt 2>/dev/null | awk '{print $4}' | grep -qE "(:|\\.)${port}\$"; then die "port already in use: $port" fi done } api_get() { local path="$1" if [[ -n "${API_SECRET}" ]]; then curl -fsS --noproxy '*' -H "Authorization: Bearer ${API_SECRET}" "${API_BASE}${path}" else curl -fsS --noproxy '*' "${API_BASE}${path}" fi } api_put() { local path="$1" local data="$2" if [[ -n "${API_SECRET}" ]]; then curl -fsS --noproxy '*' -X PUT \ -H "Authorization: Bearer ${API_SECRET}" \ -H 'Content-Type: application/json' \ -d "$data" "${API_BASE}${path}" else curl -fsS --noproxy '*' -X PUT \ -H 'Content-Type: application/json' \ -d "$data" "${API_BASE}${path}" fi } api_put_empty() { local path="$1" if [[ -n "${API_SECRET}" ]]; then curl -fsS --noproxy '*' -X PUT \ -H "Authorization: Bearer ${API_SECRET}" "${API_BASE}${path}" else curl -fsS --noproxy '*' -X PUT "${API_BASE}${path}" fi } sync_global_group() { local pjson pjson="$(api_get "/proxies" 2>/dev/null)" || return 1 jq -e '.proxies["GLOBAL"]?.all? | index("Proxy") != null' >/dev/null <<<"$pjson" || return 0 local now now="$(jq -r '.proxies["GLOBAL"].now // empty' <<<"$pjson")" if [[ "$now" != "Proxy" ]]; then api_put "/proxies/GLOBAL" '{"name":"Proxy"}' >/dev/null info "Synced global selector [GLOBAL] -> [Proxy]" fi } api_delay() { local name="$1" local timeout="${2:-2000}" local url="http://www.gstatic.com/generate_204" local enc_name enc_url enc_name="$(urlenc "$name")" enc_url="$(urlenc "$url")" local out out="$(api_get "/proxies/${enc_name}/delay?timeout=${timeout}&url=${enc_url}" 2>/dev/null || true)" jq -r 'if (.delay|type)=="number" then (.delay|tostring) else "-" end' <<<"$out" 2>/dev/null || echo "-" } switch_group_node() { # 用法:switch_group_node "" "" local group="$1" local node="$2" [[ -n "$group" && -n "$node" ]] || return 1 local enc_group enc_group="$(urlenc "$group")" api_put "/proxies/${enc_group}" "$(jq -nc --arg name "$node" '{name:$name}')" >/dev/null info "Switched group [$group] -> [$node]" } has_mihomo_cap_net_admin() { command -v getcap >/dev/null 2>&1 || return 1 getcap "$MIHOMO_BIN" 2>/dev/null | grep -q 'cap_net_admin' } disable_dns_fallback_geoip() { local cfg="$1" [[ -f "$cfg" ]] || return 1 yq -y -i ' .dns.enable = false | .dns.fallback = [] | .dns."fallback-filter" = null ' "$cfg" } #===============测试小工具,上====================================== #==============================配置文件读取,下====================================== get_main_group() { local pjson pjson="$(api_get "/proxies" 2>/dev/null)" || return 1 # 1) prefer group named "Proxy" if jq -e '.proxies["Proxy"]? != null and .proxies["Proxy"].all? != null' >/dev/null <<<"$pjson"; then echo "Proxy" return 0 fi # 2) fallback to your heuristic jq -r ' .proxies | to_entries | map(select(.value.all? != null)) | (map(select(.value.type?=="Selector" or .value.type?=="URLTest" or .value.type?=="Fallback" or .value.type?=="LoadBalance")) + .) | .[0].key // empty ' <<<"$pjson" } get_now_node() { # 输出“当前节点名”;失败输出空并返回非 0 local group pjson group="$(get_main_group)" || return 1 [[ -n "$group" ]] || return 1 pjson="$(api_get "/proxies" 2>/dev/null)" || return 1 jq -r --arg g "$group" '.proxies[$g].now // empty' <<<"$pjson" } #==============================配置文件读取,上====================================== #============================测速、测速结果处理,下================================= speedtest_node_delay() { # 用 mihomo 内置 delay API 测某个节点,输出 ms 或 "-" # 用法:speedtest_node_delay "" [timeout_ms] local node="${1:-}" local timeout_ms="${2:-1000}" [[ -n "$node" ]] || { echo "-"; return 1; } # DIRECT/REJECT 没必要测 if [[ "$node" == "DIRECT" || "$node" == "REJECT" ]]; then echo "-" return 0 fi api_delay "$node" "$timeout_ms" } speedtest_now() { # 用法:speedtest_now [timeout_ms] local timeout_ms="${1:-1000}" local node delay node="$(get_now_node || true)" if [[ -z "$node" ]]; then echo "⚠️ 无法获取当前节点(mihomo API 不通或没有可用 group)" return 1 fi delay="$(speedtest_node_delay "$node" "$timeout_ms")" # 简单打个标 local badge="🟡" if [[ "$delay" =~ ^[0-9]+$ ]]; then if (( delay <= 150 )); then badge="🟢" elif (( delay <= 400 )); then badge="🟡" else badge="🔴" fi else badge="⚪" fi echo "👉 当前节点:${node}" echo "${badge} 延迟:${delay} ms (timeout=${timeout_ms})" } wait_api() { local t="${1:-5}" # seconds local i=0 while (( i < t*10 )); do if api_get "/proxies" >/dev/null 2>&1; then return 0 fi sleep 0.1 i=$((i+1)) done return 1 } #============================测速、测速结果处理,上================================= #============================mihomo配置,下================== write_cfg() { # name url file local name="$1" local url="$2" cat > "${MIHOMO_ROOT}/$name/config.yaml" </dev/null) (( ${#providers[@]} > 0 )) || die "missing proxy-providers in $cfg" local provider ptype purl ppath for provider in "${providers[@]}"; do ptype="$(yq -r --arg p "$provider" '."proxy-providers"[$p].type // ""' "$cfg")" case "$ptype" in http) purl="$(yq -r --arg p "$provider" '."proxy-providers"[$p].url // ""' "$cfg")" [[ -n "$purl" ]] || die "provider [$provider] is http but url is empty" yq -y -i --arg p "$provider" --arg path "${profile_dir}/providers/${provider}.yaml" '."proxy-providers"[$p].path = $path | ."proxy-providers"[$p].interval = 86400' "$cfg" ;; file) ppath="$(yq -r --arg p "$provider" '."proxy-providers"[$p].path // ""' "$cfg")" [[ -n "$ppath" ]] || ppath="${profile_dir}/providers/${provider}.yaml" yq -y -i --arg p "$provider" --arg path "$ppath" '."proxy-providers"[$p].path = $path' "$cfg" [[ -f "$ppath" ]] || die "provider [$provider] file not found: $ppath" ;; *) die "provider [$provider] has unsupported or empty type: ${ptype:-}" ;; esac done local mode mode="$(yq -r '.mode // ""' "$cfg" 2>/dev/null)" case "$mode" in rule|global|direct) ;; "") mode="rule" ;; *) warn "invalid mode [$mode], reset to rule"; mode="rule" ;; esac yq -y -i " .mode = \"${mode}\" | .[\"mixed-port\"] = ${MIXED_PORT} | .[\"socks-port\"] = ${SOCK_PORT} | .[\"external-controller\"] = \"${API_HOST}:${API_PORT}\" | .secret = (.secret // \"\") | .[\"log-level\"] = \"error\" | .[\"allow-lan\"] = (.[\"allow-lan\"] // false) | .[\"bind-address\"] = \"${API_HOST}\" " "$cfg" if ! yq -e '([."proxy-groups"[]?.name] | index("Proxy") != null and index("Final") != null) and (.rules | type == "array" and length > 0)' "$cfg" >/dev/null 2>&1; then warn "proxy-groups or rules missing, applying basic rule config" reset_basic_rules "$cfg" || die "failed to initialize basic rules" fi yq -e '.mode as $mode | ($mode == "rule" or $mode == "global" or $mode == "direct") and (."mixed-port" | type == "number") and (."socks-port" | type == "number") and (."proxy-providers" | type == "object" and length > 0) and (."proxy-groups" | type == "array" and length > 0) and (.rules | type == "array" and length > 0)' "$cfg" >/dev/null || die "invalid mihomo config after normalization: $cfg" } #=====================mihomo配置,上==================================== print_mihomo_status() { local cfg pjson cfg="$(api_get "/configs" 2>/dev/null)" || { echo "❌ API 不通: ${API_BASE}"; return 1; } pjson="$(api_get "/proxies" 2>/dev/null)" || { echo "❌ 读取 /proxies 失败"; return 1; } local allow_lan bind_addr mode mixed_port ec allow_lan="$(jq -r '."allow-lan" // false' <<<"$cfg")" bind_addr="$(jq -r '."bind-address" // "-"' <<<"$cfg")" mode="$(jq -r '.mode // "-"' <<<"$cfg")" mixed_port="$(jq -r '."mixed-port" // .port // "-"' <<<"$cfg")" ec="$(jq -r '."external-controller" // "-"' <<<"$cfg")" local group now node_count group="$(get_main_group 2>/dev/null || true)" [[ -n "$group" ]] || { echo "❌ 找不到主组(/proxies 里没有可用 group)"; return 1; } now="$(jq -r --arg g "$group" '.proxies[$g].now // "-"' <<<"$pjson")" node_count="$(jq -r --arg g "$group" '.proxies[$g].all | length' <<<"$pjson" 2>/dev/null || echo "-")" local lan_icon="🔒" [[ "$allow_lan" == "true" ]] && lan_icon="📡" echo "🧠 Mihomo Status" echo "🌐 API: ${API_BASE} (EC=${ec})" echo "🚪 Port: mixed-port=${mixed_port}" echo "${lan_icon} LAN: allow-lan=${allow_lan} bind=${bind_addr}" echo "🧭 Mode: ${mode}" echo "🧩 Group: ${group}" echo "📦 Nodes: ${node_count}" echo "✅ Now: ${now}" } port_open_local() { local port="$1" ss -lnt 2>/dev/null | awk '{print $4}' | grep -qE "(:|\\.)${port}\$" } show_runtime_routing_status() { local cfg pjson cfg="$(api_get "/configs" 2>/dev/null)" || { echo "Routing: API unavailable" return 0 } pjson="$(api_get "/proxies" 2>/dev/null)" || { echo "Routing: /proxies unavailable" return 0 } local mode global_now final_now proxy_now mode="$(jq -r '.mode // "-"' <<<"$cfg")" global_now="$(jq -r '.proxies["GLOBAL"].now // empty' <<<"$pjson")" final_now="$(jq -r '.proxies["Final"].now // empty' <<<"$pjson")" proxy_now="$(jq -r '.proxies["Proxy"].now // empty' <<<"$pjson")" echo "Mode: ${mode}" [[ -n "$global_now" ]] && echo "GLOBAL -> ${global_now}" [[ -n "$final_now" ]] && echo "Final -> ${final_now}" [[ -n "$proxy_now" ]] && echo "Proxy -> ${proxy_now}" } extract_proxy_port() { local value="${1:-}" [[ -n "$value" ]] || return 1 sed -E 's#^[[:alpha:]][[:alnum:]+.-]*://[^:/]+:([0-9]+)/*$#\1#' <<<"$value" } proxy_env_status_line() { local name="$1" local value="$2" if [[ -z "$value" ]]; then echo "${name}: OFF" return 0 fi local port port="$(extract_proxy_port "$value" 2>/dev/null || true)" if [[ -n "$port" ]] && port_open_local "$port"; then echo "${name}: ON (${value}, port ${port} reachable)" elif [[ -n "$port" ]]; then echo "${name}: STALE (${value}, port ${port} not listening)" else echo "${name}: ON (${value})" fi } start_end(){ # start/end name/none 文件 local start0Rend="${1:-}" local name_file=${2:-} local cfg=$MIHOMO_ROOT/$name_file [[ -n "$start0Rend" ]] || die "usage: $0 start|end" if [[ "$start0Rend" == "start" ]]; then start_end "end" # 先停止当前 [[ -d "$cfg" ]] || die "profile not found: $cfg" [[ -f "$cfg/config.yaml" ]] || die "missing config: $cfg/config.yaml" ensure_profile_config "$cfg" if [[ "$(yq -r '.tun.enable // false' "$cfg/config.yaml" 2>/dev/null || echo false)" != "true" ]]; then disable_dns_fallback_geoip "$cfg/config.yaml" fi # 进入的前提配置没有问题,可以直接运行 info "start mihomo with config=$cfg/config.yaml data=$cfg" # 前台跑不方便,这里后台启动并记录 pid test_port "$API_PORT" "$MIXED_PORT" "$SOCK_PORT" "$MIHOMO_BIN" -d "$cfg" -f "$cfg/config.yaml" >"$cfg/mihomo.log" 2>&1 & # 每次启动情况记录 echo $! >"$PID_FILE" # 保存到定点位置 elif [[ "$start0Rend" == "end" ]]; then if [[ -f "$PID_FILE" ]]; then # 存在就终止 local pid pid="$(cat "$PID_FILE" || true)" if [[ -n "${pid}" ]] && kill "$pid" 2>/dev/null; then info "stop process pid=$pid" # 等一下让它退出 for _ in {1..20}; do kill -0 "$pid" 2>/dev/null || break sleep 0.1 done if kill -0 "$pid" 2>/dev/null; then warn "force kill pid=$pid" kill -9 "$pid" 2>/dev/null || true fi fi rm -f "$PID_FILE" fi else die "usage: $0 start|end" fi } cfg_add() { # add name src if [[ "$1" == "add" ]]; then shift # 去掉 "add" 参数 fi local name="${1:-}" local src="${2:-}" local cfg_root="${MIHOMO_ROOT}/${name}" [[ -n "$name" ]] || die "usage: $0 add " [[ -n "$src" ]] || die "usage: $0 add " if [[ -d "$cfg_root" ]]; then die "profile exists: $cfg_root ,请换一个名称" fi mkdir -p "$cfg_root/providers" write_cfg "$name" "$src" if [[ -f "$src" ]]; then #file: 直接复制,不用下载了 info "copy file -> ${cfg_root}/providers/$name.yaml" cp -f "$src" "$cfg_root/providers/$name.yaml" yq -y -i " .[\"proxy-providers\"][\"$name\"].type = \"file\" | del(.[\"proxy-providers\"][\"$name\"].url) | del(.[\"proxy-providers\"][\"$name\"].interval) " "$cfg_root/config.yaml" info "saved: ${cfg_root}/providers/$name.yaml" # 有了文件,等会挂载 fi ensure_bypass_list "$cfg_root" reset_basic_rules "$cfg_root/config.yaml" # 启动这个配置 select_one "select" "$name" # 启动这个新的节点 print_mihomo_status 1000 } get_group_nodes_json() { local pjson group nodes pjson="$(api_get "/proxies")" || return 1 group="$(get_main_group)" || return 1 [[ -n "$group" ]] || return 1 nodes="$(jq -c --arg g "$group" '.proxies[$g].all' <<<"$pjson")" || return 1 jq -nc --arg group "$group" --argjson nodes "$nodes" '{group:$group, nodes:$nodes}' } provider_http_list() { local cfg="$1" yq -r '."proxy-providers" | to_entries[]? | select(.value.type == "http") | .key' "$cfg" } provider_in_list() { local needle="$1" shift local item for item in "$@"; do [[ "$item" == "$needle" ]] && return 0 done return 1 } update_one_provider() { local provider="$1" local enc_provider enc_provider="$(urlenc "$provider")" api_put_empty "/providers/proxies/${enc_provider}" >/dev/null info "updated provider: $provider" } update_providers_cmd() { local target="${1:-}" local prof cfg_dir cfg prof="$(current_profile)" || die "no current profile" cfg_dir="${MIHOMO_ROOT}/${prof}" cfg="${cfg_dir}/config.yaml" ensure_profile_config "$cfg_dir" local -a providers selected mapfile -t providers < <(provider_http_list "$cfg") (( ${#providers[@]} > 0 )) || die "no http proxy-providers in profile=${prof}" if ! wait_api 3; then info "mihomo API not ready, starting current profile=${prof} first..." start_end start "$prof" wait_api 5 || die "mihomo API not ready after start" fi case "$target" in "" ) echo "📦 HTTP providers (profile=${prof}):" echo " 0) ALL" local i pick for i in "${!providers[@]}"; do printf " %d) %s\n" "$((i+1))" "${providers[$i]}" done while true; do read -r -p "👉 请输入序号 (0-${#providers[@]}): " pick [[ "$pick" =~ ^[0-9]+$ ]] || { echo "❌ 请输入数字"; continue; } (( pick>=0 && pick<=${#providers[@]} )) || { echo "❌ 超出范围"; continue; } break done if (( pick == 0 )); then selected=("${providers[@]}") else selected=("${providers[$((pick-1))]}") fi ;; all|ALL) selected=("${providers[@]}") ;; *) provider_in_list "$target" "${providers[@]}" || die "provider not found or not http: $target" selected=("$target") ;; esac local provider for provider in "${selected[@]}"; do update_one_provider "$provider" done } theme_cmd() { local action="${1:-show}" case "$action" in show|current) echo "current theme: $(theme_current)" echo "available: basic ganyu kaomoji minimal" ;; list) cat < "$THEME_FILE" info "theme set to ${action}" ;; *) die "usage: $0 theme [show|list|basic|ganyu|kaomoji|minimal]" ;; esac } help() { cat < 新建 profile select [name] 切换/启动 profile,不带 name 时交互选择 start 等价于 select end 停止当前 mihomo env 输出代理 export,常用:eval "\$(mh env)" theme [name] 查看或切换输出主题 sysproxy [on|off|status] 开关 GNOME 系统代理 update [all|provider] 更新当前 profile 的 HTTP provider mode rule|global|direct 切换运行模式并重启 rules show|reset 查看或重置基础规则 tun [on|off|toggle] 开关 TUN list | -l | --list [kw] 列出节点并可输入序号切换 doctor 输出诊断信息 db 调试菜单 help | -h | --help 显示帮助 Default: mh 显示 TUN/路由/代理环境/当前节点延迟 mh 搜索节点并按延迟排序选择切换 Examples: mh add demo "https://example.com/sub.yaml" mh add local "/path/to/sub.yaml" mh select mh select demo mh 美国 eval "\$(mh env)" Notes: root=${MIHOMO_ROOT} api=${API_BASE} mixed-port=${MIXED_PORT} mihomo=${MIHOMO_BIN} EOF } select_one(){ # select name shift local name if [[ -z "${1:-}" ]];then local -a files mapfile -t files < <(find "$MIHOMO_ROOT" -mindepth 1 -maxdepth 1 -type d -exec test -f '{}/config.yaml' ';' -print | sort) if (( ${#files[@]} == 0 )); then echo "⚠️ ${MIHOMO_ROOT} 下没有找到任何 目录" return 1 fi echo "📄 可用配置(输入序号选择,例如 1):" local i for i in "${!files[@]}"; do printf " %d) %s\n" "$((i+1))" "$(basename "${files[$i]}")" done local pick while true; do read -r -p "👉 请输入序号 (1-${#files[@]}): " pick [[ "$pick" =~ ^[0-9]+$ ]] || { echo "❌ 请输入数字"; continue; } (( pick>=1 && pick<=${#files[@]} )) || { echo "❌ 超出范围"; continue; } name="$(basename "${files[$((pick-1))]}")" break done else local file_cfg="$MIHOMO_ROOT/$1" if [[ -d $file_cfg ]];then name="$1" else echo "❌ 不存在: $file_cfg"# 输入name有无 return 1 fi fi echo "$name" > "${MIHOMO_ROOT}/current.profile" start_end "start" "$name" wait_api 5 || warn "mihomo API not ready yet" sync_global_group || warn "failed to sync GLOBAL -> Proxy" speedtest_now 1000 } #====================tun控制,下========================= current_profile() { [[ -f "${MIHOMO_ROOT}/current.profile" ]] || return 1 cat "${MIHOMO_ROOT}/current.profile" } ensure_bypass_list() { local profile_dir="$1" local file="${profile_dir}/bypass.list" [[ -f "$file" ]] && return 0 cat > "$file" </dev/null)" [[ "$providers_json" != "null" && "$providers_json" != "[]" ]] || { warn "cannot detect proxy-provider names from $cfg"; return 1; } profile_dir="$(dirname "$cfg")" ensure_bypass_list "$profile_dir" bypass_rules="$(compile_bypass_rules "${profile_dir}/bypass.list")" rules_json="$({ printf "%s\n" "$bypass_rules" printf "%s\n" \ "IP-CIDR,100.64.0.0/10,DIRECT,no-resolve" \ "IP-CIDR,224.0.0.0/4,DIRECT,no-resolve" \ "IP-CIDR6,::1/128,DIRECT,no-resolve" \ "IP-CIDR6,fc00::/7,DIRECT,no-resolve" \ "IP-CIDR6,fe80::/10,DIRECT,no-resolve" \ "MATCH,Final" } | rules_json_from_lines)" yq -y -i --argjson providers "$providers_json" --argjson rules "$rules_json" ".mode = \"rule\" | .[\"proxy-groups\"] = [{\"name\":\"Proxy\",\"type\":\"select\",\"use\":\$providers},{\"name\":\"Final\",\"type\":\"select\",\"proxies\":[\"Proxy\",\"DIRECT\"]}] | .rules = \$rules" "$cfg" } mode_set() { local next_mode="${1:-}" case "$next_mode" in rule|global|direct) ;; *) die "usage: $0 mode rule|global|direct" ;; esac local prof cfg prof="$(current_profile)" || die "no current profile" cfg="${MIHOMO_ROOT}/${prof}/config.yaml" [[ -f "$cfg" ]] || die "missing config: $cfg" if [[ "$next_mode" == "rule" ]] && ! yq -e '.rules | length > 0' "$cfg" >/dev/null 2>&1; then info "No rules found in profile=${prof}, applying basic rules first..." reset_basic_rules "$cfg" || die "failed to initialize basic rules" fi yq -y -i ".mode = \"${next_mode}\"" "$cfg" info "Mode set to ${next_mode} (profile=${prof}), restarting..." start_end end start_end start "$prof" wait_api 5 || warn "mihomo API not ready" if [[ "$next_mode" == "global" ]]; then sync_global_group || warn "failed to sync GLOBAL -> Proxy" fi print_mihomo_status || true } rules_cmd() { local action="${1:-show}" local cfg prof prof="$(current_profile)" || die "no current profile" cfg="${MIHOMO_ROOT}/${prof}/config.yaml" [[ -f "$cfg" ]] || die "missing config: $cfg" case "$action" in show) echo "Rules (profile=${prof}):" yq -r '.rules[]?' "$cfg" ;; reset) reset_basic_rules "$cfg" || die "failed to reset basic rules" info "Basic rules reset (profile=${prof}), restarting..." start_end end start_end start "$prof" wait_api 5 || warn "mihomo API not ready" print_mihomo_status || true ;; *) die "usage: $0 rules show|reset" ;; esac } tun() { # 用法:sudo mh tun on|off|toggle local action="${1:-toggle}" local prof prof="$(current_profile)" || { warn "no current profile"; return 1; } local cfg="${MIHOMO_ROOT}/${prof}/config.yaml" [[ -f "$cfg" ]] || { warn "missing config: $cfg"; return 1; } local cur cur="$(yq -r '.tun.enable // false' "$cfg" 2>/dev/null || echo false)" local next case "$action" in on) next=true ;; off) next=false ;; toggle) [[ "$cur" == "true" ]] && next=false || next=true ;; *) warn "usage: mh tun on|off|toggle"; return 1 ;; esac if [[ "$next" == "true" && "${EUID:-$(id -u)}" -ne 0 ]] && ! has_mihomo_cap_net_admin; then warn "开启 TUN 通常需要 root,或提前给 mihomo 设置 cap_net_admin/cap_net_raw" warn "继续尝试启动;若失败,请执行:sudo setcap cap_net_admin,cap_net_raw+ep \"$MIHOMO_BIN\"" fi yq -y -i " .tun.enable = ${next} | .tun.stack = (.tun.stack // \"system\") | .tun.device = (.tun.device // \"mihomo\") | .tun[\"auto-route\"] = (.tun[\"auto-route\"] // true) | .tun[\"auto-redir\"] = (.tun[\"auto-redir\"] // true) | .tun[\"auto-detect-interface\"] = (.tun[\"auto-detect-interface\"] // true) | .tun[\"dns-hijack\"] = (.tun[\"dns-hijack\"] // []) | ( .tun[\"dns-hijack\"] |= ( . + [\"any:53\"] | unique ) ) | .dns.enable = ${next} | .dns.listen = (.dns.listen // \"127.0.0.1:1053\") | .dns[\"enhanced-mode\"] = \"redir-host\" | .dns.nameserver = (.dns.nameserver // [\"223.5.5.5\",\"119.29.29.29\"]) | .dns.fallback = (.dns.fallback // [\"1.1.1.1\",\"8.8.8.8\"]) | .dns[\"fallback-filter\"].geoip = false " "$cfg" if [[ "$next" != "true" ]]; then disable_dns_fallback_geoip "$cfg" fi info "TUN set to ${next} (profile=${prof}), restarting..." start_end end start_end start "$prof" wait_api 5 || warn "mihomo API not ready" sync_global_group || warn "failed to sync GLOBAL -> Proxy" print_mihomo_status 1200 || true } #=============================tun控制,上=================== list_nodes() { local kw="${1:-}" wait_api 5 || { warn "mihomo API not ready"; return 1; } local pjson group now pjson="$(api_get "/proxies")" || { warn "cannot read /proxies"; return 1; } group="$(get_main_group)" || { warn "cannot detect main group"; return 1; } now="$(jq -r --arg g "$group" '.proxies[$g].now // ""' <<<"$pjson")" local -a nodes if [[ -n "$kw" ]]; then mapfile -t nodes < <(jq -r --arg g "$group" --arg kw "$kw" ' def lc: ascii_downcase; .proxies[$g].all | map(select((contains($kw)) or ((lc) | contains($kw | lc)))) | .[] ' <<<"$pjson") else mapfile -t nodes < <(jq -r --arg g "$group" '.proxies[$g].all[]' <<<"$pjson") fi (( ${#nodes[@]} > 0 )) || { warn "No nodes matched: ${kw:-}"; return 1; } echo "📌 Group: ${group}" [[ -n "$now" ]] && echo "⭐ Now: ${now}" [[ -n "$kw" ]] && echo "🔎 Filter: ${kw}" echo local i for i in "${!nodes[@]}"; do printf " %d) %s%s\n" "$((i+1))" "$(if [[ "${nodes[$i]}" == "$now" ]]; then printf "⭐ "; else printf " "; fi)" "${nodes[$i]}" done [[ -t 0 ]] || return 0 local pick while true; do read -r -p "👉 选择节点 (1-${#nodes[@]}, 0 cancel): " pick [[ "$pick" =~ ^[0-9]+$ ]] || { echo "❌ 请输入数字"; continue; } (( pick == 0 )) && { echo "cancelled"; return 0; } (( pick>=1 && pick<=${#nodes[@]} )) || { echo "❌ 超出范围"; continue; } break done switch_group_node "$group" "${nodes[$((pick-1))]}" speedtest_now 1500 || true } #===============================搜索测速排序,下====================== # 批量测延迟(默认并发 6),输出:"\t" batch_delay() { # 用法:batch_delay ... local timeout_ms="${1:-1200}"; shift || true local max_jobs="${1:-6}"; shift || true local -a nodes=( "$@" ) # 兼容:没有节点直接返回 (( ${#nodes[@]} == 0 )) && return 0 # 并发测 local tmp tmp="$(mktemp)" rm -f "$tmp"; : >"$tmp" # 简单并发控制:后台跑,超过 max_jobs 就 wait 一个 local running=0 for n in "${nodes[@]}"; do ( local d d="$(api_delay "$n" "$timeout_ms")" # 统一成数值:"-" 变成 999999,便于排序 if [[ "$d" =~ ^[0-9]+$ ]]; then printf "%s\t%s\n" "$d" "$n" else printf "999999\t%s\n" "$n" fi ) >>"$tmp" & running=$((running+1)) if (( running >= max_jobs )); then wait -n 2>/dev/null || wait running=$((running-1)) fi done wait # 排序输出 sort -n "$tmp" rm -f "$tmp" } # 关键词匹配 + 测速排序 + 选择切换 pick_switch_with_delay() { # 用法:pick_switch_with_delay "" [timeout_ms] local kw="${1:-}" local timeout_ms="${2:-1200}" [[ -n "$kw" ]] || { warn "usage: mh "; return 1; } wait_api 5 || { warn "mihomo API not ready"; return 1; } local gj group gj="$(get_group_nodes_json)" || { warn "cannot read proxies/groups"; return 1; } group="$(jq -r '.group' <<<"$gj")" # 匹配候选:中文用 contains;英文用 lower-case contains local candidates candidates="$(jq -r --arg kw "$kw" ' def lc: ascii_downcase; .nodes | map(select(. != "DIRECT" and . != "REJECT")) | map(select( (contains($kw)) or ((lc) | contains($kw | lc)) )) | .[] ' <<<"$gj")" if [[ -z "$candidates" ]]; then warn "No nodes matched keyword: $kw" info "Tip: try: 美国 / 香港 / 日本 / 狮城 / 韩国 / 台湾 / US / HK / JP / SG" return 1 fi mapfile -t arr <<<"$candidates" info "Testing delay for ${#arr[@]} nodes (timeout=${timeout_ms}ms)..." # 并发 6:你可以改大/改小 local lines lines="$(batch_delay "$timeout_ms" 6 "${arr[@]}")" # 组装排序后的节点数组(按 delay asc) local -a sorted_nodes=() local -a sorted_delay=() while IFS=$'\t' read -r d n; do sorted_nodes+=( "$n" ) sorted_delay+=( "$d" ) done <<<"$lines" echo "🔎 Matched nodes in group [$group] for keyword: [$kw] (sorted by delay)" local i showd for i in "${!sorted_nodes[@]}"; do showd="${sorted_delay[$i]}" [[ "$showd" == "999999" ]] && showd="-" # 显示时还原 printf " %d) %s\t%s ms\n" "$((i+1))" "${sorted_nodes[$i]}" "$showd" done local pick while true; do read -r -p "👉 choose (1-${#sorted_nodes[@]}, 0 cancel): " pick [[ "$pick" =~ ^[0-9]+$ ]] || { echo "❌ 请输入数字"; continue; } (( pick==0 )) && { echo "cancelled"; return 0; } (( pick>=1 && pick<=${#sorted_nodes[@]} )) || { echo "❌ 超出范围"; continue; } break done switch_group_node "$group" "${sorted_nodes[$((pick-1))]}" speedtest_now 1500 || true } #==============================搜索测速排序,上====================== #============================状态检查,下===================== tun_status() { local prof cfg enabled dev prof="$(current_profile 2>/dev/null || true)" [[ -n "$prof" ]] || { echo "TUN: unknown (no current profile)"; return 0; } cfg="${MIHOMO_ROOT}/${prof}/config.yaml" [[ -f "$cfg" ]] || { echo "TUN: unknown (missing config)"; return 0; } enabled="$(yq -r '.tun.enable // false' "$cfg" 2>/dev/null || echo false)" dev="$(yq -r '.tun.device // "mihomo"' "$cfg" 2>/dev/null || echo mihomo)" if [[ "$enabled" != "true" ]]; then echo "TUN: OFF (profile=${prof})" return 0 fi echo "TUN: ON (profile=${prof})" echo "Expect iface: ${dev}" if ip link show "$dev" >/dev/null 2>&1; then echo "Iface: ✅ ${dev} exists" else echo "Iface: ❌ ${dev} not found (mihomo tun may not be running/privileged)" fi echo "Default route:" ip route show default 2>/dev/null | sed 's/^/ /' || true } proxy_env_status() { proxy_env_status_line "HTTP_PROXY" "${HTTP_PROXY:-}" proxy_env_status_line "HTTPS_PROXY" "${HTTPS_PROXY:-}" proxy_env_status_line "ALL_PROXY" "${ALL_PROXY:-}" } show_proxy_exports() { cat < OK/FAIL” local -a urls=( "https://www.google.com" "https://www.cloudflare.com" "https://www.github.com" "https://www.youtube.com" "https://www.baidu.com" "https://www.bing.com" ) echo "== Net Test (timeout=10s) ==" local url for url in "${urls[@]}"; do if curl -fsS -I -m 10 "$url" >/dev/null 2>&1; then printf "%s -> ✅ OK\n" "$url" else printf "%s -> ❌ FAIL\n" "$url" fi done } net_test_double() { local -a urls=( "https://www.google.com" "https://www.cloudflare.com" "https://www.github.com" "https://www.youtube.com" "https://www.baidu.com" "https://www.bing.com" ) echo "== Net Test (timeout=10s) ==" echo "Format: URL -> DIRECT | PROXY(mixed-port=${MIXED_PORT})" local url ok1 ok2 for url in "${urls[@]}"; do # 直连(完全忽略环境代理) if curl -fsS -I -m 10 --noproxy '*' "$url" >/dev/null 2>&1; then ok1="✅" else ok1="❌" fi # 强制走 mihomo mixed-port(明确指定代理) if curl -fsS -I -m 10 -x "http://127.0.0.1:${MIXED_PORT}" "$url" >/dev/null 2>&1; then ok2="✅" else ok2="❌" fi printf "%-28s -> DIRECT:%s PROXY:%s\n" "$url" "$ok1" "$ok2" done } #============================状态检查,上===================== #=========================debug,下=============================== debug_one() { shift local -a debug_code=() debug_code[1]="ss -lntp | grep -E ':(9090|9091|7890|7891)'|| echo \"❌ 未找到代理服务\"" debug_code[2]="ps -fp \${1:-} -o pid,ppid,cmd" debug_code[3]="net_test" debug_code[4]="net_test_double" debug_code[5]="test_env" debug_code[6]="ip -o link show" debug_code[7]="resolvectl status 2>/dev/null | sed -n '1,120p' || cat /etc/resolv.conf" local -a debug_desc=() debug_desc[1]="检查 9090/9091/7890/7891 端口监听:" debug_desc[2]="查看指定 PID 的进程信息:" debug_desc[3]="测试一下网络(仅proxy)" debug_desc[4]="测试一下网络(直连+proxy)" debug_desc[5]="测试需要的软件是否安装:" debug_desc[6]="看看是否有虚拟网卡 mihomo:" debug_desc[7]="dns debug" echo "== debug list ==" local i for i in "${!debug_code[@]}"; do printf " [%s] %s\n %s\n" "$i" "${debug_desc[$i]:-}" "${debug_code[$i]}" done local idx read -r -p "select index: " idx for i in "${!debug_code[@]}"; do if [[ "$i" == "$idx" ]]; then echo "== run [$i] ==" eval "${debug_code[$i]}" return $? fi done echo "invalid index: $idx" return 1 } sysproxy_cmd() { local action="${1:-status}" command -v gsettings >/dev/null 2>&1 || die "gsettings not found; this sysproxy command is for GNOME/Ubuntu desktops" case "$action" in on) gsettings set org.gnome.system.proxy mode 'manual' gsettings set org.gnome.system.proxy.http host '127.0.0.1' gsettings set org.gnome.system.proxy.http port "${MIXED_PORT}" gsettings set org.gnome.system.proxy.https host '127.0.0.1' gsettings set org.gnome.system.proxy.https port "${MIXED_PORT}" gsettings set org.gnome.system.proxy.socks host '127.0.0.1' gsettings set org.gnome.system.proxy.socks port "${SOCK_PORT}" gsettings set org.gnome.system.proxy ignore-hosts "['localhost','127.0.0.0/8','::1','10.0.0.0/8','172.16.0.0/12','192.168.0.0/16']" info "system proxy enabled: http=127.0.0.1:${MIXED_PORT}, socks=127.0.0.1:${SOCK_PORT}" ;; off) gsettings set org.gnome.system.proxy mode 'none' info "system proxy disabled" ;; status) echo "mode=$(gsettings get org.gnome.system.proxy mode)" echo "http=$(gsettings get org.gnome.system.proxy.http host):$(gsettings get org.gnome.system.proxy.http port)" echo "https=$(gsettings get org.gnome.system.proxy.https host):$(gsettings get org.gnome.system.proxy.https port)" echo "socks=$(gsettings get org.gnome.system.proxy.socks host):$(gsettings get org.gnome.system.proxy.socks port)" ;; *) die "usage: $0 sysproxy [on|off|status]" ;; esac } doctor() { local prof cfg prof="$(current_profile 2>/dev/null || true)" cfg="" [[ -n "$prof" ]] && cfg="${MIHOMO_ROOT}/${prof}/config.yaml" echo "== mh doctor ==" echo "root: ${MIHOMO_ROOT}" echo "bin: ${MIHOMO_BIN}" echo "pid: ${PID_FILE}" [[ -n "$prof" ]] && echo "profile: ${prof}" || echo "profile: " [[ -n "$cfg" ]] && echo "config: ${cfg}" echo echo "== capability ==" if command -v getcap >/dev/null 2>&1; then getcap "$MIHOMO_BIN" 2>/dev/null || true else echo "getcap not found" fi echo echo "== ports ==" ss -lnt 2>/dev/null | grep -E ":(9090|7890|7891|1053)\\b" || echo "no mh ports listening" echo echo "== runtime ==" tun_status show_runtime_routing_status proxy_env_status echo echo "== api ==" api_get "/configs" 2>/dev/null | jq -r '{mode, "mixed-port": ."mixed-port", "socks-port": ."socks-port", "external-controller": ."external-controller"}' 2>/dev/null || echo "api unavailable" echo echo "== route ==" ip route show default 2>/dev/null || true echo echo "== dns ==" resolvectl status 2>/dev/null | sed -n '1,120p' || cat /etc/resolv.conf echo echo "== log tail ==" if [[ -n "$prof" && -f "${MIHOMO_ROOT}/${prof}/mihomo.log" ]]; then tail -n 40 "${MIHOMO_ROOT}/${prof}/mihomo.log" else echo "no log file" fi } #=========================debug,上=============================== main() { if [[ -z "${1:-}" ]]; then tun_status show_runtime_routing_status proxy_env_status speedtest_now 1000 || true # print_mihomo_status exit 0 fi case "${1:-}" in add) cfg_add "$@";; start) [[ -n "${2:-}" ]] || die "usage: $0 start " select_one "select" "${2}";; end) start_end "$@";; select) select_one "$@";; db) debug_one "$@";; doctor) doctor ;; env) show_proxy_exports ;; theme) shift; theme_cmd "${1:-show}" ;; sysproxy) shift; sysproxy_cmd "${1:-status}" ;; update) shift; update_providers_cmd "${1:-}" ;; mode) shift; mode_set "${1:-}" ;; rules) shift; rules_cmd "${1:-show}" ;; help|-h|--help) help ;; -l|--list|list) shift; list_nodes "${1:-}" ;; tun) shift; tun "${1:-toggle}" ;; *) pick_switch_with_delay "${1:-}" 1200 ;; esac } main "$@"