commit 8e4505391de2c5754e6d4c3330bf6ed1c0cdcf3c Author: Jz0ojiang Date: Sun Aug 9 12:51:57 2026 +0800 Initial commit: mihomo 内核管理脚本 bash 脚本,用于在 Linux 服务器上检查、安装、升级、回滚 mihomo 内核, 支持交互菜单与命令行双模式,带安全验证、自动备份与回滚。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..dad7644 --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# mihomo-up + +一个 bash 脚本,用来在 Linux 服务器上**检查、安装、升级、回滚 [mihomo](https://github.com/MetaCubeX/mihomo) 内核**(Clash.Meta 的继任者,MetaCubeX 维护的代理核心)。可以直接跑交互菜单,也可以用子命令接进 cron / CI。 + +脚本只管内核二进制本身:它**不会**替你创建 systemd 服务、生成配置文件或设置订阅,这些需要你自己先准备好。 + +## 特性 + +- **双模式**:终端下无参数运行进入交互菜单;被管道/cron 调用时自动退化为安静的 `check` +- **两道安全闸门**:替换前先验证新二进制能运行且版本号对得上,再用新二进制 `-t` 校验你现有的配置,任一失败就中止、不动任何文件 +- **自动备份与回滚**:替换前把旧版备份到 `/var/backups/mihomo/`(保留最近 3 份);升级后服务起不来会自动回滚并重启旧版 +- **curl 一键运行友好**:交互输入读 `/dev/tty`,`bash <(curl ...)` 管道方式运行菜单照样能按键 +- 支持 amd64 / arm64 / armv7,amd64 可指定微架构变体(v1/v2/v3/compatible) + +## 环境要求 + +- Linux + bash +- 依赖命令:`curl`、`gzip`、`install`、`awk`(一般发行版自带) +- 升级操作需要 **root**(要写 `/usr/local/bin` 并重启服务) +- 服务的启停依赖 systemd 中名为 `mihomo` 的 service;没有的话脚本只替换二进制、跳过重启 + +## 快速开始 + +**交互菜单**(把地址换成你自己托管的脚本地址): + +```bash +bash <(curl -fsSL https://你的地址/mihomo-up.sh) +``` + +**命令行**: + +```bash +chmod +x mihomo-up.sh + +./mihomo-up.sh check # 只查不改:当前版本 vs 最新版本 +sudo ./mihomo-up.sh update # 升级到最新版 +sudo ./mihomo-up.sh update --version v1.19.28 # 装指定版本 +sudo ./mihomo-up.sh rollback # 回滚到上一个备份 +./mihomo-up.sh list # 查看本地备份 +``` + +菜单里另有:校验配置文件、服务控制(启停/状态/最近日志)等入口。 + +## 在 cron / CI 里用 + +`check` 的退出码专为脚本化设计: + +| 退出码 | 含义 | +|---|---| +| 0 | 已是最新 | +| 10 | 有新版(或未安装) | +| 1 | 出错 | + +例子——有新版时自动升级: + +```bash +mihomo-up.sh check && exit 0 +[ $? -eq 10 ] && mihomo-up.sh update -y +``` + +加 `--json` 会额外输出一行机器可读的 JSON,方便接入监控: + +```json +{"current":"v1.19.27","latest":"v1.19.28","outdated":true} +``` + +## 选项一览 + +| 选项 | 作用 | +|---|---| +| `-y, --yes` | 非交互确认,全部默认同意 | +| `-n, --dry-run` | 只打印会做什么,不实际执行 | +| `--force` | 即使版本相同也重装(`install` 子命令等价于 `update --force`) | +| `--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` | + +## 默认值与环境变量 + +| 默认值 | 环境变量 | +|---|---| +| `/usr/local/bin/mihomo` | `MIHOMO_BIN` | +| `/etc/mihomo` | `MIHOMO_CONF_DIR` | +| `mihomo`(systemd 服务名) | `MIHOMO_SERVICE` | +| `/var/backups/mihomo` | `MIHOMO_BACKUP_DIR` | + +备份最多保留 3 份(脚本内常量 `KEEP=3`)。 + +## 升级时发生了什么 + +1. 从 GitHub Releases 下载对应架构的 `.gz` 包并解压 +2. **闸门 1**:新二进制必须能执行,且 `-v` 报告的版本号与目标一致 +3. **闸门 2**:若存在 `config.yaml`,用新二进制 `-t` 校验现有配置,失败即中止 +4. 备份旧二进制 → 停服务 → 安装新版 → 启动服务 +5. 启动失败则**自动回滚**到刚才的备份并重启,最后提示你查 `journalctl -u mihomo -n 50` + +任何一步失败都不会留下半个新系统:要么没动,要么已回滚。 diff --git a/mihomo-up.sh b/mihomo-up.sh new file mode 100644 index 0000000..18b14a3 --- /dev/null +++ b/mihomo-up.sh @@ -0,0 +1,388 @@ +#!/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 \ No newline at end of file