#!/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 substore 安装 / 更新 Sub-Store(订阅聚合,可选) 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 # --max-time 是必须的: --connect-timeout 只管建连,连上之后被中间设备挂住会无限等, # 而这个脚本管的是代理,用它的时候网络本来就大概率不正常 v="$(curl -fsSL --retry 2 --connect-timeout 5 --max-time 15 \ "$(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" # 不用 `list-unit-files | grep -q`: grep 命中即退出,上游收到 SIGPIPE 返回 141, # 配上 pipefail 会让这个判断在"确实装了"的时候反而为假 elif [[ -f "$UNIT_FILE" ]] || systemctl cat "$SERVICE" >/dev/null 2>&1; then echo "stopped" else echo "未安装"; fi } svc_enabled() { # 是否开机自启 has_systemd || { echo "-"; return; } # is-enabled 对 disabled 单元会往 stdout 打印 "disabled" 并返回 1, # 直接 `|| echo disabled` 会输出两行,把安装总结的排版顶乱 local s; s="$(systemctl is-enabled "$SERVICE" 2>/dev/null || true)" printf '%s' "${s:-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 (( DRY_RUN )) || 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" || warn "停止失败,仍尝试替换"; } # 这一步失败过去会被吞掉,然后拿旧二进制启动、还打印"升级完成" if ! install -m 0755 "$STAGED" "$BIN"; then warn "写入 $BIN 失败(只读挂载 / 磁盘满 / 文件被占用),内核没有变动" (( was_active )) && { systemctl start "$SERVICE" || true; } trap 'die "第 $LINENO 行出错,已中止"' ERR return 1 fi 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; } (( DRY_RUN )) || 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() { (( DRY_RUN )) || 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 失败时必须返回失败: 以前这里无论如何都返回 0 并吐出一个不存在的路径, # 后面的"改坏了就还原"整条链路就静默失效了 cp -a "$f" "$bak" || { warn "备份失败,写不了 $bak(不是 root?)"; return 1; } printf '%s' "$bak" # 备份不自动清理会越攒越多,只留最近 10 份 ls -1t "${f}".bak-* 2>/dev/null | tail -n +11 | xargs -r rm -f 2>/dev/null || true } 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 tmp; f="$(conf_file)" [[ -f "$f" ]] || { warn "找不到 $f,先生成配置"; return 1; } # 用 awk 整行重建,不用 sed 的 s///。sed 的替换文本里 & 代表整条匹配、 # \ 是转义引导符,值里带这两个字符时会把旧行塞进新值,把配置写坏。 tmp="$(mktemp)" # 值走环境变量而不是 -v: awk 会解释 -v 赋值里的转义序列,值里的 \ 会被吃掉 CFG_KEY="$key" CFG_VAL="$val" awk ' !done && index($0, ENVIRON["CFG_KEY"] ":") == 1 { print ENVIRON["CFG_KEY"] ": " ENVIRON["CFG_VAL"]; done = 1; next } { print } END { if (!done) print ENVIRON["CFG_KEY"] ": " ENVIRON["CFG_VAL"] } ' "$f" > "$tmp" || { rm -f "$tmp"; warn "改写 $f 失败"; return 1; } [[ -s "$tmp" ]] || { rm -f "$tmp"; warn "改写后的配置是空的,已放弃"; return 1; } cat "$tmp" > "$f" || { rm -f "$tmp"; warn "写入 $f 失败(不是 root?)"; return 1; } rm -f "$tmp" } 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 已重启" \ || warn "重启失败,配置写进去了但没生效: journalctl -u $SERVICE -n 50" } fi return 0 fi if [[ -n "$bak" ]]; then confirm_default_yes " 配置校验没过,还原到改动前?" && cfg_restore "$bak" fi return 1 } 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_wizard() { # 生成一份全新的 config.yaml require_root "写入 $(conf_file)" local f; f="$(conf_file)" mkdir -p "$CONF_DIR" log "" log "${W} 配置向导${N} ${D}— 回车用方括号里的默认值${N}" log "${D} 生成一份最小可用配置:一个订阅、PROXY/AUTO 两个分组、内网直连。${N}" log "${D} 订阅的聚合、改名、筛选交给 Sub-Store 之类的工具做,这里只认一条最终地址。${N}" log "" local sub port lan ctrl_mode ctrl secret ui rules_mode local old_sub old_port old_ctrl old_secret old_sub="$(cfg_sub_url || true)"; old_port="$(cfg_get 'mixed-port' || true)" old_ctrl="$(cfg_get 'external-controller' || true)"; old_secret="$(cfg_get 'secret' || true)" local tries=0 while :; do ask_default " 订阅地址(机场给的,或 Sub-Store 输出的那条)" "${old_sub:-}" sub if [[ -z "$sub" ]]; then (( ++tries >= 3 )) && { warn " 一直没拿到订阅地址,向导先退出了,配置没有改动"; return 1; } warn " 至少要有一条订阅,一个节点都没有 mihomo 启动不了" continue fi if [[ "$sub" == *'"'* || "$sub" == *'\'* ]]; then warn " 地址里不能有引号或反斜杠,那会破坏 YAML 解析。请换一条" continue fi break done ask_default " 本地代理端口(HTTP 和 SOCKS5 共用)" "${old_port:-7890}" port if [[ ! "$port" =~ ^[0-9]+$ ]]; then warn " 端口要是数字,收到的是「$port」。向导先退出了,配置没有改动"; return 1 fi 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 local cport case "$ctrl_mode" in 2) ask_default " 管理接口端口" "9090" cport; ctrl="0.0.0.0:${cport}" ;; 3) ctrl="" ;; *) ask_default " 管理接口端口" "9090" cport; ctrl="127.0.0.1:${cport}" ;; esac if [[ -n "$ctrl" && ! "$cport" =~ ^[0-9]+$ ]]; then warn " 管理接口端口要是数字,收到的是「$cport」。向导先退出了,配置没有改动"; return 1 fi secret="" if [[ -n "$ctrl" ]]; then ask_default " 管理接口密钥(回车=自动生成一个)" "${old_secret:-$(gen_secret)}" secret if [[ "$secret" == *'"'* || "$secret" == *'\'* ]]; then warn " 密钥里不能有引号或反斜杠。向导先退出了,配置没有改动"; return 1 fi if confirm_default_yes " 顺带安装一个 Web 面板(metacubexd,内核启动时自动下载)?"; then ui="yes"; else ui="no"; fi else ui="no" fi 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" || { rm -f "$tmp"; warn "生成配置失败,原配置没有改动"; return 1; } [[ -s "$tmp" ]] || { rm -f "$tmp"; warn "生成的配置是空的,已放弃"; return 1; } local bak="" if [[ -f "$f" ]]; then confirm " $f 已存在,覆盖它?(旧的会先备份)" || { rm -f "$tmp"; warn " 已取消,配置没动"; return 1; } bak="$(cfg_backup)" || { rm -f "$tmp"; return 1; } [[ -n "$bak" ]] && info "旧配置已备份 → $bak" fi install -m 0640 "$tmp" "$f" || { rm -f "$tmp"; warn "写入 $f 失败"; return 1; } rm -f "$tmp" ok "配置已写入 $f ${D}(权限 0640,密钥在里面,不让其它用户读)${N}" cfg_apply "$bak" || warn "配置写进去了但校验没过,可以用「手动编辑配置」改,或重新运行一遍向导" } # 订阅行由本脚本写入,行尾带 "# mihomo-up:sub" 标记。认标记而不是猜 YAML 结构, # 所以手写配置里别处的 url: 不会被误伤。 # 用 awk 找「最后一个」标记位置来切,这样订阅地址里本身含 # 也不会被截断。 cfg_sub_url() { local f; f="$(conf_file)" [[ -f "$f" ]] || return 0 awk ' /url:/ && /#[ \t]*mihomo-up:sub/ { line = $0; s = line; off = 0; tagpos = 0 while ((p = index(s, "#")) > 0) { off += p if (substr(line, off) ~ /^#[ \t]*mihomo-up:sub/) tagpos = off s = substr(s, p + 1) } if (tagpos == 0) next u = substr(line, 1, tagpos - 1) sub(/^[ \t]*url:[ \t]*/, "", u) sub(/[ \t]+$/, "", u) gsub(/^"|"$/, "", u) print u exit } ' "$f" } cfg_change_port() { require_root "修改 $(conf_file)" cfg_require || return 1 local cur; cur="$(cfg_get 'mixed-port')" local new; ask_default " 本地代理端口" "${cur:-7890}" new [[ "$new" =~ ^[0-9]+$ ]] || { warn "端口要是数字,收到的是「$new」"; return 1; } [[ "$new" == "$cur" ]] && { info "没有改动"; return 0; } local bak; bak="$(cfg_backup)" || return 1 cfg_set 'mixed-port' "$new" || { cfg_restore "$bak"; return 1; } ok "代理端口 → $new" cfg_apply "$bak" } show_panel_info() { # 只读: 面板在哪、密钥是什么、怎么从本机之外打开 cfg_require || return 1 local ctrl secret ui host port ctrl="$(cfg_get 'external-controller')" secret="$(cfg_get 'secret')" ui="$(cfg_get 'external-ui')" log "" if [[ -z "$ctrl" || "$ctrl" == '""' ]]; then log " ${Y}管理接口没有开启${N},面板和 API 都连不上。" log " ${D}要开的话重新运行一次配置向导(菜单第 1 项)。${N}" return 0 fi host="${ctrl%:*}"; port="${ctrl##*:}" log " ${D}管理接口${N} $ctrl" log " ${D}访问密钥${N} ${W}${secret:-(没设,任何人都能连)}${N}" if [[ -n "$ui" ]]; then log " ${D}面板路径${N} /$ui" else log " ${D}面板 ${N} ${D}没装,配置里没有 external-ui${N}" fi log "" if [[ "$host" == "127.0.0.1" || "$host" == "localhost" ]]; then log " ${D}只绑了本机,所以要从你自己的电脑打开,先开一条 SSH 隧道:${N}" log " ${W}ssh -L ${port}:127.0.0.1:${port} 用户名@这台机器的地址${N}" log " ${D}然后本机浏览器访问:${N}" log " ${W}http://127.0.0.1:${port}${ui:+/$ui}${N}" else log " ${D}浏览器直接访问:${N}" log " ${W}http://这台机器的地址:${port}${ui:+/$ui}${N}" warn "绑的是 $host,任何能连到这台机器的人都能访问,请用防火墙只放行可信来源" fi log "" log " ${D}密钥就存在 $(conf_file) 的 secret: 那一行,忘了随时回来看。${N}" } cfg_edit() { require_root "修改 $(conf_file)" 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" } # ───────────────────────── Sub-Store ───────────────────────── # 订阅聚合器:把多个机场和自建节点合成一条订阅地址,带 Web UI 能看到结果节点列表。 # 它是独立服务,不进「一键安装」,也不是用 mihomo 的前提。 # # 安全模型要知道:它的后端 API 没有鉴权,URL 里那串随机路径就是唯一凭据。 # 所以默认只绑 127.0.0.1,要从外面看就开 SSH 隧道。 SS_DIR="${SUBSTORE_DIR:-/opt/sub-store}" SS_SERVICE="${SUBSTORE_SERVICE:-sub-store}" SS_UNIT="/etc/systemd/system/${SS_SERVICE}.service" SS_BACKEND_URL="sub-store-org/Sub-Store/releases/latest/download/sub-store.bundle.js" SS_FRONTEND_URL="sub-store-org/Sub-Store-Front-End/releases/latest/download/dist.zip" ss_installed() { [[ -f "$SS_DIR/sub-store.bundle.js" ]]; } ss_node() { # 找到 node,找不到就打印该发行版的安装命令 local n; n="$(command -v node 2>/dev/null || true)" if [[ -n "$n" ]]; then printf '%s' "$n"; return 0; fi warn "没找到 node。Sub-Store 是 Node 程序,得先装运行时" warn "脚本不替你装语言运行时 —— 装错版本或动了 apt 源,坏的是整台机器" log "" log " ${D}按你的发行版选一条执行,装完再回来:${N}" log " ${W}Debian / Ubuntu${N} curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt install -y nodejs" log " ${W}Alpine${N} sudo apk add nodejs" log " ${W}RHEL / Rocky${N} sudo dnf install -y nodejs" log " ${W}Arch${N} sudo pacman -S nodejs" log "" return 1 } ss_gen_path() { # 后端 API 路径,20 位随机;这串就是访问凭据 local p="" [[ -r /dev/urandom ]] && p="$(od -An -tx1 -N10 /dev/urandom 2>/dev/null | tr -d ' \n')" || p="" [[ -n "$p" ]] || p="substore$(date +%s)" printf '/%s' "$p" } ss_get_env() { # 从已装好的 unit 里读回某个环境变量的值 [[ -f "$SS_UNIT" ]] || return 0 sed -n -E "s/^Environment=\"$1=(.*)\"$/\1/p" "$SS_UNIT" | sed -n '1p' } ss_write_unit() { # ss_write_unit <后端路径> <前端端口> <绑定地址> local api_path="$1" port="$2" host="$3" node; node="$(command -v node)" cat > "$SS_UNIT" </dev/null \ || { warn "下载到的后端不是有效的 JS(被中间设备改了?没下完?),已放弃"; return 1; } info "下载前端 dist.zip" curl -fL -# --retry 3 --connect-timeout 15 --max-time 300 \ -o "$dir/dist.zip" "$(gh_url "$SS_FRONTEND_URL")" \ || { warn "前端下载失败"; return 1; } # 闸门 2: zip 完整性 unzip -t "$dir/dist.zip" >/dev/null 2>&1 \ || { warn "下载到的前端不是有效的 zip,已放弃"; return 1; } unzip -q -o "$dir/dist.zip" -d "$dir" || { warn "解压前端失败"; return 1; } [[ -d "$dir/dist" ]] || { warn "前端包里没有 dist 目录,结构和预期不符,已放弃"; return 1; } rm -f "$dir/dist.zip" ok "两个产物都校验通过" } ss_install() { require_root "安装 Sub-Store" need curl; need unzip ss_node >/dev/null || return 1 has_systemd || { warn "没有 systemd,装不了 Sub-Store 服务"; return 1; } local api_path port host api_path="$(ss_get_env SUB_STORE_FRONTEND_BACKEND_PATH)" [[ -n "$api_path" ]] || api_path="$(ss_gen_path)" local cur_port; cur_port="$(ss_get_env SUB_STORE_FRONTEND_PORT)" ask_default " Web 界面端口" "${cur_port:-3001}" port [[ "$port" =~ ^[0-9]+$ ]] || { warn "端口要是数字,收到的是「$port」"; return 1; } log "" log " ${D}Sub-Store 的后端 API 没有鉴权,地址里那串随机路径就是唯一凭据。${N}" if confirm " 要让局域网/公网直接访问吗?(不建议,默认只绑本机 + SSH 隧道)"; then host="0.0.0.0" else host="127.0.0.1" fi mkdir -p "$SS_DIR" local tmp; tmp="$(mktemp -d)" if ! ss_stage "$tmp"; then rm -rf "$tmp"; return 1; fi rm -rf "$SS_DIR/frontend" install -m 0644 "$tmp/sub-store.bundle.js" "$SS_DIR/sub-store.bundle.js" \ || { rm -rf "$tmp"; warn "写入 $SS_DIR 失败"; return 1; } mv "$tmp/dist" "$SS_DIR/frontend" || { rm -rf "$tmp"; warn "安装前端失败"; return 1; } rm -rf "$tmp" chmod 700 "$SS_DIR" # 数据目录里是你的订阅,别让同机其他用户读 ss_write_unit "$api_path" "$port" "$host" systemctl daemon-reload systemctl enable "$SS_SERVICE" >/dev/null 2>&1 && info "已设为开机自启" systemctl restart "$SS_SERVICE" || true sleep 2 if systemctl is-active --quiet "$SS_SERVICE"; then ok "Sub-Store 已启动" ss_show_access else warn "Sub-Store 没起来: journalctl -u $SS_SERVICE -n 50 --no-pager" return 1 fi } ss_update() { require_root "更新 Sub-Store" ss_installed || { warn "还没安装 Sub-Store"; return 1; } need unzip; ss_node >/dev/null || return 1 local tmp; tmp="$(mktemp -d)" if ! ss_stage "$tmp"; then rm -rf "$tmp"; warn "更新已取消,当前版本没有改动"; return 1 fi # 备份旧的,装新的,起不来就换回去 —— 和内核升级同一套路 local bakdir="${SS_DIR}/.bak" rm -rf "$bakdir"; mkdir -p "$bakdir" cp -a "$SS_DIR/sub-store.bundle.js" "$bakdir/" 2>/dev/null || true cp -a "$SS_DIR/frontend" "$bakdir/frontend" 2>/dev/null || true systemctl stop "$SS_SERVICE" 2>/dev/null || true install -m 0644 "$tmp/sub-store.bundle.js" "$SS_DIR/sub-store.bundle.js" \ || { rm -rf "$tmp"; warn "写入失败,正在启动旧版本"; systemctl start "$SS_SERVICE" || true; return 1; } rm -rf "$SS_DIR/frontend"; mv "$tmp/dist" "$SS_DIR/frontend" rm -rf "$tmp" systemctl start "$SS_SERVICE" || true sleep 3 if systemctl is-active --quiet "$SS_SERVICE"; then ok "Sub-Store 已更新并运行中" rm -rf "$bakdir" else warn "新版本起不来,正在回滚到更新前的版本" systemctl stop "$SS_SERVICE" 2>/dev/null || true cp -a "$bakdir/sub-store.bundle.js" "$SS_DIR/" 2>/dev/null || true rm -rf "$SS_DIR/frontend"; mv "$bakdir/frontend" "$SS_DIR/frontend" 2>/dev/null || true rm -rf "$bakdir" systemctl start "$SS_SERVICE" || true if systemctl is-active --quiet "$SS_SERVICE"; then warn "已回滚,Sub-Store 仍在跑旧版本" else warn "回滚后仍起不来: journalctl -u $SS_SERVICE -n 50 --no-pager" fi return 1 fi } ss_show_access() { local api_path port host api_path="$(ss_get_env SUB_STORE_FRONTEND_BACKEND_PATH)" port="$(ss_get_env SUB_STORE_FRONTEND_PORT)" host="$(ss_get_env SUB_STORE_FRONTEND_HOST)" [[ -n "$port" ]] || { warn "读不到 Sub-Store 的配置,$SS_UNIT 可能被改过"; return 1; } log "" log "${G} ── Sub-Store 访问方式 ──────────────────${N}" if [[ "$host" == "127.0.0.1" ]]; then log " ${D}只绑了本机。先在你自己的电脑上开隧道:${N}" log " ${W}ssh -L ${port}:127.0.0.1:${port} 用户名@这台机器${N}" log " ${D}然后浏览器打开(后面那段必须带上,它就是密码):${N}" log " ${W}http://127.0.0.1:${port}/?api=http://127.0.0.1:${port}${api_path}${N}" else log " ${D}浏览器打开(后面那段必须带上,它就是密码):${N}" log " ${W}http://这台机器的地址:${port}/?api=http://这台机器的地址:${port}${api_path}${N}" warn "绑的是 $host,等于把它挂到了网上。API 没有鉴权,请务必用防火墙限制来源" fi log "" log " ${D}用法: 在里面添加各个机场的订阅和自建节点 → 做一条「组合订阅」→${N}" log " ${D}复制它的 mihomo 格式输出地址 → 回本脚本「配置管理 → 生成配置」填进去。${N}" log "${G} ────────────────────────────────────────${N}" } ss_uninstall() { require_root "卸载 Sub-Store" log "" log " ${D}会删掉: 服务 $SS_UNIT、程序和数据目录 $SS_DIR${N}" log " ${Y}数据目录里是你所有的订阅和组合规则,删了就没了。${N}" confirm " 确认卸载 Sub-Store?" || { info "已取消"; return 0; } systemctl stop "$SS_SERVICE" 2>/dev/null || true systemctl disable "$SS_SERVICE" 2>/dev/null || true [[ -f "$SS_UNIT" ]] && { rm -f "$SS_UNIT"; ok "已删除 $SS_UNIT"; } systemctl daemon-reload; systemctl reset-failed "$SS_SERVICE" 2>/dev/null || true if confirm " 连数据目录 $SS_DIR 一起删?(订阅和组合规则都在里面)"; then rm -rf "$SS_DIR"; ok "已删除 $SS_DIR" else info "数据保留在 $SS_DIR,下次重新安装能直接接上" fi ok "Sub-Store 已卸载" } ss_menu() { while true; do local st="${D}未安装${N}" if ss_installed; then if systemctl is-active --quiet "$SS_SERVICE" 2>/dev/null; then st="${G}● 运行中${N}" else st="${Y}○ 已停止${N}"; fi fi cat >&2 <%s ' "$W" "$N")" c echo >&2 case "$c" in 1) ss_install || true; pause ;; 2) ss_update || true; pause ;; 3) ss_show_access || true; pause ;; 4) ss_svc_menu ;; 5) ss_uninstall || true; pause ;; 0|q|"") return 0 ;; *) warn "没有「$c」这个选项,输入上面列出的数字,或 0 返回"; sleep 1 ;; esac done } ss_svc_menu() { cat >&2 <%s ' "$W" "$N")" c case "$c" in 1) require_root "控制 $SS_SERVICE"; systemctl start "$SS_SERVICE" && ok "已启动" || warn "启动失败: journalctl -u $SS_SERVICE -n 50" ;; 2) require_root "控制 $SS_SERVICE"; systemctl stop "$SS_SERVICE" && ok "已停止" ;; 3) require_root "控制 $SS_SERVICE"; systemctl restart "$SS_SERVICE" && ok "已重启" || warn "重启失败: journalctl -u $SS_SERVICE -n 50" ;; 4) systemctl status "$SS_SERVICE" --no-pager || true ;; 5) journalctl -u "$SS_SERVICE" -n 40 --no-pager || true ;; *) return 0 ;; esac pause } # ───────────────────────── 一键安装 ───────────────────────── 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 # Sub-Store 是独立服务,不跟着 mihomo 一起删 —— 它是你的订阅数据所在 if ss_installed; then log "" info "Sub-Store 还装着($SS_DIR),它是独立服务,没有跟着删" info "要一起卸载的话: 菜单第 9 项 → 卸载,或 $0 substore" 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) ss_menu ;; u|U) 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 sub port="$(cfg_get 'mixed-port' || true)" lan="$(cfg_get 'allow-lan' || true)" ctrl="$(cfg_get 'external-controller' || true)" sub="$(cfg_sub_url || true)" cat >&2 <%s ' "$W" "$N")" c echo >&2 case "$c" in 1) cfg_wizard || true; pause ;; 2) cfg_change_port || true; pause ;; 3) show_panel_info || true; pause ;; 4) cfg_validate || true; pause ;; 5) cfg_edit || 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|substore|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 ;; substore) ss_menu ;; 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