diff --git a/README.md b/README.md index e2dfd8f..64b4ce4 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,124 @@ # mihomo-up -一个 bash 脚本,用来在 Linux 服务器上**检查、安装、升级、回滚 [mihomo](https://github.com/MetaCubeX/mihomo) 内核**(Clash.Meta 的继任者,MetaCubeX 维护的代理核心)。可以直接跑交互菜单,也可以用子命令接进 cron / CI。 +一个 bash 脚本,在 Linux 服务器上**装好、配好、管住** [mihomo](https://github.com/MetaCubeX/mihomo) 内核(Clash.Meta 的继任者,MetaCubeX 维护的代理内核)。 -脚本只管内核二进制本身:它**不会**替你创建 systemd 服务、生成配置文件或设置订阅,这些需要你自己先准备好。 +一条命令从零到能用:下载内核 → 写 systemd 服务 → 用向导生成 `config.yaml` → 启动。之后改订阅、换端口、升级、回滚、卸载也都在同一个菜单里。 -## 特性 +它管这三样东西,别的不碰: -- **双模式**:终端下无参数运行进入交互菜单;被管道/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;没有的话脚本只替换二进制、跳过重启 +| | 默认位置 | +|---|---| +| 内核二进制 | `/usr/local/bin/mihomo` | +| systemd 服务 | `/etc/systemd/system/mihomo.service` | +| 配置文件 | `/etc/mihomo/config.yaml` | ## 快速开始 -**交互菜单**: +**交互菜单**(推荐,装机和日常维护都用它): ```bash bash <(curl -fsSL https://git.im0o.top/im0o/mihomo-up/raw/branch/main/mihomo-up.sh) ``` +菜单第 1 项就是一键安装。跑完你会拿到:本机的 HTTP/SOCKS5 代理端口、一个 Web 面板地址和它的访问密钥,以及一个开机自启的 systemd 服务。 + **命令行**: ```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 # 查看本地备份 +sudo ./mihomo-up.sh install # 一键安装:内核 + systemd + 配置向导 + 启动 + ./mihomo-up.sh check # 只查不改:当前版本 vs 最新版本 +sudo ./mihomo-up.sh update # 只换内核二进制,配置和服务定义不动 +sudo ./mihomo-up.sh config # 重新跑配置向导 +sudo ./mihomo-up.sh rollback # 内核回滚到上一个备份 +sudo ./mihomo-up.sh uninstall # 卸载,会逐项问你配置和备份删不删 ``` -菜单里另有:校验配置文件、服务控制(启停/状态/最近日志)等入口。 +## 菜单里有什么 + +``` +1. 一键安装 内核 + systemd + 配置,缺哪步补哪步 +2. 检查更新 3. 升级内核 4. 装指定版本内核 +5. 回滚内核 6. 查看内核备份 +7. 配置管理 换订阅 / 改端口 / 局域网共享 / 面板密钥 / 导入配置 / 校验 / 编辑 / 备份还原 +8. 服务控制 启停 / 重启 / 状态 / 最近日志 / 开机自启 / 重写服务文件 +9. 卸载 +``` + +## 一键安装做了什么 + +1. **下内核**:按机器架构从 GitHub Releases 取对应的 `.gz` 并解压 +2. **验内核**:新二进制必须能执行,且 `-v` 自报的版本号和目标一致,否则不落盘 +3. **配文件**:没有 `config.yaml` 就跑配置向导(下面详述);已经有了就保持不动,只问一句要不要重做 +4. **写服务**:按 [MetaCubeX 官方文档](https://wiki.metacubex.one/startup/service/)的推荐写 `mihomo.service`;已存在且内容不同的话,旧文件先备份成 `.bak-<时间戳>` +5. **起服务**:`daemon-reload` → `enable`(开机自启)→ `restart`,起不来就提示你看 `journalctl` + +装完会打印一段总结:代理端口怎么用、面板地址和密钥是什么、日志怎么看。 + +## 配置向导问什么 + +向导生成的是一份**最小可用配置**,够跑,不臃肿: + +| 问题 | 说明 | +|---|---| +| 订阅链接 | 机场给的 http(s) 地址,写成 `proxy-providers`,每 24 小时自动更新一次 | +| 本地代理端口 | `mixed-port`,HTTP 和 SOCKS5 共用,默认 7890 | +| 局域网共享 | `allow-lan`,开了同网段其它设备也能连(记得放行防火墙) | +| 管理接口 | 只允许本机 / 局域网公网可访问 / 不开。面板和 API 都连这里 | +| 访问密钥 | 回车即自动生成随机串;管理接口开在 `0.0.0.0` 时务必设 | +| Web 面板 | 选是就写 `external-ui-url`,内核首次启动自己去下载 metacubexd | +| 分流规则 | 国内直连、其余走代理(默认),或者全部走代理 | + +生成的分组是 `PROXY`(手动选,默认跟着 AUTO)和 `AUTO`(自动挑延迟最低的),规则里内网地址一律直连。 + +**改配置不用手写 YAML**:菜单「配置管理」能直接换订阅、改端口、开关局域网共享、改面板密钥。每次改动之前自动备份成 `config.yaml.bak-<时间戳>`,改完立刻用内核 `-t` 校验,**校验不过会问你要不要还原**,服务在跑的话再问一句要不要重启生效。 + +需要手写的复杂配置(DNS、TUN、自定义规则)请用菜单里的「手动编辑配置」,或者用「从 URL 导入完整配置」整份换掉——这两条路同样有备份、校验、还原兜底。 + +## 升级和回滚 + +`update` 只换内核二进制,不动你的配置和服务定义。两道闸门: + +1. 新二进制能跑起来,且版本号对得上 +2. 用**新版本**去 `-t` 校验你**现有的**配置,通不过就中止,一个文件都不动 + (配置本来就有问题、但你只想换内核时,加 `--skip-conf-check` 跳过这道) + +替换前旧内核备份到 `/var/backups/mihomo/`(保留最近 3 份)。升级后服务起不来会**自动回滚**到旧版并重启,然后提示你看 `journalctl -u mihomo -n 50`。 + +任何一步失败都不会留下半个系统:要么没动,要么已回滚。 + +## 卸载会删什么 + +```bash +sudo ./mihomo-up.sh uninstall # 逐项确认 +sudo ./mihomo-up.sh uninstall --purge # 全删,不问 +``` + +直接删掉:内核二进制、`mihomo.service` 及其备份、`mihomo.service.d/` drop-in 目录,并 `daemon-reload` + `reset-failed` 清干净 systemd 状态(服务会先 stop 再 disable)。 + +单独问你:配置目录 `/etc/mihomo`(订阅、节点选择记录、面板文件都在里面)、内核备份目录 `/var/backups/mihomo`。留着的话下次重装能直接接上。 ## 在 cron / CI 里用 +无参数运行时,脚本会看 stdin 是不是终端:是就进菜单,被管道或 cron 调用就安静地跑 `check`。 + `check` 的退出码专为脚本化设计: | 退出码 | 含义 | |---|---| | 0 | 已是最新 | -| 10 | 有新版(或未安装) | +| 10 | 有新版(或还没安装) | | 1 | 出错 | -例子——有新版时自动升级: +例子——有新版就自动升级: ```bash mihomo-up.sh check && exit 0 [ $? -eq 10 ] && mihomo-up.sh update -y ``` -加 `--json` 会额外输出一行机器可读的 JSON,方便接入监控: +加 `--json` 会额外输出一行机器可读的 JSON: ```json {"current":"v1.19.27","latest":"v1.19.28","outdated":true} @@ -68,34 +128,54 @@ mihomo-up.sh check && exit 0 | 选项 | 作用 | |---|---| -| `-y, --yes` | 非交互确认,全部默认同意 | -| `-n, --dry-run` | 只打印会做什么,不实际执行 | -| `--force` | 即使版本相同也重装(`install` 子命令等价于 `update --force`) | -| `--version ` | 指定版本,如 `v1.19.28` | +| `-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` | -| `--bin ` | 二进制路径,默认 `/usr/local/bin/mihomo` | +| `--mirror ` | GitHub 下载前缀,见下 | +| `--bin ` | 内核路径,默认 `/usr/local/bin/mihomo` | | `--conf-dir ` | 配置目录,默认 `/etc/mihomo` | +| `--service ` | systemd 服务名,默认 `mihomo` | +| `-v` | 打印脚本自身版本 | + +## GitHub 不通的机器 + +内核、版本号、Web 面板都从 GitHub 下载。国内机器直连失败时,用 `--mirror` 指定一个反代前缀,脚本会把它拼在所有 GitHub 地址前面: + +```bash +sudo ./mihomo-up.sh install --mirror https://ghfast.top/ +``` + +带 `--mirror` 跑配置向导时,生成的配置里会额外写上走同一个镜像的 `geox-url`,免得内核首次启动下载 GeoIP 数据库时卡住。 ## 默认值与环境变量 +命令行选项优先级最高,其次是环境变量,最后是下面这些默认值: + | 默认值 | 环境变量 | |---|---| | `/usr/local/bin/mihomo` | `MIHOMO_BIN` | | `/etc/mihomo` | `MIHOMO_CONF_DIR` | | `mihomo`(systemd 服务名) | `MIHOMO_SERVICE` | | `/var/backups/mihomo` | `MIHOMO_BACKUP_DIR` | +| 空(不用镜像) | `MIHOMO_MIRROR` | -备份最多保留 3 份(脚本内常量 `KEEP=3`)。 +内核备份最多保留 3 份(脚本内常量 `KEEP=3`)。配置备份不自动清理,攒多了自己删 `config.yaml.bak-*`。 -## 升级时发生了什么 +## 环境要求 -1. 从 GitHub Releases 下载对应架构的 `.gz` 包并解压 -2. **闸门 1**:新二进制必须能执行,且 `-v` 报告的版本号与目标一致 -3. **闸门 2**:若存在 `config.yaml`,用新二进制 `-t` 校验现有配置,失败即中止 -4. 备份旧二进制 → 停服务 → 安装新版 → 启动服务 -5. 启动失败则**自动回滚**到刚才的备份并重启,最后提示你查 `journalctl -u mihomo -n 50` +- Linux + bash 4 以上 +- 依赖命令:`curl`、`gzip`、`install`、`awk`、`sed`(一般发行版自带) +- 安装、升级、改配置、卸载都需要 **root**,非 root 进菜单会先提醒一句 +- 没有 systemd 的机器(容器等)也能用:脚本会跳过服务相关步骤,只管内核和配置,你自己 `mihomo -d /etc/mihomo` 拉起来 +- 交互输入读 `/dev/tty`,所以 `bash <(curl ...)` 这种 stdin 被占用的跑法照样能按键 -任何一步失败都不会留下半个新系统:要么没动,要么已回滚。 +## 参考 + +systemd 服务写法来自 [MetaCubeX 官方文档](https://wiki.metacubex.one/startup/service/)。一键安装和卸载的思路参考了 [ForLoveIcu/mihomo-for-linux-install](https://github.com/ForLoveIcu/mihomo-for-linux-install) 和 [nelvko/clash-for-linux-install](https://github.com/nelvko/clash-for-linux-install)。 diff --git a/mihomo-up.sh b/mihomo-up.sh index 18b14a3..b77b5fd 100644 --- a/mihomo-up.sh +++ b/mihomo-up.sh @@ -1,22 +1,36 @@ #!/usr/bin/env bash # -# mihomo-up.sh — mihomo 内核管理脚本 (交互菜单 + 命令行双模式) +# mihomo-up.sh — mihomo 内核的安装 / 配置 / 升级 / 卸载脚本(交互菜单 + 命令行双模式) # -# bash <(curl -fsSL https://你的地址/mihomo-up.sh) # 交互菜单 -# ./mihomo-up.sh check # 非交互, 给 cron/CI 用 +# 它管三样东西: +# 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" + 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 +# 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 -TMPDIR_="" +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 @@ -39,44 +53,63 @@ cleanup() { return $rc } trap cleanup EXIT -trap 'die "第 $LINENO 行出错, 已中止"' ERR +trap 'die "第 $LINENO 行出错,已中止"' ERR usage() { cat <<'EOF' -mihomo-up.sh — 检查 / 安装 / 升级 / 回滚 mihomo 内核 +mihomo-up.sh — 安装 / 配置 / 升级 / 卸载 mihomo 内核 - mihomo-up.sh 进入交互菜单 (终端下默认) - mihomo-up.sh check 只查, 不改任何东西 - mihomo-up.sh update [--force] 升级内核 - mihomo-up.sh rollback 回滚到上一个备份 - mihomo-up.sh list 列出本地备份 + 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 rollback 回滚到上一个备份的内核 + mihomo-up.sh list 列出本地内核备份 + mihomo-up.sh uninstall [--purge] 卸载内核和服务;--purge 连配置和备份一起删 选项: - -y, --yes 非交互确认, 全部默认同意 - -n, --dry-run 只打印会做什么 - --version 指定版本, 如 v1.19.28 + -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 - --bin 默认 /usr/local/bin/mihomo - --conf-dir 默认 /etc/mihomo + --mirror GitHub 下载前缀,如 https://ghfast.top/ + --bin 内核路径,默认 /usr/local/bin/mihomo + --conf-dir 配置目录,默认 /etc/mihomo + --service systemd 服务名,默认 mihomo + -v 打印本脚本自身的版本号 -check 退出码: 0 = 已是最新 10 = 有新版 1 = 出错 +check 退出码: 0 = 已是最新 10 = 有新版或未安装 1 = 出错 EOF exit "${1:-0}" } # ───────────────────────── 交互输入 ───────────────────────── -# 关键: 从 /dev/tty 读, 这样 bash <(curl ...) 和管道场景都不会把 stdin 抢走 +# 从 /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 prompt="$1" __var="$2" __ans="" - printf '%s' "$prompt" >&2 - read -r __ans < "$TTY_IN" || __ans="" - printf -v "$__var" '%s' "$__ans" + 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 @@ -85,18 +118,43 @@ confirm() { # confirm "问题?" -> 0/1 [[ "$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 指定)" ;; + *) die "未适配的架构: $(uname -m)(可用 --arch 手动指定 amd64/arm64/armv7)" ;; esac } @@ -106,7 +164,7 @@ asset_name() { echo "mihomo-linux-${arch}${suffix}-${1}.gz" } -version_line() { # 不用 head, 避免 SIGPIPE 触发 ERR trap +version_line() { # 不用 head,避免 SIGPIPE 触发 ERR trap local out; out="$("$1" -v 2>/dev/null || true)" printf '%s' "${out%%$'\n'*}" } @@ -120,53 +178,77 @@ current_version() { 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" + "$(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() { - command -v systemctl >/dev/null 2>&1 || { echo "无 systemd"; return; } + has_systemd || { 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" + elif [[ -f "$UNIT_FILE" ]] || systemctl list-unit-files 2>/dev/null | grep -q "^${SERVICE}\.service"; then echo "stopped" else echo "未安装"; fi } -# ───────────────────────── 动作 ───────────────────────── -do_update() { # $1 = 目标版本 +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 + ok "已是最新 ($cur),无需操作"; return 0 fi - [[ ${EUID:-$(id -u)} -eq 0 ]] || die "需要 root (要写 $BIN 并重启服务)" + require_root "替换 $BIN 并重启服务" - TMPDIR_="$(mktemp -d)" - local asset url; asset="$(asset_name "$target")" - url="https://github.com/${REPO}/releases/download/${target}/${asset}" + if (( DRY_RUN )); then + ok "[dry-run] 会下载 $(asset_name "$target") 并安装到 $BIN"; return 0 + fi - 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" + stage_binary "$target" - # 闸门 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 "配置在新版本下校验通过" + # 闸门 2: 用新版本校验现有配置,配置在新版本下跑不通就别换 + if [[ -f "$(conf_file)" ]] && (( SKIP_CONF_CHECK == 0 )); then + local out + if out="$(test_conf_with "$STAGED")"; then + ok "现有配置在 $target 下校验通过" else - warn "新版本校验现有配置失败:"; sed 's/^/ /' "$TMPDIR_/test.log" >&2 - die "已中止, 未替换任何文件" + warn "$target 校验现有配置失败:"; sed 's/^/ /' <<<"$out" >&2 + die "没有替换任何文件。先把配置改好(菜单「配置管理 → 校验配置」可以反复验),或加 --skip-conf-check 跳过这道检查" fi fi @@ -178,9 +260,9 @@ do_update() { # $1 = 目标版本 local was_active=0 [[ "$(svc_state)" == "running" ]] && was_active=1 - trap - ERR # 下面自行处理失败并回滚 + trap - ERR # 下面自己处理失败并回滚 (( was_active )) && { info "停止 $SERVICE"; systemctl stop "$SERVICE"; } - install -m 0755 "$TMPDIR_/mihomo" "$BIN" + install -m 0755 "$STAGED" "$BIN" ok "已安装 $target → $BIN" if (( NO_RESTART )); then @@ -191,12 +273,15 @@ do_update() { # $1 = 目标版本 if [[ "$(svc_state)" == "running" ]]; then ok "$SERVICE 运行中" else - warn "$SERVICE 启动失败, 自动回滚" - [[ -f "$BACKUP_DIR/mihomo-$cur" ]] && { + 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" - } + warn "已回滚到 $cur;看失败原因: journalctl -u $SERVICE -n 50" + else + warn "没有 $cur 的备份可回滚;看日志: journalctl -u $SERVICE -n 50" + fi + trap 'die "第 $LINENO 行出错,已中止"' ERR return 1 fi fi @@ -204,12 +289,13 @@ do_update() { # $1 = 目标版本 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 + 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; } + 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 @@ -224,14 +310,550 @@ do_list() { printf ' %s %s%s%s\n' "$(basename "$f")" "$D" "$(date -r "$f" '+%F %T')" "$N" done else - log " ${D}没有备份 ($BACKUP_DIR)${N}" + 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 +# ───────────────────────── 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 +} + +# ───────────────────────── 配置 ───────────────────────── +# 说明:下面这组函数只改 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),先用「生成配置」跑一遍向导" + 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 +} + +cfg_wizard() { # 生成一份全新的 config.yaml + local f; f="$(conf_file)" + mkdir -p "$CONF_DIR" + + log "" + log "${W} 配置向导${N} ${D}— 回车用方括号里的默认值${N}" + log "${D} 生成的是一份最小可用配置:一个订阅、一组选择/自动测速分组、国内直连其余走代理。${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 " 订阅链接(机场给的那个 http(s) 地址)" "${old_sub:-}" sub + [[ -n "$sub" ]] && break + if (( ++tries >= 3 )); then + warn " 一直没拿到订阅链接,向导先退出了,配置没有改动" + return 1 + fi + warn " 订阅链接不能空,没有节点 mihomo 起不来" + done + + 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 + + ask_default " 分流规则: 1=国内直连、其余走代理 2=全部走代理" "1" rules_mode + + # 先写到临时文件,确认无误再落盘 + 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 "配置写进去了但校验没过,可以用「编辑配置」改,或重新跑一遍向导" +} + +cfg_sub_url() { # 读回本脚本写的订阅链接 + local f; f="$(conf_file)" + [[ -f "$f" ]] || return 0 + sed -n -E 's|^[[:space:]]*url:[[:space:]]*"?([^"#]*)"?[[:space:]]*#[[:space:]]*mihomo-up:sub.*|\1|p' "$f" \ + | sed -n '1p' | sed -E 's/[[:space:]]+$//' +} + +cfg_change_sub() { + local f; f="$(conf_file)" + [[ -f "$f" ]] || { warn "还没有配置文件,先用「生成配置」跑一遍向导"; return 1; } + local cur; cur="$(cfg_sub_url)" + if [[ -z "$cur" ]]; then + warn "在 $f 里没找到本脚本写的订阅行(标记是行尾的 # mihomo-up:sub)" + warn "这份配置多半是手写或外部导入的,改订阅请用「编辑配置」,或重跑向导生成新配置" + return 1 + fi + local new; ask_default " 新的订阅链接" "$cur" new + [[ -n "$new" && "$new" != "$cur" ]] || { info "没有改动"; return 0; } + + local bak; bak="$(cfg_backup)" + local esc; esc="$(printf '%s' "$new" | sed -e 's/[\\&|]/\\&/g')" + sed -i -E "s|^([[:space:]]*url:[[:space:]]*).*(#[[:space:]]*mihomo-up:sub.*)|\1\"${esc}\" \2|" "$f" + ok "订阅链接已更新" + # 订阅缓存留着会继续用旧节点,删掉让内核下次启动重新拉 + rm -f "$CONF_DIR/providers/subs.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 "已允许局域网设备连本机代理(记得放行防火墙端口)" + 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}" ;; + *) 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 "" +} + +# ───────────────────────── 一键安装 ───────────────────────── +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、并在缺配置时启动向导"; 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 (( 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}" + 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}" + 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 + + 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 "卸载完成" } # ───────────────────────── 菜单 ───────────────────────── @@ -245,14 +867,16 @@ EOF } status_panel() { - local cur latest svc conf - cur="$(current_version)"; svc="$(svc_state)" + local cur latest svc conf enabled + cur="$(current_version)"; svc="$(svc_state)"; enabled="$(svc_enabled)" latest="$(latest_version 2>/dev/null || echo '查询失败')" - if [[ -x "$BIN" && -f "$CONF_DIR/config.yaml" ]]; then + if [[ -x "$BIN" && -f "$(conf_file)" ]]; then "$BIN" -t -d "$CONF_DIR" >/dev/null 2>&1 && conf="${G}有效${N}" || conf="${R}有错${N}" + elif [[ -f "$(conf_file)" ]]; then + conf="${D}有文件, 未校验${N}" else - conf="${D}无配置${N}" + conf="${Y}未配置${N}" fi local cur_s="${cur:-${Y}未安装${N}}" latest_s="$latest" @@ -262,71 +886,140 @@ status_panel() { 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" "$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) local cur latest; cur="$(current_version)"; latest="$(latest_version)" - if [[ -z "$cur" ]]; then warn "还没安装, 选 2 装上" + 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"; fi + confirm " 现在升级?" && { do_update "$latest" || warn "升级未完成"; }; fi pause ;; - 2) local latest; latest="$(latest_version)" - confirm " 升级/重装到 $latest ?" && { FORCE=1; do_update "$latest"; FORCE=0; } + 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 ;; - 3) local v; ask " 输入版本号 (如 v1.19.28): " v - [[ "$v" =~ ^v[0-9] ]] && { FORCE=1; do_update "$v"; FORCE=0; } || warn "版本号格式不对" + 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 ;; - 4) confirm " 确认回滚?" && do_rollback; pause ;; - 5) echo >&2; do_list; pause ;; - 6) do_test_conf || true; pause ;; - 7) svc_menu ;; + 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 "没这个选项啦"; sleep 1 ;; esac done } +conf_menu() { + while true; do + local sub port lan ctrl + sub="$(cfg_sub_url || true)"; port="$(cfg_get 'mixed-port' || true)" + lan="$(cfg_get 'allow-lan' || true)"; ctrl="$(cfg_get 'external-controller' || true)" + + cat >&2 <%s ' "$W" "$N")" c + echo >&2 + case "$c" in + 1) cfg_wizard || true; pause ;; + 2) cfg_change_sub || true; pause ;; + 3) cfg_change_port || true; pause ;; + 4) cfg_toggle_lan || true; pause ;; + 5) cfg_change_ctrl || true; pause ;; + 6) cfg_import_url || true; pause ;; + 7) cfg_validate || true; pause ;; + 8) cfg_edit || true; pause ;; + 9) cfg_backup_menu || true; pause ;; + 0|q|"") return 0 ;; + *) warn "没这个选项啦"; sleep 1 ;; + esac + done +} + svc_menu() { cat >&2 <%s ' "$W" "$N")" c case "$c" in - 1) systemctl start "$SERVICE" && ok "已启动" ;; + 1) systemctl start "$SERVICE" && ok "已启动" || warn "启动失败,看日志: journalctl -u $SERVICE -n 50" ;; 2) systemctl stop "$SERVICE" && ok "已停止" ;; - 3) systemctl restart "$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 ;; *) return 0 ;; esac pause @@ -335,17 +1028,17 @@ EOF # ───────────────────────── 参数解析 ───────────────────────── CMD="" if [[ $# -eq 0 ]]; then - # stdin 是终端就进菜单; 被重定向/管道 (cron、CI) 就安静地跑 check + # 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 ;; + check|update|install|uninstall|config|service|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 -[[ "$CMD" == install ]] && { CMD="update"; FORCE=1; } while (( $# )); do case "$1" in @@ -353,14 +1046,18 @@ while (( $# )); do -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:?}"; shift ;; - --arch) ARCH_OVERRIDE="${2:?}"; shift ;; - --variant) VARIANT="${2:?}"; shift ;; - --bin) BIN="${2:?}"; shift ;; - --conf-dir) CONF_DIR="${2:?}"; shift ;; + --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 ;; + --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" ;; + *) die "未知参数: $1(用 --help 看全部选项)" ;; esac shift done @@ -369,15 +1066,19 @@ need curl; need gzip; need install # ───────────────────────── 分发 ───────────────────────── case "$CMD" in - menu) menu ;; - list) do_list ;; - rollback) do_rollback ;; + menu) menu ;; + install) do_install ;; + uninstall) do_uninstall ;; + config) cfg_wizard ;; + service) install_unit ;; + 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; } + [[ -z "$CUR" ]] && { log "→ 还没装,跑 install 一键安装"; exit 10; } [[ "$CUR" == "$TARGET" ]] && { ok "已是最新"; exit 0; } warn "有新版本可升级"; exit 10 ;; update) @@ -385,4 +1086,4 @@ case "$CMD" in CUR="$(current_version)" log "当前: ${CUR:-${Y}未安装${N}} 目标: $TARGET" do_update "$TARGET" ;; -esac \ No newline at end of file +esac