#!/usr/bin/env bash # # mihomo-up.sh — mihomo 内核管理脚本 (交互菜单 + 命令行双模式) # # bash <(curl -fsSL https://你的地址/mihomo-up.sh) # 交互菜单 # ./mihomo-up.sh check # 非交互, 给 cron/CI 用 # set -Eeuo pipefail REPO="MetaCubeX/mihomo" 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}" KEEP=3 ARCH_OVERRIDE=""; VARIANT=""; WANT_VERSION="" FORCE=0; DRY_RUN=0; NO_RESTART=0; JSON=0; ASSUME_YES=0 TMPDIR_="" # ───────────────────────── 输出 ───────────────────────── 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 check 只查, 不改任何东西 mihomo-up.sh update [--force] 升级内核 mihomo-up.sh rollback 回滚到上一个备份 mihomo-up.sh list 列出本地备份 选项: -y, --yes 非交互确认, 全部默认同意 -n, --dry-run 只打印会做什么 --version 指定版本, 如 v1.19.28 --no-restart 装完不启动服务 --json check 时额外输出一行 JSON --arch amd64 | arm64 | armv7 --variant amd64 微架构: v1 | v2 | v3 | compatible --bin 默认 /usr/local/bin/mihomo --conf-dir 默认 /etc/mihomo check 退出码: 0 = 已是最新 10 = 有新版 1 = 出错 EOF exit "${1:-0}" } # ───────────────────────── 交互输入 ───────────────────────── # 关键: 从 /dev/tty 读, 这样 bash <(curl ...) 和管道场景都不会把 stdin 抢走 TTY_IN="/dev/stdin" [[ -r /dev/tty ]] && TTY_IN="/dev/tty" ask() { # ask "提示" varname local prompt="$1" __var="$2" __ans="" printf '%s' "$prompt" >&2 read -r __ans < "$TTY_IN" || __ans="" printf -v "$__var" '%s' "$__ans" } confirm() { # confirm "问题?" -> 0/1 (( ASSUME_YES )) && return 0 local a; ask "$1 ${D}[y/N]${N} " a [[ "$a" =~ ^[Yy]$ ]] } pause() { local _x; ask "$(printf '\n%s按回车继续…%s' "$D" "$N")" _x; } # ───────────────────────── 基础能力 ───────────────────────── need() { command -v "$1" >/dev/null 2>&1 || die "缺少命令: $1"; } 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 指定)" ;; 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 \ "https://github.com/${REPO}/releases/latest/download/version.txt" | tr -d '[:space:]')" \ || die "拉取版本号失败 (GitHub 不通?)" [[ "$v" =~ ^v[0-9]+\.[0-9]+ ]] || die "版本号格式异常: $v" echo "$v" } svc_state() { command -v systemctl >/dev/null 2>&1 || { echo "无 systemd"; return; } if systemctl is-active --quiet "$SERVICE" 2>/dev/null; then echo "running" elif systemctl list-unit-files 2>/dev/null | grep -q "^${SERVICE}\.service"; then echo "stopped" else echo "未安装"; fi } # ───────────────────────── 动作 ───────────────────────── do_update() { # $1 = 目标版本 local target="$1" cur; cur="$(current_version)" if [[ "$cur" == "$target" && $FORCE -eq 0 ]]; then ok "已是最新 ($cur), 无需操作"; return 0 fi [[ ${EUID:-$(id -u)} -eq 0 ]] || die "需要 root (要写 $BIN 并重启服务)" TMPDIR_="$(mktemp -d)" local asset url; asset="$(asset_name "$target")" url="https://github.com/${REPO}/releases/download/${target}/${asset}" info "下载 $asset" (( DRY_RUN )) && { ok "[dry-run] 会下载 $url"; return 0; } curl -fL -# --retry 3 --connect-timeout 15 -o "$TMPDIR_/m.gz" "$url" || die "下载失败: $url" gzip -dc "$TMPDIR_/m.gz" > "$TMPDIR_/mihomo" || die "解压失败" chmod +x "$TMPDIR_/mihomo" # 闸门 1: 新二进制能跑, 版本号对得上 local newline newver newline="$(version_line "$TMPDIR_/mihomo")" [[ -n "$newline" ]] || die "新二进制无法执行 (架构选错?)" newver="$(awk '{print $3}' <<<"$newline")" [[ "$newver" == "$target" ]] || die "版本对不上: 期望 $target, 实际 $newver" ok "校验通过: $newline" # 闸门 2: 用新二进制验现有配置 if [[ -f "$CONF_DIR/config.yaml" ]]; then if "$TMPDIR_/mihomo" -t -d "$CONF_DIR" >"$TMPDIR_/test.log" 2>&1; then ok "配置在新版本下校验通过" else warn "新版本校验现有配置失败:"; sed 's/^/ /' "$TMPDIR_/test.log" >&2 die "已中止, 未替换任何文件" 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 "$TMPDIR_/mihomo" "$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 启动失败, 自动回滚" [[ -f "$BACKUP_DIR/mihomo-$cur" ]] && { install -m 0755 "$BACKUP_DIR/mihomo-$cur" "$BIN" systemctl start "$SERVICE" || true warn "已回滚到 $cur; 查日志: journalctl -u $SERVICE -n 50" } 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 | head -n1 || true)" [[ -n "$b" ]] || { warn "没有可回滚的备份"; return 1; } 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 } do_test_conf() { [[ -x "$BIN" ]] || { warn "mihomo 还没装"; return 1; } [[ -f "$CONF_DIR/config.yaml" ]] || { warn "找不到 $CONF_DIR/config.yaml"; return 1; } if "$BIN" -t -d "$CONF_DIR"; then ok "配置有效"; else warn "配置有问题, 见上面报错"; fi } # ───────────────────────── 菜单 ───────────────────────── banner() { printf '%s\n' "" >&2 cat >&2 </dev/null 2>&1 && conf="${G}有效${N}" || conf="${R}有错${N}" else conf="${D}无配置${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}${svc}${N}" ;; esac 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() { while true; do clear 2>/dev/null || true banner status_panel cat >&2 <%s ' "$W" "$N")" c echo >&2 case "$c" in 1) local cur latest; cur="$(current_version)"; latest="$(latest_version)" if [[ -z "$cur" ]]; then warn "还没安装, 选 2 装上" elif [[ "$cur" == "$latest" ]]; then ok "已是最新 $cur" else warn "有新版: $cur → $latest" confirm " 现在升级?" && do_update "$latest"; fi pause ;; 2) local latest; latest="$(latest_version)" confirm " 升级/重装到 $latest ?" && { FORCE=1; do_update "$latest"; FORCE=0; } pause ;; 3) local v; ask " 输入版本号 (如 v1.19.28): " v [[ "$v" =~ ^v[0-9] ]] && { FORCE=1; do_update "$v"; FORCE=0; } || warn "版本号格式不对" pause ;; 4) confirm " 确认回滚?" && do_rollback; pause ;; 5) echo >&2; do_list; pause ;; 6) do_test_conf || true; pause ;; 7) svc_menu ;; 0|q|"") log " ${D}bye~${N}"; exit 0 ;; *) warn "没这个选项啦"; sleep 1 ;; esac done } svc_menu() { cat >&2 <%s ' "$W" "$N")" c case "$c" in 1) systemctl start "$SERVICE" && ok "已启动" ;; 2) systemctl stop "$SERVICE" && ok "已停止" ;; 3) systemctl restart "$SERVICE" && ok "已重启" ;; 4) systemctl status "$SERVICE" --no-pager || true ;; 5) journalctl -u "$SERVICE" -n 40 --no-pager || 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|rollback|list|menu) CMD="$1"; shift ;; -h|--help|help) usage 0 ;; -*) CMD="check" ;; *) echo "未知子命令: $1" >&2; usage 1 ;; esac fi [[ "$CMD" == install ]] && { CMD="update"; FORCE=1; } while (( $# )); do case "$1" in --force) FORCE=1 ;; -y|--yes) ASSUME_YES=1 ;; -n|--dry-run) DRY_RUN=1 ;; --no-restart) NO_RESTART=1 ;; --json) JSON=1 ;; --version) WANT_VERSION="${2:?}"; shift ;; --arch) ARCH_OVERRIDE="${2:?}"; shift ;; --variant) VARIANT="${2:?}"; shift ;; --bin) BIN="${2:?}"; shift ;; --conf-dir) CONF_DIR="${2:?}"; shift ;; -h|--help) usage 0 ;; *) die "未知参数: $1" ;; esac shift done need curl; need gzip; need install # ───────────────────────── 分发 ───────────────────────── case "$CMD" in menu) menu ;; list) do_list ;; rollback) do_rollback ;; 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 "→ 未安装"; 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" ;; esac