#!/usr/bin/env bash # # mihomo-up.sh — mihomo 内核的安装 / 配置 / 升级 / 卸载脚本(交互菜单 + 命令行双模式) # # 它管三样东西: # 1. 内核二进制 默认 /usr/local/bin/mihomo # 2. systemd 服务 默认 /etc/systemd/system/mihomo.service # 3. 配置文件 默认 /etc/mihomo/config.yaml # # bash <(curl -fsSL https://git.im0o.top/im0o/mihomo-up/raw/branch/main/mihomo-up.sh) # └─ 交互菜单;一键安装、修改配置、升级、卸载都在里面 # ./mihomo-up.sh check # 非交互,给 cron/CI 用,退出码见 --help # set -Eeuo pipefail SELF_VERSION="2.0.0" REPO="MetaCubeX/mihomo" UI_ZIP="https://github.com/MetaCubeX/metacubexd/archive/refs/heads/gh-pages.zip" # 测延迟用的地址:返回 204 空响应,够轻,全球都能连 HEALTH_URL="https://www.gstatic.com/generate_204" BIN="${MIHOMO_BIN:-/usr/local/bin/mihomo}" CONF_DIR="${MIHOMO_CONF_DIR:-/etc/mihomo}" SERVICE="${MIHOMO_SERVICE:-mihomo}" BACKUP_DIR="${MIHOMO_BACKUP_DIR:-/var/backups/mihomo}" UNIT_FILE="/etc/systemd/system/${SERVICE}.service" KEEP=3 # 快捷命令: 安装一个 mihomo-up 到 PATH 里,省得每次都记那条 curl 长链接。 # 它是个启动器,每次执行先拉取最新脚本,拉取失败就回落到 CACHE_DIR 里的缓存。 SCRIPT_URL="${MIHOMO_UP_URL:-https://git.im0o.top/im0o/mihomo-up/raw/branch/main/mihomo-up.sh}" LAUNCHER="${MIHOMO_UP_LAUNCHER:-/usr/local/bin/mihomo-up}" CACHE_DIR="${MIHOMO_UP_CACHE:-/var/lib/mihomo-up}" # GitHub 下载前缀。机器直连 GitHub 不通或太慢时填个反代,例如 https://ghfast.top/ MIRROR="${MIHOMO_MIRROR:-}" ARCH_OVERRIDE=""; VARIANT=""; WANT_VERSION="" FORCE=0; DRY_RUN=0; NO_RESTART=0; JSON=0; ASSUME_YES=0; PURGE=0; SKIP_CONF_CHECK=0 TMPDIR_=""; STAGED="" # ───────────────────────── 输出 ───────────────────────── if [[ -t 1 ]]; then R=$'\033[31m'; G=$'\033[32m'; Y=$'\033[33m'; B=$'\033[34m' C=$'\033[36m'; W=$'\033[1m'; D=$'\033[2m'; N=$'\033[0m' else R=""; G=""; Y=""; B=""; C=""; W=""; D=""; N="" fi log() { (( JSON )) || printf '%s\n' "$*" >&2; } info() { log "${D}··${N} $*"; } ok() { log "${G}✓${N} $*"; } warn() { log "${Y}!${N} $*"; } die() { printf '%s✗%s %s\n' "$R" "$N" "$*" >&2; exit 1; } cleanup() { local rc=$? trap - ERR [[ -n "${TMPDIR_:-}" && -d "$TMPDIR_" ]] && rm -rf "$TMPDIR_" return $rc } trap cleanup EXIT trap 'die "第 $LINENO 行出错,已中止"' ERR usage() { cat <<'EOF' mihomo-up.sh — 安装 / 配置 / 升级 / 卸载 mihomo 内核 mihomo-up.sh 进入交互菜单(终端下默认) mihomo-up.sh install 一键安装:内核 + systemd 服务 + 配置向导 + 启动 mihomo-up.sh check 只查询,不改动任何东西 mihomo-up.sh update [--force] 只更换内核二进制,不碰配置和服务定义 mihomo-up.sh config 进入配置向导,重新生成 config.yaml mihomo-up.sh service 只写入 / 刷新 systemd 服务文件 mihomo-up.sh shortcut 只安装 / 刷新 mihomo-up 快捷命令 mihomo-up.sh rollback 回滚到上一个备份的内核 mihomo-up.sh list 列出本地内核备份 mihomo-up.sh uninstall [--purge] 卸载内核和服务;--purge 连配置和备份一起删除 选项: -y, --yes 非交互确认,所有询问都当作同意 -n, --dry-run 只打印会做什么,不实际改动 --force 版本相同也重新安装 --version 指定内核版本,如 v1.19.28 --no-restart 安装完成后不启动服务 --purge uninstall 时连配置目录和备份一起删除 --skip-conf-check 升级时不用新版本预校验现有配置(配置本来就有问题时用) --json check 时额外输出一行 JSON --arch amd64 | arm64 | armv7 --variant amd64 微架构: v1 | v2 | v3 | compatible --mirror GitHub 下载前缀,如 https://ghfast.top/ --script-url 快捷命令每次去拉的脚本地址(fork 之后指到你自己那份) --bin 内核路径,默认 /usr/local/bin/mihomo --conf-dir 配置目录,默认 /etc/mihomo --service systemd 服务名,默认 mihomo -v 打印本脚本自身的版本号 check 退出码: 0 = 已是最新 10 = 有新版或未安装 1 = 出错 EOF exit "${1:-0}" } # ───────────────────────── 交互输入 ───────────────────────── # 从 /dev/tty 读,这样 bash <(curl ...) 这种 stdin 被占用的场景也能正常按键 TTY_IN="/dev/stdin" [[ -r /dev/tty ]] && TTY_IN="/dev/tty" # 注意: 这两个函数用 printf -v 往调用方的变量里写值。bash 是动态作用域,局部变量 # 重名会把调用方的变量遮住,所以这里的内部变量名都加了 __ask_ / __adf_ 前缀。 ask() { # ask "提示" varname local __ask_prompt="$1" __ask_var="$2" __ask_ans="" printf '%s' "$__ask_prompt" >&2 read -r __ask_ans < "$TTY_IN" || __ask_ans="" printf -v "$__ask_var" '%s' "$__ask_ans" } ask_default() { # ask_default "提示" "默认值" varname local __adf_def="$2" __adf_var="$3" __adf_ans="" ask "$(printf '%s %s[%s]%s ' "$1" "$D" "$__adf_def" "$N")" __adf_ans [[ -z "$__adf_ans" ]] && __adf_ans="$__adf_def" printf -v "$__adf_var" '%s' "$__adf_ans" } confirm() { # confirm "问题?" -> 0/1 (( ASSUME_YES )) && return 0 local a; ask "$1 ${D}[y/N]${N} " a [[ "$a" =~ ^[Yy]$ ]] } confirm_default_yes() { # 默认同意版,回车即 yes (( ASSUME_YES )) && return 0 local a; ask "$1 ${D}[Y/n]${N} " a [[ ! "$a" =~ ^[Nn]$ ]] } pause() { local _x; ask "$(printf '\n%s按回车继续…%s' "$D" "$N")" _x; } # ───────────────────────── 基础能力 ───────────────────────── need() { command -v "$1" >/dev/null 2>&1 || die "缺少命令: $1"; } require_root() { [[ ${EUID:-$(id -u)} -eq 0 ]] || die "$1 需要 root 权限,请用 sudo 重新运行" } has_systemd() { command -v systemctl >/dev/null 2>&1; } gh_url() { # gh_url "owner/repo/releases/..." -> 完整下载地址 local u="https://github.com/$1" [[ -n "$MIRROR" ]] && u="${MIRROR%/}/$u" printf '%s' "$u" } gen_secret() { local s="" [[ -r /dev/urandom ]] && s="$(od -An -tx1 -N12 /dev/urandom 2>/dev/null | tr -d ' \n')" || s="" [[ -n "$s" ]] || s="mihomo$(date +%s)" printf '%s' "$s" } detect_arch() { [[ -n "$ARCH_OVERRIDE" ]] && { echo "$ARCH_OVERRIDE"; return; } case "$(uname -m)" in x86_64|amd64) echo "amd64" ;; aarch64|arm64) echo "arm64" ;; armv7l|armv7) echo "armv7" ;; *) die "未适配的架构: $(uname -m)(可用 --arch 手动指定 amd64/arm64/armv7)" ;; esac } asset_name() { local arch suffix=""; arch="$(detect_arch)" [[ "$arch" == "amd64" && -n "$VARIANT" ]] && suffix="-${VARIANT}" echo "mihomo-linux-${arch}${suffix}-${1}.gz" } version_line() { # 不用 head,避免 SIGPIPE 触发 ERR trap local out; out="$("$1" -v 2>/dev/null || true)" printf '%s' "${out%%$'\n'*}" } current_version() { [[ -x "$BIN" ]] || { printf ''; return; } local line; line="$(version_line "$BIN")" [[ -n "$line" ]] && awk '{print $3}' <<<"$line" || printf '' } latest_version() { local v v="$(curl -fsSL --retry 3 --connect-timeout 10 \ "$(gh_url "${REPO}/releases/latest/download/version.txt")" | tr -d '[:space:]')" \ || die "拉取最新版本号失败(GitHub 不通?可用 --mirror 指定反代前缀)" [[ "$v" =~ ^v[0-9]+\.[0-9]+ ]] || die "拿到的版本号格式不对: $v" echo "$v" } svc_state() { has_systemd || { echo "无 systemd"; return; } if systemctl is-active --quiet "$SERVICE" 2>/dev/null; then echo "running" elif [[ -f "$UNIT_FILE" ]] || systemctl list-unit-files 2>/dev/null | grep -q "^${SERVICE}\.service"; then echo "stopped" else echo "未安装"; fi } svc_enabled() { # 是否开机自启 has_systemd || { echo "-"; return; } systemctl is-enabled "$SERVICE" 2>/dev/null || echo "disabled" } conf_file() { printf '%s/config.yaml' "$CONF_DIR"; } # ───────────────────────── 内核二进制 ───────────────────────── stage_binary() { # $1 = 目标版本;成功后 STAGED 指向校验过的新二进制 local target="$1" asset url [[ -n "${TMPDIR_:-}" && -d "${TMPDIR_:-}" ]] || TMPDIR_="$(mktemp -d)" asset="$(asset_name "$target")" url="$(gh_url "${REPO}/releases/download/${target}/${asset}")" info "下载 $asset" curl -fL -# --retry 3 --connect-timeout 15 -o "$TMPDIR_/m.gz" "$url" \ || die "下载失败: $url(网络不通就加 --mirror 换个下载源,版本号写错也会 404)" gzip -dc "$TMPDIR_/m.gz" > "$TMPDIR_/mihomo" || die "解压失败,下载到的不是有效的 .gz 文件" chmod +x "$TMPDIR_/mihomo" # 闸门 1: 新二进制能运行起来,且自报版本号和目标一致 local line ver line="$(version_line "$TMPDIR_/mihomo")" [[ -n "$line" ]] || die "新二进制执行不了,多半是架构选错了(这次按 $(detect_arch) 下载,可用 --arch 覆盖)" ver="$(awk '{print $3}' <<<"$line")" [[ "$ver" == "$target" ]] || die "版本对不上: 期望 $target,实际 $ver" ok "校验通过: $line" STAGED="$TMPDIR_/mihomo" } test_conf_with() { # test_conf_with <二进制路径>;配置无效返回 1,输出错误 local b="$1" [[ -f "$(conf_file)" ]] || return 0 # 没配置就没什么可校验的 "$b" -t -d "$CONF_DIR" 2>&1 } do_update() { # $1 = 目标版本;只换二进制,不动配置和服务定义 local target="$1" cur; cur="$(current_version)" if [[ "$cur" == "$target" && $FORCE -eq 0 ]]; then ok "已是最新 ($cur),无需操作"; return 0 fi require_root "替换 $BIN 并重启服务" if (( DRY_RUN )); then ok "[dry-run] 会下载 $(asset_name "$target") 并安装到 $BIN"; return 0 fi stage_binary "$target" # 闸门 2: 用新版本校验现有配置。现有配置在新内核上通不过,就不要替换 if [[ -f "$(conf_file)" ]] && (( SKIP_CONF_CHECK == 0 )); then local out if out="$(test_conf_with "$STAGED")"; then ok "现有配置在 $target 下校验通过" else warn "$target 校验现有配置失败:"; sed 's/^/ /' <<<"$out" >&2 die "没有替换任何文件。先按上面的报错修改配置(改完用 $BIN -t -d $CONF_DIR 重新校验),或加 --skip-conf-check 只更换内核不管配置" fi fi mkdir -p "$BACKUP_DIR" if [[ -x "$BIN" && -n "$cur" ]]; then cp -a "$BIN" "$BACKUP_DIR/mihomo-$cur"; info "已备份旧版 → $BACKUP_DIR/mihomo-$cur" fi local was_active=0 [[ "$(svc_state)" == "running" ]] && was_active=1 trap - ERR # 下面自己处理失败并回滚 (( was_active )) && { info "停止 $SERVICE"; systemctl stop "$SERVICE"; } install -m 0755 "$STAGED" "$BIN" ok "已安装 $target → $BIN" if (( NO_RESTART )); then warn "--no-restart: 记得手动 systemctl start $SERVICE" elif (( was_active )); then systemctl start "$SERVICE" || true sleep 2 if [[ "$(svc_state)" == "running" ]]; then ok "$SERVICE 运行中" else warn "$SERVICE 启动失败,正在回滚到 $cur" if [[ -f "$BACKUP_DIR/mihomo-$cur" ]]; then install -m 0755 "$BACKUP_DIR/mihomo-$cur" "$BIN" systemctl start "$SERVICE" || true warn "已回滚到 $cur;看失败原因: journalctl -u $SERVICE -n 50" else warn "没有 $cur 的备份可回滚;看日志: journalctl -u $SERVICE -n 50" fi trap 'die "第 $LINENO 行出错,已中止"' ERR return 1 fi fi compgen -G "$BACKUP_DIR/mihomo-*" >/dev/null && \ ls -1t "$BACKUP_DIR"/mihomo-* | tail -n +$((KEEP + 1)) | xargs -r rm -f ok "完成: ${cur:-无} → $target" trap 'die "第 $LINENO 行出错,已中止"' ERR } do_rollback() { local b; b="$(ls -1t "$BACKUP_DIR"/mihomo-* 2>/dev/null | sed -n '1p' || true)" [[ -n "$b" ]] || { warn "没有可回滚的备份(备份目录 $BACKUP_DIR 是空的)"; return 1; } require_root "替换 $BIN" info "回滚到 $(basename "$b")" (( DRY_RUN )) && { ok "[dry-run] 到此为止"; return 0; } systemctl stop "$SERVICE" 2>/dev/null || true install -m 0755 "$b" "$BIN" systemctl start "$SERVICE" 2>/dev/null || true ok "已回滚: $(version_line "$BIN")" } do_list() { if compgen -G "$BACKUP_DIR/mihomo-*" >/dev/null 2>&1; then ls -1t "$BACKUP_DIR"/mihomo-* | while read -r f; do printf ' %s %s%s%s\n' "$(basename "$f")" "$D" "$(date -r "$f" '+%F %T')" "$N" done else log " ${D}还没有备份(备份目录 $BACKUP_DIR)${N}" fi } # ───────────────────────── systemd 服务 ───────────────────────── # 参考 MetaCubeX 官方文档 https://wiki.metacubex.one/startup/service/ write_unit() { cat > "$1" </dev/null 2>&1 && info "已设为开机自启" systemctl restart "$SERVICE" || true sleep 2 if [[ "$(svc_state)" == "running" ]]; then ok "$SERVICE 已启动" else warn "$SERVICE 没有启动成功。看具体原因: journalctl -u $SERVICE -n 50 --no-pager" return 1 fi } # ───────────────────────── 快捷命令 ───────────────────────── # 设计取舍: 启动器每次运行都拉取最新脚本,所以你改了仓库、客户那边下次执行就用上了, # 不需要谁去手动更新。但它落一份缓存并在拉取失败时回落 —— 这个脚本管的是代理, # 你最需要它的时候常常正是网络不正常的时候,那时候必须还能进菜单重启服务、看日志。 write_launcher() { local out="$1" { printf '#!/usr/bin/env bash\n' printf '# mihomo-up 启动器 — 由 mihomo-up.sh v%s 生成,别手动改,重新安装会覆盖\n' "$SELF_VERSION" printf '# 先拉取最新脚本运行;拉取失败 (网络断了 / 仓库挂了) 就用本地缓存。\n' printf 'SRC="${MIHOMO_UP_URL:-%s}"\n' "$SCRIPT_URL" printf 'CACHE="%s/mihomo-up.sh"\n' "$CACHE_DIR" cat <<'EOF' TMP="$(mktemp)"; trap 'rm -f "$TMP"' EXIT RUN="" # --connect-timeout / --max-time: 网络卡住时别让菜单一直转,那正是要用它的时候 if curl -fsSL --connect-timeout 5 --max-time 20 "$SRC" -o "$TMP" && [[ -s "$TMP" ]]; then # 拿到了不等于能运行: 上游推坏了、或者中间设备塞回来一个门户登录页, # 都不该以 root 执行。语法过不了就当这次没拉到, 回落缓存。 if bash -n "$TMP" 2>/dev/null; then mkdir -p "${CACHE%/*}" 2>/dev/null || true if install -m 0755 "$TMP" "$CACHE" 2>/dev/null; then RUN="$CACHE"; else RUN="$TMP"; fi else printf '! 拉到的内容没通过语法检查,已丢弃 (上游坏了? 返回的根本不是脚本?)\n' >&2 fi else printf '! 拉取最新脚本失败: %s\n' "$SRC" >&2 fi if [[ -z "$RUN" && -x "$CACHE" ]]; then printf ' 改用本地缓存 (%s)\n' "$(date -r "$CACHE" '+%F %T')" >&2 RUN="$CACHE" fi [[ -n "$RUN" ]] || { printf '✗ 远端拉取失败,本地也没有缓存,没有可执行的脚本\n' >&2; exit 1; } bash "$RUN" "$@" EOF } > "$out" } seed_cache() { # 先放一份缓存,这样安装完成后立刻断网也能用 local cache="$CACHE_DIR/mihomo-up.sh" mkdir -p "$CACHE_DIR" # bash <(curl ...) 运行的时候 $0 是条管道,内容已被 bash 读掉一截,复制不出完整脚本, # 只有 $0 是真实文件时才能直接拷,否则老老实实重新下一份。 if [[ -f "$0" && "$0" != /dev/fd/* && "$0" != /proc/* ]]; then install -m 0755 "$0" "$cache"; return 0 fi local tmp; tmp="$(mktemp)" if curl -fsSL --retry 2 --connect-timeout 10 "$SCRIPT_URL" -o "$tmp" \ && [[ -s "$tmp" ]] && bash -n "$tmp" 2>/dev/null; then install -m 0755 "$tmp" "$cache"; rm -f "$tmp"; return 0 fi rm -f "$tmp" warn "缓存没建立: 从 $SCRIPT_URL 下载失败" warn "快捷命令照样能用,但这台机器断网时它既拉取不到脚本、也没有缓存可回落,会直接报错" return 1 } install_launcher() { require_root "写入 $LAUNCHER" if (( DRY_RUN )); then ok "[dry-run] 会写入快捷命令 $LAUNCHER"; return 0; fi local tmp; tmp="$(mktemp)" write_launcher "$tmp" bash -n "$tmp" || { rm -f "$tmp"; die "生成的启动器语法有问题,没有安装(这是脚本自身的 bug,请反馈)"; } install -m 0755 "$tmp" "$LAUNCHER"; rm -f "$tmp" seed_cache || true ok "快捷命令已安装好: 以后直接输入 ${W}$(basename "$LAUNCHER")${N} 就能进菜单" } # ───────────────────────── 配置 ───────────────────────── # 说明:下面这组函数只改 config.yaml 顶层的简单键(形如 `mixed-port: 7890` 这种 # 顶格写、值在同一行的),改之前一定先备份、改完一定校验,失败就还原。 # 缩进层级里的东西(比如 dns、proxy-groups)不碰,那种请用「编辑配置」手改。 cfg_backup() { # 备份当前配置,输出备份路径 local f; f="$(conf_file)" [[ -f "$f" ]] || return 0 local bak="${f}.bak-$(date +%Y%m%d%H%M%S)" cp -a "$f" "$bak" printf '%s' "$bak" } cfg_restore() { # cfg_restore <备份路径> [[ -n "${1:-}" && -f "$1" ]] || return 0 cp -a "$1" "$(conf_file)" warn "已还原到改动前的配置" } cfg_require() { # 没有配置文件时,给一句能照着做的提示 [[ -f "$(conf_file)" ]] && return 0 warn "还没有配置文件 $(conf_file),先运行一遍配置向导生成(菜单「配置管理 → 生成配置」,命令行是 $0 config)" return 1 } cfg_get() { # cfg_get <顶层键> -> 值(拿不到就是空) local key="$1" f; f="$(conf_file)" [[ -f "$f" ]] || return 0 sed -n -E "s/^${key}:[[:space:]]*//p" "$f" | sed -n '1p' | sed -E 's/[[:space:]]*#.*$//; s/^"(.*)"$/\1/; s/^'"'"'(.*)'"'"'$/\1/' } cfg_set() { # cfg_set <顶层键> <值> local key="$1" val="$2" f; f="$(conf_file)" [[ -f "$f" ]] || die "找不到 $f,先生成配置" local esc; esc="$(printf '%s' "$val" | sed -e 's/[\\&|]/\\&/g')" if grep -qE "^${key}:" "$f"; then sed -i -E "0,/^${key}:.*/s|^${key}:.*|${key}: ${esc}|" "$f" else printf '%s: %s\n' "$key" "$val" >> "$f" fi } cfg_validate() { # 校验配置;无效时打印错误并返回 1 local f; f="$(conf_file)" [[ -f "$f" ]] || { warn "还没有配置文件 $f"; return 1; } [[ -x "$BIN" ]] || { warn "内核还没安装($BIN 不存在),无法校验,先「一键安装」"; return 1; } local out if out="$("$BIN" -t -d "$CONF_DIR" 2>&1)"; then ok "配置有效"; return 0 else warn "配置有问题:"; sed 's/^/ /' <<<"$out" >&2; return 1 fi } cfg_apply() { # cfg_apply <备份路径> — 校验并按需重启;失败给还原选项 local bak="${1:-}" if cfg_validate; then if [[ "$(svc_state)" == "running" ]]; then confirm_default_yes " 重启 $SERVICE 让新配置生效?" && { systemctl restart "$SERVICE" && ok "$SERVICE 已重启"; } fi return 0 fi if [[ -n "$bak" ]]; then confirm_default_yes " 配置校验没过,还原到改动前?" && cfg_restore "$bak" fi return 1 } # 订阅里的节点名千奇百怪,用一串关键词把常见写法都罩住。(?i) 是忽略大小写。 region_filter() { case "$1" in HK) printf '(?i)香港|hk|hong ?kong|🇭🇰' ;; TW) printf '(?i)台湾|台灣|tw|taiwan|🇹🇼' ;; JP) printf '(?i)日本|jp|japan|东京|大阪|🇯🇵' ;; SG) printf '(?i)新加坡|sg|singapore|狮城|獅城|🇸🇬' ;; US) printf '(?i)美国|美國|us|united ?states|usa|🇺🇸' ;; KR) printf '(?i)韩国|韓國|kr|korea|首尔|首爾|🇰🇷' ;; UK|GB) printf '(?i)英国|英國|uk|united ?kingdom|london|🇬🇧' ;; DE) printf '(?i)德国|德國|de|germany|🇩🇪' ;; FR) printf '(?i)法国|法國|fr|france|🇫🇷' ;; *) return 1 ;; esac } REGIONS_SUPPORTED="HK TW JP SG US KR UK DE FR" cc_normalize() { # 两位国家代码,转大写;不合格式就返回空并说明 local cc; cc="$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]')" if [[ "$cc" =~ ^[A-Z]{2}$ ]]; then printf '%s' "$cc" else warn " «$1» 不是两位国家代码,这条地区规则就先不写了,之后可以手动加" printf '' fi } # meta-rules-dat 的单个规则集。.mrs 是二进制格式,比整包 geosite.dat 小得多, # 只下你真正要用的那几个列表。走 gh_url 所以 --mirror 对它同样生效。 mrs_url() { gh_url "MetaCubeX/meta-rules-dat/raw/meta/geo/$1/$2.mrs"; } cfg_group_names() { # 配置里现有的策略组名(入站选出站时用) local f; f="$(conf_file)" [[ -f "$f" ]] || return 0 # 组名后面常跟着行内注释,先切到第一个 # 为止,再去掉尾部空格 sed -n -E 's/^ - name:[[:space:]]*([^#]*).*$/\1/p' "$f" | sed -E 's/[[:space:]]+$//' } cfg_wizard() { # 生成一份全新的 config.yaml local f; f="$(conf_file)" mkdir -p "$CONF_DIR" log "" log "${W} 配置向导${N} ${D}— 回车用方括号里的默认值${N}" log "${D} 生成的是一份最小可用配置:一个订阅、一组选择/自动测速分组、内网直连其余走代理。${N}" log "" local port lan ctrl_mode ctrl secret ui rules_mode local old_list old_port old_ctrl old_secret old_list="$(cfg_sub_list || true)"; old_port="$(cfg_get 'mixed-port' || true)" old_ctrl="$(cfg_get 'external-controller' || true)"; old_secret="$(cfg_get 'secret' || true)" # ── 订阅:可以填多个 ── local -a subs=() if [[ -n "$old_list" ]]; then log " ${D}现有订阅(回车=沿用,输入新地址=覆盖):${N}" while IFS=$'\t' read -r _i _n _u; do [[ -n "$_u" ]] && log " $_i. $_u"; done <<<"$old_list" fi local n=1 s dflt tries=0 while :; do dflt="$(sed -n "${n}p" <<<"$old_list" | cut -f3)" if (( n == 1 )); then ask_default " 订阅 #1(机场给的 http(s) 地址)" "$dflt" s if [[ -z "$s" ]]; then (( ++tries >= 3 )) && { warn " 一直没拿到订阅链接,向导先退出了,配置没有改动"; return 1; } warn " 至少要有一个订阅,一个节点都没有 mihomo 启动不了" continue fi else ask_default " 订阅 #${n}(有别的机场就继续加,回车=到此为止)" "$dflt" s [[ -z "$s" ]] && break fi subs+=("$s"); n=$((n + 1)) (( n > 8 )) && { info " 最多 8 个订阅,够用了"; break; } done (( ${#subs[@]} > 1 )) && ok " 共 ${#subs[@]} 个订阅,节点会合并到同一组里" ask_default " 本地代理端口(HTTP 和 SOCKS5 共用)" "${old_port:-7890}" port [[ "$port" =~ ^[0-9]+$ ]] || die "端口要是数字,收到的是: $port" if confirm " 允许同局域网的其它设备用这个代理?"; then lan="true"; else lan="false"; fi log "" log " ${D}管理接口用来接 Web 面板和 API。开在 0.0.0.0 等于对外网暴露,务必设密钥。${N}" ask_default " 管理接口: 1=只允许本机 2=局域网/公网可访问 3=不开启" "1" ctrl_mode case "$ctrl_mode" in 2) local cport; ask_default " 管理接口端口" "9090" cport; ctrl="0.0.0.0:${cport}" ;; 3) ctrl="" ;; *) local cport; ask_default " 管理接口端口" "9090" cport; ctrl="127.0.0.1:${cport}" ;; esac secret="" if [[ -n "$ctrl" ]]; then ask_default " 管理接口密钥(回车=自动生成一个)" "${old_secret:-$(gen_secret)}" secret if confirm_default_yes " 顺带安装一个 Web 面板(metacubexd,内核启动时自动下载)?"; then ui="yes"; else ui="no"; fi else ui="no" fi # ── 按地区拆分组 ── log "" log " ${D}可以按地区把节点拆成几个组,每组自动挑该地区里延迟最低的那个。${N}" log " ${D}支持 ${REGIONS_SUPPORTED},留空=不拆,所有节点放一组。${N}" local regions_in; ask_default " 拆哪些地区(空格分隔)" "" regions_in local -a rlist=(); local r for r in $regions_in; do r="$(printf '%s' "$r" | tr '[:lower:]' '[:upper:]')" if region_filter "$r" >/dev/null 2>&1; then rlist+=("$r") else warn " 不认识地区代码 «$r»,跳过它(支持: ${REGIONS_SUPPORTED})" fi done (( ${#rlist[@]} )) && warn " 提醒: 某个地区一个节点都没匹配上的话,内核会拒绝启动。下面的校验会告诉你" # ── 分流规则 ── log "" log " ${D}分流规则。三种模式下内网地址都是直连。${N}" log " ${C}1${N} 全部走代理" log " ${C}2${N} 指定地区直连、其余走代理 ${D}(常见的翻墙用法)${N}" log " ${C}3${N} 指定地区走代理、其余直连 ${D}(回国/分区代理)${N}" ask_default " 选哪个" "1" rules_mode local direct_cc="" proxy_cc="" case "$rules_mode" in 2) ask_default " 哪个地区直连(两位 ISO 代码,如 CN / US / JP / DE)" "CN" direct_cc direct_cc="$(cc_normalize "$direct_cc")" ;; 3) ask_default " 哪个地区走代理(两位 ISO 代码,回国填 CN)" "CN" proxy_cc proxy_cc="$(cc_normalize "$proxy_cc")" if [[ -n "$proxy_cc" && "$proxy_cc" != "CN" ]]; then warn " 只有 CN 有成体系的域名列表,$proxy_cc 只能按 IP 匹配" warn " 后果: 该地区挂在境外 CDN 上的站点会漏掉,走直连而不是代理" fi ;; esac # 先写到临时文件,确认无误再落盘 local tmp; tmp="$(mktemp)" { cat < "$tmp" local bak="" if [[ -f "$f" ]]; then confirm " $f 已存在,覆盖它?(旧的会先备份)" || { rm -f "$tmp"; warn " 已取消,配置没动"; return 1; } bak="$(cfg_backup)" [[ -n "$bak" ]] && info "旧配置已备份 → $bak" fi install -m 0644 "$tmp" "$f"; rm -f "$tmp" ok "配置已写入 $f" cfg_apply "$bak" || warn "配置写进去了但校验没过,可以用「编辑配置」改,或重新运行一遍向导" } # 订阅行由本脚本写入,行尾带 "# mihomo-up:sub:" 标记。 # 认这个标记而不是猜 YAML 结构,所以手写配置里别处的 url: 不会被误伤。 cfg_sub_list() { # 每行输出「序号 provider名 订阅地址」 local f; f="$(conf_file)" [[ -f "$f" ]] || return 0 sed -n -E 's|^[[:space:]]*url:[[:space:]]*"?([^"#]*)"?[[:space:]]*#[[:space:]]*mihomo-up:sub:?([A-Za-z0-9_-]*).*|\2\t\1|p' "$f" \ | sed -E 's/\t[[:space:]]*/\t/; s/[[:space:]]+$//' \ | awk -F'\t' '{ printf "%d\t%s\t%s\n", NR, ($1 == "" ? "subs" : $1), $2 }' } cfg_set_sub() { # cfg_set_sub <序号> <新地址> local idx="$1" new="$2" f tmp; f="$(conf_file)"; tmp="$(mktemp)" # 整行重建(缩进 + 新地址 + 原样保留行尾标记),比在原行上做替换更不容易出错 awk -v idx="$idx" -v url="$new" ' ($0 ~ /url:/ && $0 ~ /mihomo-up:sub/) { n++ if (n == idx) { indent = $0; sub(/[^ ].*$/, "", indent) tag = $0; sub(/^.*#/, "#", tag) print indent "url: \"" url "\" " tag next } } { print } ' "$f" > "$tmp" && cat "$tmp" > "$f" rm -f "$tmp" } cfg_sub_menu() { cfg_require || return 1 local f list; f="$(conf_file)" list="$(cfg_sub_list)" if [[ -z "$list" ]]; then warn "在 $f 里没找到本脚本写的订阅行(标记是行尾的 # mihomo-up:sub:...)" warn "这份配置多半是手写或外部导入的。改订阅请用「手动编辑配置」,或重新运行向导生成一份新的" return 1 fi log "" log " ${D}当前订阅:${N}" local i nm u while IFS=$'\t' read -r i nm u; do [[ -n "$u" ]] && printf ' %s%s.%s %s %s(%s)%s\n' "$C" "$i" "$N" "$u" "$D" "$nm" "$N" >&2 done <<<"$list" log " ${D}增加或删除订阅要重新生成配置,向导会预先填好现有这几条。${N}" local total; total="$(grep -c . <<<"$list")" local pick="1" (( total > 1 )) && ask_default " 改哪一条(序号)" "1" pick if [[ ! "$pick" =~ ^[0-9]+$ ]] || (( pick < 1 || pick > total )); then warn "序号要在 1 到 $total 之间,收到的是「$pick」"; return 1 fi nm="$(sed -n "${pick}p" <<<"$list" | cut -f2)" local cur; cur="$(sed -n "${pick}p" <<<"$list" | cut -f3)" local new; ask_default " 第 $pick 条的新地址" "$cur" new [[ -n "$new" && "$new" != "$cur" ]] || { info "没有改动"; return 0; } local bak; bak="$(cfg_backup)" cfg_set_sub "$pick" "$new" ok "第 $pick 条订阅已更新" # 旧节点缓存留着的话内核会继续用它,删掉才会去拉取新地址 rm -f "$CONF_DIR/providers/${nm}.yaml" 2>/dev/null || true cfg_apply "$bak" } cfg_change_port() { cfg_require || return 1 local cur; cur="$(cfg_get 'mixed-port')" local new; ask_default " 本地代理端口" "${cur:-7890}" new [[ "$new" =~ ^[0-9]+$ ]] || { warn "端口要是数字"; return 1; } [[ "$new" == "$cur" ]] && { info "没有改动"; return 0; } local bak; bak="$(cfg_backup)" cfg_set 'mixed-port' "$new" ok "代理端口 → $new" cfg_apply "$bak" } cfg_toggle_lan() { cfg_require || return 1 local cur; cur="$(cfg_get 'allow-lan')" local new="true"; [[ "$cur" == "true" ]] && new="false" local bak; bak="$(cfg_backup)" cfg_set 'allow-lan' "$new" if [[ "$new" == "true" ]]; then ok "已允许局域网设备连本机代理。还要在防火墙放行 $(cfg_get 'mixed-port') 端口,否则外面照样连不进来" else ok "已改回只允许本机使用" fi cfg_apply "$bak" } cfg_change_ctrl() { cfg_require || return 1 local cur_ctrl cur_secret cur_ctrl="$(cfg_get 'external-controller')"; cur_secret="$(cfg_get 'secret')" log " ${D}当前: ${cur_ctrl:-未开启} 密钥: ${cur_secret:-无}${N}" local mode; ask_default " 管理接口: 1=只允许本机 2=局域网/公网可访问 3=不开启" "1" mode local bak; bak="$(cfg_backup)" case "$mode" in 3) cfg_set 'external-controller' '""'; ok "已关闭管理接口(面板也就连不上了)" ;; 2) local p; ask_default " 端口" "9090" p cfg_set 'external-controller' "0.0.0.0:${p}" local s; ask_default " 访问密钥(对外暴露必须设)" "${cur_secret:-$(gen_secret)}" s cfg_set 'secret' "\"$s\"" ok "管理接口 → 0.0.0.0:${p},密钥 ${s}" warn "0.0.0.0 意味着任何能连到这台机器的人都能访问它,请用防火墙只放行你信任的来源" ;; *) local p; ask_default " 端口" "9090" p cfg_set 'external-controller' "127.0.0.1:${p}" local s; ask_default " 访问密钥" "${cur_secret:-$(gen_secret)}" s cfg_set 'secret' "\"$s\"" ok "管理接口 → 127.0.0.1:${p}(只有本机能连)" ;; esac cfg_apply "$bak" } cfg_import_url() { local url; ask " 完整 config.yaml 的下载地址: " url [[ -n "$url" ]] || { info "已取消"; return 0; } mkdir -p "$CONF_DIR" local tmp; tmp="$(mktemp)" curl -fsSL --retry 2 --connect-timeout 15 -o "$tmp" "$url" || { rm -f "$tmp"; warn "下载失败: $url"; return 1; } [[ -s "$tmp" ]] || { rm -f "$tmp"; warn "下载到的是空文件,没有覆盖任何东西"; return 1; } local bak="" [[ -f "$(conf_file)" ]] && { bak="$(cfg_backup)"; info "旧配置已备份 → $bak"; } install -m 0644 "$tmp" "$(conf_file)"; rm -f "$tmp" ok "已导入到 $(conf_file)" cfg_apply "$bak" } cfg_edit() { local f; f="$(conf_file)" [[ -f "$f" ]] || { warn "还没有配置文件,先用「生成配置」运行一遍向导"; return 1; } local ed="${EDITOR:-}" [[ -n "$ed" ]] || { command -v nano >/dev/null 2>&1 && ed=nano || ed=vi; } command -v "${ed%% *}" >/dev/null 2>&1 || { warn "找不到编辑器 $ed,可以先 export EDITOR=你的编辑器 再回来"; return 1; } local bak; bak="$(cfg_backup)" # 编辑器要直接接管终端,否则 bash <(curl ...) 这种场景下它读不到按键 if [[ -w /dev/tty ]]; then "$ed" "$f" /dev/tty 2>&1 || true else "$ed" "$f" || true; fi cfg_apply "$bak" } cfg_backup_menu() { local f; f="$(conf_file)" log "" log " ${D}配置备份(每次改动前自动生成):${N}" if compgen -G "${f}.bak-*" >/dev/null 2>&1; then ls -1t "${f}".bak-* | while read -r b; do printf ' %s %s%s%s\n' "$(basename "$b")" "$D" "$(date -r "$b" '+%F %T')" "$N" done else log " ${D}还没有备份${N}"; return 0 fi confirm " 还原到最近的一份备份?" || return 0 local last; last="$(ls -1t "${f}".bak-* | sed -n '1p')" cp -a "$last" "$f" ok "已还原 $(basename "$last")" cfg_apply "" } # ───────────────────────── 入站端口 ───────────────────────── # 一个端口对应一个出站:从 7801 进来的全走香港,从 7802 进来的全走日本。 # 每条入站写成一行 YAML 流式映射并带标记,加/删就是加一行/删一行,不动别处。 LISTENER_TAG="# mihomo-up:listener" cfg_listener_list() { # 每行输出「序号 原文件行号 整行内容」 local f; f="$(conf_file)" [[ -f "$f" ]] || return 0 # 末尾的 || true 不能省: 一条入站都没有时 grep 退出码是 1,配上 pipefail # 会让整个函数返回失败,进而触发 ERR trap —— 但"没有入站"是正常状态,不是错误 grep -n "$LISTENER_TAG" "$f" 2>/dev/null \ | sed -E 's/^([0-9]+):[[:space:]]*/\1\t/' \ | awk -F'\t' '{ printf "%d\t%s\t%s\n", NR, $1, $2 }' || true } cfg_listener_show() { local list; list="$(cfg_listener_list)" if [[ -z "$list" ]]; then log " ${D}还没有额外的入站端口。主代理端口 $(cfg_get 'mixed-port') 照常走 rules。${N}" return 0 fi log " ${D}额外的入站端口:${N}" local i _ln body while IFS=$'\t' read -r i _ln body; do [[ -n "$body" ]] || continue local nm pt px nm="$(sed -E 's/.*name:[[:space:]]*([^,}]*).*/\1/' <<<"$body")" pt="$(sed -E 's/.*port:[[:space:]]*([^,}]*).*/\1/' <<<"$body")" px="$(grep -o 'proxy:[^,}]*' <<<"$body" | sed -E 's/proxy:[[:space:]]*//' || true)" printf ' %s%s.%s 端口 %-6s → %s %s(%s)%s\n' \ "$C" "$i" "$N" "$pt" "${px:-按 rules 分流}" "$D" "$nm" "$N" >&2 done <<<"$list" } cfg_listener_add() { cfg_require || return 1 local port name type out ask_default " 新入站的端口" "7801" port [[ "$port" =~ ^[0-9]+$ ]] || { warn "端口要是数字,收到的是「$port」"; return 1; } if grep -q "port: ${port}," "$(conf_file)" 2>/dev/null; then warn "端口 $port 已经被另一条入站占了"; return 1 fi [[ "$port" == "$(cfg_get 'mixed-port')" ]] && { warn "端口 $port 就是主代理端口,换一个"; return 1; } ask_default " 入站类型(mixed=HTTP+SOCKS5 / http / socks)" "mixed" type case "$type" in mixed|http|socks) ;; *) warn "类型只能是 mixed / http / socks"; return 1 ;; esac log "" log " ${D}这个端口的流量走哪个出站:${N}" local -a groups=(); local g while IFS= read -r g; do [[ -n "$g" ]] && groups+=("$g"); done < <(cfg_group_names) local idx=1 printf ' %s0.%s 按 rules 分流 %s(和主端口一样)%s\n' "$C" "$N" "$D" "$N" >&2 for g in ${groups[@]+"${groups[@]}"}; do printf ' %s%s.%s %s\n' "$C" "$idx" "$N" "$g" >&2; idx=$((idx + 1)) done printf ' %s%s.%s DIRECT %s(不走代理)%s\n' "$C" "$idx" "$N" "$D" "$N" >&2 local pick; ask_default " 选一个" "0" pick [[ "$pick" =~ ^[0-9]+$ ]] || { warn "要填序号"; return 1; } if (( pick == 0 )); then out="" elif (( pick == idx )); then out="DIRECT" elif (( pick >= 1 && pick < idx )); then out="${groups[pick-1]}" else warn "没有这个序号"; return 1; fi name="in-${port}" local line=" - { name: ${name}, type: ${type}, listen: 0.0.0.0, port: ${port}" [[ -n "$out" ]] && line+=", proxy: ${out}" line+=" } ${LISTENER_TAG}" local bak; bak="$(cfg_backup)" local f tmp; f="$(conf_file)"; tmp="$(mktemp)" if grep -q '^listeners:' "$f"; then awk -v l="$line" '{ print } /^listeners:[[:space:]]*$/ && !d { print l; d=1 }' "$f" > "$tmp" else { cat "$f"; printf '\nlisteners:\n%s\n' "$line"; } > "$tmp" fi cat "$tmp" > "$f"; rm -f "$tmp" if [[ -n "$out" ]]; then ok "入站 $port → $out" warn "注意: 指定了出站就完全跳过 rules,从这个端口进来的流量一律走 $out" else ok "入站 $port 已添加,按 rules 分流" fi cfg_apply "$bak" } cfg_listener_del() { cfg_require || return 1 local list; list="$(cfg_listener_list)" [[ -n "$list" ]] || { warn "没有可删除的额外入站端口"; return 1; } cfg_listener_show local total; total="$(grep -c . <<<"$list")" local pick; ask_default " 删除哪一条(序号)" "" pick if [[ ! "$pick" =~ ^[0-9]+$ ]] || (( pick < 1 || pick > total )); then warn "序号要在 1 到 $total 之间,收到的是「$pick」"; return 1 fi local bak; bak="$(cfg_backup)" local f tmp; f="$(conf_file)"; tmp="$(mktemp)" awk -v idx="$pick" -v tag="$LISTENER_TAG" ' index($0, tag) { n++; if (n == idx) next } { print } ' "$f" > "$tmp" # 删光了就把空的 listeners: 标题也去掉,留着它是个没有值的键 if ! grep -q "$LISTENER_TAG" "$tmp"; then awk '!/^listeners:[[:space:]]*$/' "$tmp" > "${tmp}.2" && mv "${tmp}.2" "$tmp" fi cat "$tmp" > "$f"; rm -f "$tmp" ok "第 $pick 条入站已删除" cfg_apply "$bak" } cfg_listener_menu() { while true; do log "" log "${W} 入站端口${N} ${D}— 多开几个本地端口,各自绑不同出站${N}" cfg_listener_show cat >&2 <%s ' "$W" "$N")" c echo >&2 case "$c" in 1) cfg_listener_add || true; pause ;; 2) cfg_listener_del || true; pause ;; 0|q|"") return 0 ;; *) warn "没有「$c」这个选项,输入上面列出的数字,或 0 返回"; sleep 1 ;; esac done } # ───────────────────────── GEO / 规则集 ───────────────────────── # 这些数据文件决定 GEOIP / GEOSITE / RULE-SET 规则能不能匹配上。 # 内核缺文件时会自己去下载,但下载失败是静默的:规则一条都匹配不上, # 流量全落到最后的 MATCH,看起来就像"配置没生效"。所以这里给出可见的状态。 ctrl_api() { # ctrl_api <方法> <路径> [数据];管理接口没开就返回 1 local ctrl secret host port ctrl="$(cfg_get 'external-controller')"; secret="$(cfg_get 'secret')" [[ -n "$ctrl" ]] || return 1 host="${ctrl%:*}"; port="${ctrl##*:}" [[ -z "$host" || "$host" == "0.0.0.0" || "$host" == "[::]" ]] && host="127.0.0.1" local -a args=(-fsS -m 30 -X "$1") [[ -n "$secret" ]] && args+=(-H "Authorization: Bearer $secret") [[ -n "${3:-}" ]] && args+=(-H "Content-Type: application/json" -d "$3") curl "${args[@]}" "http://${host}:${port}$2" } geo_show() { log " ${D}$CONF_DIR 里的数据文件:${N}" local found=0 p for p in "$CONF_DIR"/geoip.dat "$CONF_DIR"/geosite.dat "$CONF_DIR"/geoip.metadb \ "$CONF_DIR"/country.mmdb "$CONF_DIR"/ASN.mmdb "$CONF_DIR"/rules/*.mrs; do [[ -f "$p" ]] || continue found=1 printf ' %-22s %8s %s%s%s\n' "$(basename "$p")" \ "$(du -h "$p" 2>/dev/null | cut -f1)" "$D" "$(date -r "$p" '+%F %T')" "$N" >&2 done if (( found == 0 )); then log " ${Y}一个都没有${N}" log " ${D}配置里没用到 GEOIP/GEOSITE/RULE-SET 的话,这是正常的。${N}" log " ${D}用到了却没有文件,说明内核还没下载成功,规则会全部匹配不上。${N}" fi local au; au="$(cfg_get 'geo-auto-update')" log " ${D}自动更新 geo-auto-update: ${au:-未设置(默认关)}${N}" } geo_refresh() { cfg_require || return 1 info "让内核重新下载 GEO 数据库…" if ctrl_api POST /configs/geo '{"path":"","payload":""}' >/dev/null 2>&1; then ok "已通过管理接口触发更新,不用重启服务" return 0 fi if ctrl_api POST /upgrade/geo '{"path":"","payload":""}' >/dev/null 2>&1; then ok "已通过管理接口触发更新,不用重启服务" return 0 fi warn "管理接口没响应(没开启,或者服务没在运行),改用删文件 + 重启的办法" confirm_default_yes " 删掉现有的 GEO 文件和 .mrs 规则集,然后重启服务重新下载?" || { info "已取消"; return 0; } rm -f "$CONF_DIR"/geoip.dat "$CONF_DIR"/geosite.dat "$CONF_DIR"/geoip.metadb \ "$CONF_DIR"/country.mmdb "$CONF_DIR"/ASN.mmdb 2>/dev/null || true rm -f "$CONF_DIR"/rules/*.mrs 2>/dev/null || true ok "已删除,内核下次启动会重新下载" if [[ "$(svc_state)" == "running" ]]; then systemctl restart "$SERVICE" && ok "$SERVICE 已重启" || warn "重启失败,看日志: journalctl -u $SERVICE -n 50" fi } geo_toggle_autoupdate() { cfg_require || return 1 local cur; cur="$(cfg_get 'geo-auto-update')" local bak; bak="$(cfg_backup)" if [[ "$cur" == "true" ]]; then cfg_set 'geo-auto-update' 'false' ok "已关闭自动更新,GEO 数据库只在缺文件时下载一次" else cfg_set 'geo-auto-update' 'true' [[ -n "$(cfg_get 'geo-update-interval')" ]] || cfg_set 'geo-update-interval' '24' ok "已打开自动更新,每 $(cfg_get 'geo-update-interval') 小时刷新一次" fi cfg_apply "$bak" } geo_menu() { while true; do log "" log "${W} GEO / 规则集${N} ${D}— GEOIP、GEOSITE、RULE-SET 规则靠这些数据文件匹配${N}" geo_show cat >&2 <%s ' "$W" "$N")" c echo >&2 case "$c" in 1) geo_refresh || true; pause ;; 2) geo_toggle_autoupdate || true; pause ;; 0|q|"") return 0 ;; *) warn "没有「$c」这个选项,输入上面列出的数字,或 0 返回"; sleep 1 ;; esac done } # ───────────────────────── 一键安装 ───────────────────────── do_install() { (( DRY_RUN )) || require_root "安装 mihomo" local target cur target="${WANT_VERSION:-$(latest_version 2>/dev/null || true)}" cur="$(current_version)" if [[ -z "$target" ]]; then warn "查不到最新版本号,GitHub 大概不通" warn "可以加 --mirror <反代前缀> 换下载源,或用 --version v1.19.28 指定版本再试" return 1 fi log "" log "${W} 一键安装${N} ${D}— 依次做五件事:安装内核、准备配置、写入 systemd 服务、安装快捷命令、启动${N}" log " ${D}内核 ${N}$BIN" log " ${D}配置 ${N}$(conf_file)" log " ${D}服务 ${N}${UNIT_FILE}" log " ${D}版本 ${N}${cur:-未安装} → ${target}" log "" if (( DRY_RUN )); then ok "[dry-run] 会安装 $target、写入 $UNIT_FILE 和快捷命令 $LAUNCHER、并在缺配置时启动向导"; return 0 fi # 1. 内核 if [[ "$cur" == "$target" && $FORCE -eq 0 ]]; then info "内核已是 $cur,跳过下载" else stage_binary "$target" [[ "$(svc_state)" == "running" ]] && { info "先停掉正在运行的 $SERVICE"; systemctl stop "$SERVICE" || true; } if [[ -x "$BIN" && -n "$cur" ]]; then mkdir -p "$BACKUP_DIR"; cp -a "$BIN" "$BACKUP_DIR/mihomo-$cur" info "旧内核已备份 → $BACKUP_DIR/mihomo-$cur" fi install -m 0755 "$STAGED" "$BIN" ok "内核已安装: $(version_line "$BIN")" fi # 2. 配置 mkdir -p "$CONF_DIR" local has_conf=0 if [[ -f "$(conf_file)" ]]; then has_conf=1 ok "已有配置 $(conf_file),保持不动" confirm " 要用向导重新生成一份吗?" && { cfg_wizard || true; } else log "" warn "还没有配置文件,没有它 mihomo 启动不了" if confirm_default_yes " 现在用向导生成一份?"; then cfg_wizard && has_conf=1 || has_conf=0 else info "跳过。自己把 config.yaml 放到 $CONF_DIR/ 之后,再执行: systemctl start $SERVICE" fi fi # 3. systemd install_unit # 4. 快捷命令 if [[ -x "$LAUNCHER" ]]; then install_launcher # 已经有了就顺手刷新成当前版本 elif confirm_default_yes " 安装一个 $(basename "$LAUNCHER") 快捷命令,省得每次记 curl 地址?"; then install_launcher else info "跳过。下次还用那条 bash <(curl ...) 进菜单" fi # 5. 启动 if (( NO_RESTART )); then warn "--no-restart: 没有启动服务。手动启动: systemctl start $SERVICE" elif (( has_conf )); then start_service || true else has_systemd && systemctl enable "$SERVICE" >/dev/null 2>&1 && info "已设为开机自启,但缺配置所以没启动" fi print_summary } print_summary() { local port ctrl secret ui port="$(cfg_get 'mixed-port')"; ctrl="$(cfg_get 'external-controller')" secret="$(cfg_get 'secret')"; ui="$(cfg_get 'external-ui')" log "" log "${G} ── 安装完成 ─────────────────────────────${N}" [[ -n "$port" ]] && { log " 本机代理 ${W}http://127.0.0.1:${port}${N} ${D}(HTTP 和 SOCKS5 同一个端口)${N}" log " 临时用起来 ${D}export https_proxy=http://127.0.0.1:${port} http_proxy=http://127.0.0.1:${port}${N}" } if [[ -n "$ctrl" && "$ctrl" != '""' ]]; then local host="${ctrl%%:*}" cport="${ctrl##*:}" [[ "$host" == "0.0.0.0" ]] && host="<本机IP>" if [[ -n "$ui" ]]; then log " Web 面板 ${W}http://${host}:${cport}/ui${N} ${D}密钥 ${secret}${N}" else log " 管理接口 ${W}http://${host}:${cport}${N} ${D}密钥 ${secret}${N}" fi fi log " 服务状态 $(systemctl is-active "$SERVICE" 2>/dev/null || echo '未运行') ${D}开机自启: $(svc_enabled)${N}" log " 看日志 ${D}journalctl -u $SERVICE -f${N}" [[ -x "$LAUNCHER" ]] && \ log " 再进菜单 ${W}$(basename "$LAUNCHER")${N} ${D}(每次自动拉取最新脚本,拉取失败就用本地缓存)${N}" log "${G} ───────────────────────────────────────${N}" } # ───────────────────────── 卸载 ───────────────────────── do_uninstall() { (( DRY_RUN )) || require_root "卸载 mihomo" log "" log "${W} 卸载${N} ${D}— 下面这些会被删掉:${N}" [[ -x "$BIN" ]] && log " 内核 $BIN" || log " ${D}内核 $BIN(不存在)${N}" [[ -f "$UNIT_FILE" ]] && log " 服务 $UNIT_FILE" || log " ${D}服务 $UNIT_FILE(不存在)${N}" [[ -x "$LAUNCHER" ]] && log " 快捷命令 $LAUNCHER 及缓存 $CACHE_DIR" log " ${D}配置目录 $CONF_DIR 和备份目录 $BACKUP_DIR 会单独问你${N}" log "" if (( DRY_RUN )); then ok "[dry-run] 到此为止,什么都没删"; return 0; fi (( PURGE )) || confirm " 确认卸载?" || { info "已取消"; return 0; } if has_systemd; then systemctl stop "$SERVICE" 2>/dev/null || true systemctl disable "$SERVICE" 2>/dev/null || true if [[ -f "$UNIT_FILE" ]]; then rm -f "$UNIT_FILE"; ok "已删除 $UNIT_FILE"; fi rm -f "${UNIT_FILE}".bak-* 2>/dev/null || true # drop-in 覆盖目录,别人加过 override.conf 的话留着会变孤儿 [[ -d "/etc/systemd/system/${SERVICE}.service.d" ]] && rm -rf "/etc/systemd/system/${SERVICE}.service.d" systemctl daemon-reload systemctl reset-failed "$SERVICE" 2>/dev/null || true ok "systemd 已清理" fi if [[ -e "$BIN" ]]; then rm -f "$BIN"; ok "已删除 $BIN"; fi # 快捷命令和它的缓存。注意: 你很可能正是从缓存里那份脚本启动起来的,删掉没关系 —— # Linux 上文件被删除后 inode 还在,bash 手里的 fd 仍然有效,这一趟能运行完。 [[ -e "$LAUNCHER" ]] && { rm -f "$LAUNCHER"; ok "已删除快捷命令 $LAUNCHER"; } [[ -d "$CACHE_DIR" ]] && { rm -rf "$CACHE_DIR"; ok "已删除脚本缓存 $CACHE_DIR"; } if [[ -d "$CONF_DIR" ]]; then if (( PURGE )) || confirm " 连配置目录 $CONF_DIR 一起删除?(订阅、节点选择、面板文件都在里面)"; then rm -rf "$CONF_DIR"; ok "已删除 $CONF_DIR" else info "配置保留在 $CONF_DIR,下次重新安装能直接接上" fi fi if [[ -d "$BACKUP_DIR" ]]; then if (( PURGE )) || confirm " 连内核备份 $BACKUP_DIR 一起删除?"; then rm -rf "$BACKUP_DIR"; ok "已删除 $BACKUP_DIR" else info "内核备份保留在 $BACKUP_DIR" fi fi ok "卸载完成" } # ───────────────────────── 菜单 ───────────────────────── banner() { printf '%s\n' "" >&2 cat >&2 </dev/null 2>&1 && conf="${G}有效${N}" || conf="${R}有错${N}" elif [[ -f "$(conf_file)" ]]; then conf="${D}有文件, 未校验${N}" else conf="${Y}未配置${N}" fi local cur_s="${cur:-${Y}未安装${N}}" latest_s="$latest" [[ -n "$cur" && "$cur" != "$latest" && "$latest" != "查询失败" ]] && latest_s="${Y}${latest} ← 有新版${N}" [[ -n "$cur" && "$cur" == "$latest" ]] && latest_s="${G}${latest}${N}" case "$svc" in running) svc="${G}● 运行中${N}" ;; stopped) svc="${Y}○ 已停止${N}" ;; 未安装) svc="${D}未安装服务${N}" ;; *) svc="${D}${svc}${N}" ;; esac [[ "$enabled" == "enabled" ]] && svc="$svc ${D}(开机自启)${N}" printf ' %s当前版本%s %s\n' "$D" "$N" "$cur_s" >&2 printf ' %s最新版本%s %s\n' "$D" "$N" "$latest_s" >&2 printf ' %s服务状态%s %s\n' "$D" "$N" "$svc" >&2 printf ' %s配置文件%s %s\n' "$D" "$N" "$conf" >&2 printf ' %s架构 %s %s\n' "$D" "$N" "$(detect_arch)" >&2 } menu() { local warned_root=0 while true; do clear 2>/dev/null || true banner status_panel if [[ ${EUID:-$(id -u)} -ne 0 && $warned_root -eq 0 ]]; then warned_root=1 log "" warn "现在不是 root。查看类操作没问题,但安装/升级/修改配置/卸载都会被拒绝,建议 sudo 重新运行本脚本" fi local first="🚀 一键安装 ${D}(内核 + systemd + 配置)${N}" [[ -x "$BIN" ]] && first="🚀 重新安装 ${D}(补齐 systemd / 配置)${N}" cat >&2 <%s ' "$W" "$N")" c echo >&2 case "$c" in 1) do_install || warn "安装没走完,看上面的报错"; pause ;; 2) local cur latest; cur="$(current_version)"; latest="$(latest_version 2>/dev/null || true)" if [[ -z "$latest" ]]; then warn "查不到最新版本号,GitHub 大概不通;可以退出后加 --mirror <反代前缀> 重新运行" elif [[ -z "$cur" ]]; then warn "内核还没安装,选 1 一键安装" elif [[ "$cur" == "$latest" ]]; then ok "已是最新 $cur" else warn "有新版: $cur → $latest" confirm " 现在升级?" && { do_update "$latest" || warn "升级未完成"; }; fi pause ;; 3) local latest; latest="$(latest_version 2>/dev/null || true)" if [[ -z "$latest" ]]; then warn "查不到最新版本号,GitHub 大概不通;可以退出后加 --mirror <反代前缀> 重新运行" else confirm " 升级/重安装内核到 $latest ?" && { FORCE=1; do_update "$latest" || warn "升级未完成"; FORCE=0; }; fi pause ;; 4) local v; ask " 输入版本号 (如 v1.19.28): " v if [[ "$v" =~ ^v[0-9] ]]; then FORCE=1; do_update "$v" || warn "安装未完成"; FORCE=0 else warn "版本号要以 v 开头,比如 v1.19.28"; fi pause ;; 5) confirm " 确认回滚到上一个备份?" && { do_rollback || true; }; pause ;; 6) echo >&2; do_list; pause ;; 7) conf_menu ;; 8) svc_menu ;; 9) do_uninstall || true; pause ;; 0|q|"") log " ${D}bye~${N}"; exit 0 ;; *) warn "没有「$c」这个选项,输入上面列出的数字,或 0 退出/返回"; sleep 1 ;; esac done } conf_menu() { while true; do local port lan ctrl nsub nlis port="$(cfg_get 'mixed-port' || true)" lan="$(cfg_get 'allow-lan' || true)"; ctrl="$(cfg_get 'external-controller' || true)" nsub="$(cfg_sub_list 2>/dev/null | grep -c . || true)" nlis="$(cfg_listener_list 2>/dev/null | grep -c . || true)" cat >&2 <%s ' "$W" "$N")" c echo >&2 case "$c" in 1) cfg_wizard || true; pause ;; 2) cfg_sub_menu || true; pause ;; 3) cfg_change_port || true; pause ;; 4) cfg_toggle_lan || true; pause ;; 5) cfg_change_ctrl || true; pause ;; 6) cfg_listener_menu ;; 7) geo_menu ;; 8) cfg_import_url || true; pause ;; 9) cfg_validate || true; pause ;; 10) cfg_edit || true; pause ;; 11) cfg_backup_menu || true; pause ;; 0|q|"") return 0 ;; *) warn "没有「$c」这个选项,输入上面列出的数字,或 0 退出/返回"; sleep 1 ;; esac done } svc_menu() { cat >&2 <%s ' "$W" "$N")" c case "$c" in 1) systemctl start "$SERVICE" && ok "已启动" || warn "启动失败,看日志: journalctl -u $SERVICE -n 50" ;; 2) systemctl stop "$SERVICE" && ok "已停止" ;; 3) systemctl restart "$SERVICE" && ok "已重启" || warn "重启失败,看日志: journalctl -u $SERVICE -n 50" ;; 4) systemctl status "$SERVICE" --no-pager || true ;; 5) journalctl -u "$SERVICE" -n 40 --no-pager || true ;; 6) if [[ "$(svc_enabled)" == "enabled" ]]; then systemctl disable "$SERVICE" && ok "已关闭开机自启" else systemctl enable "$SERVICE" && ok "已设为开机自启" fi ;; 7) install_unit && ok "服务文件已刷新;生效需要重启服务" || true ;; 8) install_launcher || true ;; *) return 0 ;; esac pause } # ───────────────────────── 参数解析 ───────────────────────── CMD="" if [[ $# -eq 0 ]]; then # stdin 是终端就进菜单;被重定向/管道(cron、CI)就安静地运行 check if [[ -t 0 ]] || { [[ -t 1 ]] && [[ -r /dev/tty ]]; }; then CMD="menu"; else CMD="check"; fi else case "$1" in check|update|install|uninstall|config|service|shortcut|rollback|list|menu) CMD="$1"; shift ;; -h|--help|help) usage 0 ;; -v|--version-self) echo "mihomo-up.sh v${SELF_VERSION}"; exit 0 ;; -*) CMD="check" ;; *) echo "未知子命令: $1" >&2; usage 1 ;; esac fi while (( $# )); do case "$1" in --force) FORCE=1 ;; -y|--yes) ASSUME_YES=1 ;; -n|--dry-run) DRY_RUN=1 ;; --no-restart) NO_RESTART=1 ;; --purge) PURGE=1 ;; --skip-conf-check) SKIP_CONF_CHECK=1 ;; --json) JSON=1 ;; --version) WANT_VERSION="${2:?--version 后面要跟版本号}"; shift ;; --arch) ARCH_OVERRIDE="${2:?--arch 后面要跟架构}"; shift ;; --variant) VARIANT="${2:?--variant 后面要跟 v1/v2/v3/compatible}"; shift ;; --mirror) MIRROR="${2:?--mirror 后面要跟下载前缀}"; shift ;; --script-url) SCRIPT_URL="${2:?--script-url 后面要跟脚本地址}"; shift ;; --bin) BIN="${2:?--bin 后面要跟路径}"; shift ;; --conf-dir) CONF_DIR="${2:?--conf-dir 后面要跟目录}"; shift ;; --service) SERVICE="${2:?--service 后面要跟服务名}"; UNIT_FILE="/etc/systemd/system/${SERVICE}.service"; shift ;; -h|--help) usage 0 ;; *) die "未知参数: $1(用 --help 看全部选项)" ;; esac shift done need curl; need gzip; need install # ───────────────────────── 分发 ───────────────────────── # 这些动作失败时自己已经打印过原因了,用 || exit 1 收尾: # 直接让它们返回非零会撞上 ERR trap,在人话提示后面再补一句「第 N 行出错」, # 看起来像脚本崩了,其实只是"没做成"。 case "$CMD" in menu) menu ;; install) do_install || exit 1 ;; uninstall) do_uninstall || exit 1 ;; config) cfg_wizard || exit 1 ;; service) install_unit || exit 1 ;; shortcut) install_launcher || exit 1 ;; list) do_list ;; rollback) do_rollback || exit 1 ;; check) CUR="$(current_version)"; TARGET="${WANT_VERSION:-$(latest_version)}" (( JSON )) && printf '{"current":"%s","latest":"%s","outdated":%s}\n' \ "${CUR:-null}" "$TARGET" "$([[ "$CUR" != "$TARGET" ]] && echo true || echo false)" log "当前: ${CUR:-${Y}未安装${N}} 最新: $TARGET" [[ -z "$CUR" ]] && { log "→ 还没安装,运行 install 一键安装"; exit 10; } [[ "$CUR" == "$TARGET" ]] && { ok "已是最新"; exit 0; } warn "有新版本可升级"; exit 10 ;; update) TARGET="${WANT_VERSION:-$(latest_version)}" CUR="$(current_version)" log "当前: ${CUR:-${Y}未安装${N}} 目标: $TARGET" do_update "$TARGET" || exit 1 ;; esac