From c9b410c65eb76fe029753489f99886939a94048b Mon Sep 17 00:00:00 2001 From: Jz0ojiang Date: Sun, 9 Aug 2026 22:21:52 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E7=A0=8D=E6=8E=89=E9=9D=9E?= =?UTF-8?q?=E5=88=9A=E9=9C=80=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BF=AE=E8=8B=A5?= =?UTF-8?q?=E5=B9=B2=E9=9D=99=E9=BB=98=E5=A4=B1=E8=B4=A5=EF=BC=8C=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20Sub-Store=20=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 订阅的聚合、改名、筛选交给 Sub-Store 承担,脚本收窄为「管内核 + 管服务 + 生成一份最小可用配置」。1579 → 1542 行,但配置相关的多行 YAML 手术全部消失。 砍掉: - 入站端口 listeners 全套、多订阅与订阅管理菜单、GEO 菜单、地区分组、 局域网开关、备份还原菜单、URL 导入;管理接口那项改成只读显示密钥 - 配置菜单从 11 项减到 5 项,剩下的 YAML 改写只有顶层单键 修: - ERR trap 在写操作里其实是失效的: `func || true` 会关掉整个函数体的 errexit 且无法在内部恢复。所以改为逐条显式检查,并给所有写操作补 require_root —— die 的 exit 是当前唯一还能可靠中止的机制 - cfg_set 用 sed 替换会损坏数据: 替换文本里 & 代表整条匹配,值含 & 时 旧行会被塞进新值;| 和 \ 还会被改写成 &。改用 awk 整行重建,值走 环境变量传入(-v 会解释转义序列,吃掉反斜杠) - 订阅地址含 # 时会被正则截断,导致列表与改写的计数口径不一致、改错行、 静默丢订阅。改用 awk 取最后一个标记位置来切 - cfg_backup 的 cp 失败仍返回 0 并吐出不存在的路径,让「改坏了就还原」 整条链路空转 - do_update 里 install 失败被吞掉,然后拿旧二进制启动并报升级成功 - svc_state 的 list-unit-files|grep -q 撞 SIGPIPE + pipefail,装了也判为未装 - svc_enabled 对 disabled 单元输出两行 - latest_version 缺 --max-time,网络被挂住时菜单无限等 - 向导端口输错直接 die 掉整个菜单;重启失败没有任何提示 新增 Sub-Store(可选,不进一键安装): - 原生 Node + systemd,装到 /opt/sub-store;不替你装 Node,只检测并给命令 - 更新走和内核升级同款闸门: node --check 验后端、unzip -t 验前端, 备份 → 换上 → 起服务,起不来自动回滚 - 后端 API 无鉴权,随机路径就是唯一凭据,故默认绑 127.0.0.1 并打印 SSH 隧道命令 - 卸载 mihomo 不连带删除它,订阅数据在里面 验证: 三场景 × 9 项严格回归(保留 set -Eeuo pipefail 和 ERR trap)全过; 三种规则模式生成的配置用 PyYAML 解析通过;含 # 的订阅地址往返无损; cfg_set 对 & | \ 五种输入均原样写入;两个 Sub-Store 资产地址经 API 确认存在。 --- README.md | 93 ++---- mihomo-up.sh | 905 ++++++++++++++++++++++++--------------------------- 2 files changed, 463 insertions(+), 535 deletions(-) diff --git a/README.md b/README.md index c0db4d8..43f90c8 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,15 @@ | 脚本缓存 | `/var/lib/mihomo-up/` | | 内核备份 | `/var/backups/mihomo/` | +(可选装的 Sub-Store 另在 `/opt/sub-store`,卸载 mihomo 不会动它) + 除此之外它不碰你系统里的任何东西。 ## 环境要求 - Linux + bash 4 以上 - 依赖命令:`curl`、`gzip`、`install`、`awk`、`sed`(一般发行版自带) +- 只有选装 Sub-Store 时才额外需要 `node` 和 `unzip`;脚本会检测并给出安装命令,不替你装 - 安装、升级、修改配置、卸载都需要 **root**。非 root 也能进菜单,会先提醒一句 - 没有 systemd 的机器(容器等)也能用:脚本跳过服务相关步骤,只管内核和配置,你自己 `mihomo -d /etc/mihomo` 启动起来 @@ -50,22 +53,24 @@ sudo ./mihomo-up.sh config # 重新运行配置向导 sudo ./mihomo-up.sh rollback # 内核回滚到上一个备份 sudo ./mihomo-up.sh uninstall # 卸载,会逐项问你配置和备份要不要删除 sudo ./mihomo-up.sh shortcut # 只安装 / 刷新 mihomo-up 快捷命令 +sudo ./mihomo-up.sh substore # 安装 / 更新 Sub-Store(订阅聚合,可选) ``` ## 菜单里有什么 ``` 1. 一键安装 内核 + systemd + 配置,缺哪步补哪步 -2. 检查更新 3. 升级内核 4. 安装指定版本内核 +2. 检查更新 3. 升级内核 4. 装指定版本内核 5. 回滚内核 6. 查看内核备份 -7. 配置管理 订阅 / 端口 / 局域网 / 面板密钥 / 入站端口 / GEO / 导入 / 校验 / 编辑 / 备份 -8. 服务控制 启动 / 停止 / 重启 / 状态 / 最近日志 / 开机自启 / 重写服务文件 -9. 卸载 +7. 配置管理 生成配置 / 改端口 / 看面板密钥 / 校验 / 手动编辑 +8. 服务控制 启动 / 停止 / 重启 / 状态 / 日志 / 开机自启 +9. Sub-Store 订阅聚合,可选,见下 +u. 卸载 mihomo ``` ## 一键安装做了什么 -1. **下载内核**:按机器架构从 GitHub Releases 下载对应的 `.gz` 并解压 +1. **下载内核**:按机器架构从 GitHub Releases 取对应的 `.gz` 并解压 2. **校验内核**:新二进制必须能执行,且 `-v` 自报的版本号和目标一致,否则不写入磁盘 3. **准备配置**:没有 `config.yaml` 就运行配置向导;已经有了就保持不动,只问一句要不要重做 4. **写入服务**:按 [MetaCubeX 官方文档](https://wiki.metacubex.one/startup/service/)的推荐写 `mihomo.service`。已存在且内容不同的话,旧文件先备份成 `.bak-<时间戳>` @@ -98,24 +103,19 @@ fork 之后想让快捷命令指向你自己那份,安装的时候加 `--scrip ## 配置向导问什么 -向导生成的是一份**最小可用配置**,够用,不臃肿: +向导生成的是一份**最小可用配置**,够用,不臃肿。它只认**一条**订阅地址——多个机场的聚合、节点改名、筛选去重,交给 Sub-Store 那类工具做(见下一节),脚本这边不重复造。 | 问题 | 说明 | |---|---| -| 订阅链接 | 机场给的 http(s) 地址。**可以填多个**,一次一个,回车结束(最多 8 个) | -| 本地代理端口 | `mixed-port`,HTTP 和 SOCKS5 共用,默认 7890 | +| 订阅地址 | 机场给的,或 Sub-Store 输出的那条。写成 `proxy-providers`,每 24 小时自动更新 | +| 本地代理端口 | `mixed-port`,HTTP 和 SOCKS5 共用同一个端口,默认 7890 | | 局域网共享 | `allow-lan`,开了同网段其它设备也能连(还要放行防火墙) | | 管理接口 | 只允许本机 / 局域网公网可访问 / 不开启。面板和 API 都连这里 | | 访问密钥 | 回车即自动生成随机串;管理接口开在 `0.0.0.0` 时务必设 | | Web 面板 | 选是就写 `external-ui-url`,内核首次启动自己去下载 metacubexd | -| 地区分组 | 留空=不拆。填 `HK JP US` 之类就按地区各建一个自动测速组 | | 分流规则 | 三选一,见下面的表 | -多个订阅的节点会合并进同一组。每个订阅是一条独立的 `proxy-providers`,各自按 24 小时周期更新。 - -**地区分组**支持 `HK TW JP SG US KR UK DE FR`。每个地区一个 `url-test` 组,靠节点名里的关键词匹配,中英文和旗帜 emoji 都认。 - -注意:某个地区一个节点都没匹配上的话内核会拒绝启动,紧接着的校验会当场告诉你。 +生成的分组是 `PROXY`(总开关,手动选,默认跟着 AUTO)和 `AUTO`(全部节点里自动挑延迟最低的)。 **分流规则**三选一,内网地址在哪种模式下都直连: @@ -127,69 +127,32 @@ fork 之后想让快捷命令指向你自己那份,安装的时候加 `--scrip 地区填两位 ISO 代码(`CN`、`US`、`JP`、`DE`…),小写自动转大写。 -生成的分组是 `PROXY`(总开关,手动选,默认跟着 AUTO)、`AUTO`(全部节点里自动测速),加上你要的那几个地区组。 +**模式 3(回国)**不用 `GEOIP`,而是生成两条 `.mrs` 规则集:域名维度打头,IP 维度兜底。原因是很多国内站挂着境外 CDN,域名解析出来的 IP 不在国内段,只靠 `GEOIP` 会整片漏掉。只有 `CN` 有成体系的域名列表,填别的国家代码时脚本会当场告诉你只能按 IP 匹配、会漏掉什么。 -## 回国 / 分区代理 +**改配置的安全网**:每次改动之前自动备份成 `config.yaml.bak-<时间戳>`(只留最近 10 份),改完立刻用内核 `-t` 校验。**校验不过会问你要不要还原**,服务正在运行的话再问一句要不要重启生效。配置文件权限是 `0640`,因为密钥就明文存在里面。 -上面的模式 3 是反过来的:命中的地区走代理,其余直连。适合人在境外、只想让国内站点走国内节点。 +复杂配置(DNS、TUN、自定义规则、多开入站端口)请用「手动编辑配置」,它同样有备份、校验、还原兜底。 -它不用 `GEOIP`,而是生成两条 `.mrs` 规则集: +## Sub-Store(可选) -```yaml -rules: - - RULE-SET,cn-domain,PROXY # 域名维度 - - RULE-SET,cn-ip,PROXY,no-resolve # IP 维度兜底 - - MATCH,DIRECT -``` +[Sub-Store](https://github.com/sub-store-org/Sub-Store) 是订阅聚合器:把多个机场和自建节点合成一条订阅地址,带 Web 界面,**能看到筛选后实际剩下哪些节点**。菜单第 9 项安装,它是独立服务,**不进一键安装,也不是用 mihomo 的前提**。 -**为什么必须有域名那份**:很多国内站挂着境外 CDN,域名解析出来的 IP 不在国内段,只靠 `GEOIP` 会整片漏掉。域名列表才是主力,IP 那条只兜住「直接连 IP、没有域名」的连接。 +装完的用法是:在它的界面里加各个机场的订阅和自建节点 → 做一条「组合订阅」→ 复制它的 mihomo 格式输出地址 → 回本脚本「配置管理 → 生成配置」填进去。 -`.mrs` 是二进制规则集,只下载你要用的那一两个列表,不用拖整包 `geosite.dat`。下载走 `proxy: DIRECT`,因为你人在境外,直连最快。 +**装法**:原生 Node + systemd,装到 `/opt/sub-store`。脚本**不替你装 Node**——装语言运行时是最容易搞坏服务器的操作,检测不到时它会打印你这个发行版的安装命令,你执行完再回来。另外需要 `unzip`。 -有个限制脚本会当场告诉你:**只有 CN 有成体系的域名列表**。换成别的国家代码就只能按 IP 匹配,该地区挂境外 CDN 的站点会漏掉。 +**更新**(菜单第 2 项)和内核升级用同一套闸门:下载到临时目录 → `node --check` 验后端、`unzip -t` 验前端 → 备份旧的 → 换上 → 起服务 → **起不来自动回滚**。任一步不过都不动现有版本。 -## 多开入站端口 - -一个端口对应一个出站——从 7801 进来的全走香港节点,从 7802 进来的全走日本。菜单「配置管理 → 入站端口」增删,写成 mihomo 的 `listeners`: - -```yaml -listeners: - - { name: in-7801, type: mixed, listen: 0.0.0.0, port: 7801, proxy: HK } - - { name: in-7802, type: mixed, listen: 0.0.0.0, port: 7802 } -``` - -用法就是把代理地址指到不同端口: +**安全上要知道的一件事**:Sub-Store 的后端 API **没有鉴权**,访问地址里那串随机路径就是唯一凭据,谁知道谁就能读走你所有订阅(里面带着机场账号)。所以脚本默认绑 `127.0.0.1`,装完打印现成的 SSH 隧道命令: ```bash -curl -x http://127.0.0.1:7801 https://example.com # 走 HK 组 -curl -x http://127.0.0.1:7802 https://example.com # 按 rules 分流 +ssh -L 3001:127.0.0.1:3001 用户名@你的服务器 +# 然后本机浏览器打开脚本给你的那条 http://127.0.0.1:3001/?api=... 地址 ``` -出站可以选任意一个已有策略组、`DIRECT`,或者「按 rules 分流」。 +要公网直连也可以,但要你明确选,脚本会警告一次。另外注意:把 Sub-Store 生成的订阅链接发给别人时,链接里就含着后端地址。 -**选了具体出站就完全跳过 rules**:这个端口的流量一律走它,包括本该直连的内网以外流量。脚本在你选完之后会提醒一次。想保留分流就选「按 rules」。 - -类型支持 `mixed`(HTTP + SOCKS5 同口)、`http`、`socks`。端口和主代理端口或其它入站冲突会被当场拒绝。 - -## GEO 数据和规则集 - -`GEOIP`、`GEOSITE`、`RULE-SET` 规则都要靠数据文件才能匹配。内核缺文件时会自己去下载,但**下载失败是静默的**。 - -失败的表现是:规则一条都匹配不上,流量全落到最后的 `MATCH`,看起来就像"配置没生效"——你会先去怀疑节点,而不是怀疑一个几天前静默失败的下载。 - -菜单「配置管理 → GEO / 规则集」把这件事变可见:列出配置目录里的 `geoip.dat`、`geosite.dat`、`*.mmdb` 和 `rules/*.mrs`,带大小和上次更新时间。几天没变就是没更新成功。 - -「立即更新」优先走管理接口的 `POST /configs/geo`,**不用重启内核**。管理接口没开、或服务没在运行时,回退到删掉数据文件再重启服务重新下载(动手前会问你)。 - -「自动更新 开/关」写的是 `geo-auto-update` 和 `geo-update-interval: 24`。 - -## 改配置的安全网 - -上面这些改动——换订阅、改端口、加入站、开关自动更新——都不用手写 YAML,也都走同一套保护。 - -每次动手之前自动备份成 `config.yaml.bak-<时间戳>`,改完立刻用内核 `-t` 校验。**校验不过会问你要不要还原**,服务正在运行的话再问一句要不要重启生效。 - -需要手写的复杂配置(DNS、TUN、自定义规则)请用菜单里的「手动编辑配置」,或者用「从 URL 导入完整配置」整份换掉——这两条路同样有备份、校验、还原兜底。 +卸载 mihomo **不会**连带删除 Sub-Store——你的订阅数据在里面。要删就走菜单第 9 项里的卸载。 ## 升级和回滚 @@ -285,6 +248,8 @@ sudo ./mihomo-up.sh install --mirror https://ghfast.top/ | `/usr/local/bin/mihomo-up`(快捷命令) | `MIHOMO_UP_LAUNCHER` | | `/var/lib/mihomo-up`(脚本缓存) | `MIHOMO_UP_CACHE` | | 仓库 raw 地址(快捷命令拉取源) | `MIHOMO_UP_URL` | +| `/opt/sub-store`(Sub-Store 目录) | `SUBSTORE_DIR` | +| `sub-store`(Sub-Store 服务名) | `SUBSTORE_SERVICE` | 内核备份最多保留 3 份(脚本内常量 `KEEP=3`)。配置备份不自动清理,积累多了自己删除 `config.yaml.bak-*`。 diff --git a/mihomo-up.sh b/mihomo-up.sh index 62b2486..4ff7a03 100644 --- a/mihomo-up.sh +++ b/mihomo-up.sh @@ -74,6 +74,7 @@ mihomo-up.sh — 安装 / 配置 / 升级 / 卸载 mihomo 内核 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 连配置和备份一起删除 @@ -187,7 +188,9 @@ current_version() { latest_version() { local v - v="$(curl -fsSL --retry 3 --connect-timeout 10 \ + # --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" @@ -197,13 +200,18 @@ latest_version() { svc_state() { has_systemd || { echo "无 systemd"; return; } if systemctl is-active --quiet "$SERVICE" 2>/dev/null; then echo "running" - elif [[ -f "$UNIT_FILE" ]] || systemctl list-unit-files 2>/dev/null | grep -q "^${SERVICE}\.service"; then echo "stopped" + # 不用 `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; } - systemctl is-enabled "$SERVICE" 2>/dev/null || echo "disabled" + # 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"; } @@ -243,7 +251,7 @@ do_update() { # $1 = 目标版本;只换二进制,不 if [[ "$cur" == "$target" && $FORCE -eq 0 ]]; then ok "已是最新 ($cur),无需操作"; return 0 fi - require_root "替换 $BIN 并重启服务" + (( DRY_RUN )) || require_root "替换 $BIN 并重启服务" if (( DRY_RUN )); then ok "[dry-run] 会下载 $(asset_name "$target") 并安装到 $BIN"; return 0 @@ -271,8 +279,14 @@ do_update() { # $1 = 目标版本;只换二进制,不 [[ "$(svc_state)" == "running" ]] && was_active=1 trap - ERR # 下面自己处理失败并回滚 - (( was_active )) && { info "停止 $SERVICE"; systemctl stop "$SERVICE"; } - install -m 0755 "$STAGED" "$BIN" + (( 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 @@ -305,7 +319,7 @@ do_update() { # $1 = 目标版本;只换二进制,不 do_rollback() { local b; b="$(ls -1t "$BACKUP_DIR"/mihomo-* 2>/dev/null | sed -n '1p' || true)" [[ -n "$b" ]] || { warn "没有可回滚的备份(备份目录 $BACKUP_DIR 是空的)"; return 1; } - require_root "替换 $BIN" + (( DRY_RUN )) || require_root "替换 $BIN" info "回滚到 $(basename "$b")" (( DRY_RUN )) && { ok "[dry-run] 到此为止"; return 0; } systemctl stop "$SERVICE" 2>/dev/null || true @@ -353,7 +367,7 @@ EOF install_unit() { # 写入或刷新 systemd 服务文件 has_systemd || { warn "这台机器没有 systemd,跳过服务安装(内核仍可手动运行: $BIN -d $CONF_DIR)"; return 0; } - require_root "写入 $UNIT_FILE" + (( DRY_RUN )) || require_root "写入 $UNIT_FILE" if (( DRY_RUN )); then ok "[dry-run] 会写入 $UNIT_FILE"; return 0; fi @@ -447,7 +461,7 @@ seed_cache() { # 先放一份缓存,这样安装完成后 } install_launcher() { - require_root "写入 $LAUNCHER" + (( DRY_RUN )) || require_root "写入 $LAUNCHER" if (( DRY_RUN )); then ok "[dry-run] 会写入快捷命令 $LAUNCHER"; return 0; fi local tmp; tmp="$(mktemp)" @@ -459,7 +473,7 @@ install_launcher() { } # ───────────────────────── 配置 ───────────────────────── -# 说明:下面这组函数只改 config.yaml 顶层的简单键(形如 `mixed-port: 7890` 这种 +# 说明:这组函数只改 config.yaml 顶层的简单键(形如 `mixed-port: 7890` 这种 # 顶格写、值在同一行的),改之前一定先备份、改完一定校验,失败就还原。 # 缩进层级里的东西(比如 dns、proxy-groups)不碰,那种请用「编辑配置」手改。 @@ -467,8 +481,12 @@ 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" + # 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 <备份路径> @@ -490,14 +508,22 @@ cfg_get() { # cfg_get <顶层键> -> 值(拿不到就 } 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 + 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 @@ -516,7 +542,10 @@ 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 已重启"; } + confirm_default_yes " 重启 $SERVICE 让新配置生效?" && { + systemctl restart "$SERVICE" && ok "$SERVICE 已重启" \ + || warn "重启失败,配置写进去了但没生效: journalctl -u $SERVICE -n 50" + } fi return 0 fi @@ -526,23 +555,6 @@ cfg_apply() { # cfg_apply <备份路径> — 校验并按 return 1 } -# 订阅里的节点名千奇百怪,用一串关键词把常见写法都罩住。(?i) 是忽略大小写。 -region_filter() { - case "$1" in - HK) printf '(?i)香港|hk|hong ?kong|🇭🇰' ;; - TW) printf '(?i)台湾|台灣|tw|taiwan|🇹🇼' ;; - JP) printf '(?i)日本|jp|japan|东京|大阪|🇯🇵' ;; - SG) printf '(?i)新加坡|sg|singapore|狮城|獅城|🇸🇬' ;; - US) printf '(?i)美国|美國|us|united ?states|usa|🇺🇸' ;; - KR) printf '(?i)韩国|韓國|kr|korea|首尔|首爾|🇰🇷' ;; - UK|GB) printf '(?i)英国|英國|uk|united ?kingdom|london|🇬🇧' ;; - DE) printf '(?i)德国|德國|de|germany|🇩🇪' ;; - FR) printf '(?i)法国|法國|fr|france|🇫🇷' ;; - *) return 1 ;; - esac -} -REGIONS_SUPPORTED="HK TW JP SG US KR UK DE FR" - cc_normalize() { # 两位国家代码,转大写;不合格式就返回空并说明 local cc; cc="$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]')" if [[ "$cc" =~ ^[A-Z]{2}$ ]]; then @@ -557,91 +569,68 @@ cc_normalize() { # 两位国家代码,转大写;不合格 # 只下你真正要用的那几个列表。走 gh_url 所以 --mirror 对它同样生效。 mrs_url() { gh_url "MetaCubeX/meta-rules-dat/raw/meta/geo/$1/$2.mrs"; } -cfg_group_names() { # 配置里现有的策略组名(入站选出站时用) - local f; f="$(conf_file)" - [[ -f "$f" ]] || return 0 - # 组名后面常跟着行内注释,先切到第一个 # 为止,再去掉尾部空格 - sed -n -E 's/^ - name:[[:space:]]*([^#]*).*$/\1/p' "$f" | sed -E 's/[[:space:]]+$//' -} - cfg_wizard() { # 生成一份全新的 config.yaml + require_root "写入 $(conf_file)" local f; f="$(conf_file)" mkdir -p "$CONF_DIR" log "" log "${W} 配置向导${N} ${D}— 回车用方括号里的默认值${N}" - log "${D} 生成的是一份最小可用配置:一个订阅、一组选择/自动测速分组、内网直连其余走代理。${N}" + log "${D} 生成一份最小可用配置:一个订阅、PROXY/AUTO 两个分组、内网直连。${N}" + log "${D} 订阅的聚合、改名、筛选交给 Sub-Store 之类的工具做,这里只认一条最终地址。${N}" log "" - local port lan ctrl_mode ctrl secret ui rules_mode - local old_list old_port old_ctrl old_secret - old_list="$(cfg_sub_list || true)"; old_port="$(cfg_get 'mixed-port' || true)" + 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 -a subs=() - if [[ -n "$old_list" ]]; then - log " ${D}现有订阅(回车=沿用,输入新地址=覆盖):${N}" - while IFS=$'\t' read -r _i _n _u; do [[ -n "$_u" ]] && log " $_i. $_u"; done <<<"$old_list" - fi - local n=1 s dflt tries=0 + local tries=0 while :; do - dflt="$(sed -n "${n}p" <<<"$old_list" | cut -f3)" - if (( n == 1 )); then - ask_default " 订阅 #1(机场给的 http(s) 地址)" "$dflt" s - if [[ -z "$s" ]]; then - (( ++tries >= 3 )) && { warn " 一直没拿到订阅链接,向导先退出了,配置没有改动"; return 1; } - warn " 至少要有一个订阅,一个节点都没有 mihomo 启动不了" - continue - fi - else - ask_default " 订阅 #${n}(有别的机场就继续加,回车=到此为止)" "$dflt" s - [[ -z "$s" ]] && break + ask_default " 订阅地址(机场给的,或 Sub-Store 输出的那条)" "${old_sub:-}" sub + if [[ -z "$sub" ]]; then + (( ++tries >= 3 )) && { warn " 一直没拿到订阅地址,向导先退出了,配置没有改动"; return 1; } + warn " 至少要有一条订阅,一个节点都没有 mihomo 启动不了" + continue fi - subs+=("$s"); n=$((n + 1)) - (( n > 8 )) && { info " 最多 8 个订阅,够用了"; break; } + if [[ "$sub" == *'"'* || "$sub" == *'\'* ]]; then + warn " 地址里不能有引号或反斜杠,那会破坏 YAML 解析。请换一条" + continue + fi + break done - (( ${#subs[@]} > 1 )) && ok " 共 ${#subs[@]} 个订阅,节点会合并到同一组里" ask_default " 本地代理端口(HTTP 和 SOCKS5 共用)" "${old_port:-7890}" port - [[ "$port" =~ ^[0-9]+$ ]] || die "端口要是数字,收到的是: $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) local cport; ask_default " 管理接口端口" "9090" cport; ctrl="0.0.0.0:${cport}" ;; + 2) ask_default " 管理接口端口" "9090" cport; ctrl="0.0.0.0:${cport}" ;; 3) ctrl="" ;; - *) local cport; ask_default " 管理接口端口" "9090" cport; ctrl="127.0.0.1:${cport}" ;; + *) 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 " ${D}支持 ${REGIONS_SUPPORTED},留空=不拆,所有节点放一组。${N}" - local regions_in; ask_default " 拆哪些地区(空格分隔)" "" regions_in - local -a rlist=(); local r - for r in $regions_in; do - r="$(printf '%s' "$r" | tr '[:lower:]' '[:upper:]')" - if region_filter "$r" >/dev/null 2>&1; then - rlist+=("$r") - else - warn " 不认识地区代码 «$r»,跳过它(支持: ${REGIONS_SUPPORTED})" - fi - done - (( ${#rlist[@]} )) && warn " 提醒: 某个地区一个节点都没匹配上的话,内核会拒绝启动。下面的校验会告诉你" - - # ── 分流规则 ── log "" log " ${D}分流规则。三种模式下内网地址都是直连。${N}" log " ${C}1${N} 全部走代理" @@ -675,6 +664,8 @@ ipv6: false unified-delay: true # 延迟测试扣掉握手耗时,数字更贴近真实体感 tcp-concurrent: true # 同一域名的多个 IP 一起连,谁先通用谁 find-process-mode: 'off' # 不做进程匹配,省一点开销(加引号防止被当成布尔值) +geo-auto-update: true # GEO 数据库自动更新,省得下载失败后一直用着旧的 +geo-update-interval: 24 # 小时 profile: store-selected: true # 记住你在面板里选的节点,重启不丢 @@ -700,35 +691,32 @@ geox-url: EOF fi - # 订阅:每个机场一个 provider,行尾的标记是脚本回头找它的依据 - local i use_list="" - printf '\nproxy-providers:\n' - for ((i = 1; i <= ${#subs[@]}; i++)); do - use_list+="${use_list:+, }sub${i}" - printf ' sub%d:\n' "$i" - printf ' type: http\n' - printf ' url: "%s" # mihomo-up:sub:sub%d\n' "${subs[i-1]}" "$i" - printf ' path: ./providers/sub%d.yaml\n' "$i" - printf ' interval: 86400 # 每 24 小时自动拉取一次订阅\n' - printf ' health-check:\n enable: true\n url: %s\n interval: 300\n' "$HEALTH_URL" - done + # 订阅:行尾那个标记是脚本下次回来找它的依据 + cat < "$tmp" + } > "$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)" + bak="$(cfg_backup)" || { rm -f "$tmp"; return 1; } [[ -n "$bak" ]] && info "旧配置已备份 → $bak" fi - install -m 0644 "$tmp" "$f"; rm -f "$tmp" - ok "配置已写入 $f" + install -m 0640 "$tmp" "$f" || { rm -f "$tmp"; warn "写入 $f 失败"; return 1; } + rm -f "$tmp" + ok "配置已写入 $f ${D}(权限 0640,密钥在里面,不让其它用户读)${N}" - cfg_apply "$bak" || warn "配置写进去了但校验没过,可以用「编辑配置」改,或重新运行一遍向导" + cfg_apply "$bak" || warn "配置写进去了但校验没过,可以用「手动编辑配置」改,或重新运行一遍向导" } -# 订阅行由本脚本写入,行尾带 "# mihomo-up:sub:" 标记。 -# 认这个标记而不是猜 YAML 结构,所以手写配置里别处的 url: 不会被误伤。 -cfg_sub_list() { # 每行输出「序号 provider名 订阅地址」 +# 订阅行由本脚本写入,行尾带 "# mihomo-up:sub" 标记。认标记而不是猜 YAML 结构, +# 所以手写配置里别处的 url: 不会被误伤。 +# 用 awk 找「最后一个」标记位置来切,这样订阅地址里本身含 # 也不会被截断。 +cfg_sub_url() { local f; f="$(conf_file)" [[ -f "$f" ]] || return 0 - sed -n -E 's|^[[:space:]]*url:[[:space:]]*"?([^"#]*)"?[[:space:]]*#[[:space:]]*mihomo-up:sub:?([A-Za-z0-9_-]*).*|\2\t\1|p' "$f" \ - | sed -E 's/\t[[:space:]]*/\t/; s/[[:space:]]+$//' \ - | awk -F'\t' '{ printf "%d\t%s\t%s\n", NR, ($1 == "" ? "subs" : $1), $2 }' -} - -cfg_set_sub() { # cfg_set_sub <序号> <新地址> - local idx="$1" new="$2" f tmp; f="$(conf_file)"; tmp="$(mktemp)" - # 整行重建(缩进 + 新地址 + 原样保留行尾标记),比在原行上做替换更不容易出错 - awk -v idx="$idx" -v url="$new" ' - ($0 ~ /url:/ && $0 ~ /mihomo-up:sub/) { - n++ - if (n == idx) { - indent = $0; sub(/[^ ].*$/, "", indent) - tag = $0; sub(/^.*#/, "#", tag) - print indent "url: \"" url "\" " tag - next + 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 } - { print } - ' "$f" > "$tmp" && cat "$tmp" > "$f" - rm -f "$tmp" -} - -cfg_sub_menu() { - cfg_require || return 1 - local f list; f="$(conf_file)" - list="$(cfg_sub_list)" - if [[ -z "$list" ]]; then - warn "在 $f 里没找到本脚本写的订阅行(标记是行尾的 # mihomo-up:sub:...)" - warn "这份配置多半是手写或外部导入的。改订阅请用「手动编辑配置」,或重新运行向导生成一份新的" - return 1 - fi - - log "" - log " ${D}当前订阅:${N}" - local i nm u - while IFS=$'\t' read -r i nm u; do - [[ -n "$u" ]] && printf ' %s%s.%s %s %s(%s)%s\n' "$C" "$i" "$N" "$u" "$D" "$nm" "$N" >&2 - done <<<"$list" - log " ${D}增加或删除订阅要重新生成配置,向导会预先填好现有这几条。${N}" - - local total; total="$(grep -c . <<<"$list")" - local pick="1" - (( total > 1 )) && ask_default " 改哪一条(序号)" "1" pick - if [[ ! "$pick" =~ ^[0-9]+$ ]] || (( pick < 1 || pick > total )); then - warn "序号要在 1 到 $total 之间,收到的是「$pick」"; return 1 - fi - - nm="$(sed -n "${pick}p" <<<"$list" | cut -f2)" - local cur; cur="$(sed -n "${pick}p" <<<"$list" | cut -f3)" - local new; ask_default " 第 $pick 条的新地址" "$cur" new - [[ -n "$new" && "$new" != "$cur" ]] || { info "没有改动"; return 0; } - - local bak; bak="$(cfg_backup)" - cfg_set_sub "$pick" "$new" - ok "第 $pick 条订阅已更新" - # 旧节点缓存留着的话内核会继续用它,删掉才会去拉取新地址 - rm -f "$CONF_DIR/providers/${nm}.yaml" 2>/dev/null || true - cfg_apply "$bak" + ' "$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 "端口要是数字"; return 1; } + [[ "$new" =~ ^[0-9]+$ ]] || { warn "端口要是数字,收到的是「$new」"; return 1; } [[ "$new" == "$cur" ]] && { info "没有改动"; return 0; } - local bak; bak="$(cfg_backup)" - cfg_set 'mixed-port' "$new" + local bak; bak="$(cfg_backup)" || return 1 + cfg_set 'mixed-port' "$new" || { cfg_restore "$bak"; return 1; } ok "代理端口 → $new" cfg_apply "$bak" } -cfg_toggle_lan() { +show_panel_info() { # 只读: 面板在哪、密钥是什么、怎么从本机之外打开 cfg_require || return 1 - local cur; cur="$(cfg_get 'allow-lan')" - local new="true"; [[ "$cur" == "true" ]] && new="false" - local bak; bak="$(cfg_backup)" - cfg_set 'allow-lan' "$new" - if [[ "$new" == "true" ]]; then - ok "已允许局域网设备连本机代理。还要在防火墙放行 $(cfg_get 'mixed-port') 端口,否则外面照样连不进来" - else - ok "已改回只允许本机使用" + 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 - cfg_apply "$bak" -} -cfg_change_ctrl() { - cfg_require || return 1 - local cur_ctrl cur_secret - cur_ctrl="$(cfg_get 'external-controller')"; cur_secret="$(cfg_get 'secret')" - log " ${D}当前: ${cur_ctrl:-未开启} 密钥: ${cur_secret:-无}${N}" - local mode; ask_default " 管理接口: 1=只允许本机 2=局域网/公网可访问 3=不开启" "1" mode - local bak; bak="$(cfg_backup)" - case "$mode" in - 3) cfg_set 'external-controller' '""'; ok "已关闭管理接口(面板也就连不上了)" ;; - 2) local p; ask_default " 端口" "9090" p - cfg_set 'external-controller' "0.0.0.0:${p}" - local s; ask_default " 访问密钥(对外暴露必须设)" "${cur_secret:-$(gen_secret)}" s - cfg_set 'secret' "\"$s\"" - ok "管理接口 → 0.0.0.0:${p},密钥 ${s}" - warn "0.0.0.0 意味着任何能连到这台机器的人都能访问它,请用防火墙只放行你信任的来源" ;; - *) local p; ask_default " 端口" "9090" p - cfg_set 'external-controller' "127.0.0.1:${p}" - local s; ask_default " 访问密钥" "${cur_secret:-$(gen_secret)}" s - cfg_set 'secret' "\"$s\"" - ok "管理接口 → 127.0.0.1:${p}(只有本机能连)" ;; - esac - cfg_apply "$bak" -} + 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 -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" + 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:-}" @@ -926,252 +862,282 @@ cfg_edit() { cfg_apply "$bak" } -cfg_backup_menu() { - local f; f="$(conf_file)" +# ───────────────────────── 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}" - 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 "" + 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 } -# ───────────────────────── 入站端口 ───────────────────────── -# 一个端口对应一个出站:从 7801 进来的全走香港,从 7802 进来的全走日本。 -# 每条入站写成一行 YAML 流式映射并带标记,加/删就是加一行/删一行,不动别处。 -LISTENER_TAG="# mihomo-up:listener" - -cfg_listener_list() { # 每行输出「序号 原文件行号 整行内容」 - local f; f="$(conf_file)" - [[ -f "$f" ]] || return 0 - # 末尾的 || true 不能省: 一条入站都没有时 grep 退出码是 1,配上 pipefail - # 会让整个函数返回失败,进而触发 ERR trap —— 但"没有入站"是正常状态,不是错误 - grep -n "$LISTENER_TAG" "$f" 2>/dev/null \ - | sed -E 's/^([0-9]+):[[:space:]]*/\1\t/' \ - | awk -F'\t' '{ printf "%d\t%s\t%s\n", NR, $1, $2 }' || true +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" } -cfg_listener_show() { - local list; list="$(cfg_listener_list)" - if [[ -z "$list" ]]; then - log " ${D}还没有额外的入站端口。主代理端口 $(cfg_get 'mixed-port') 照常走 rules。${N}" - return 0 - fi - log " ${D}额外的入站端口:${N}" - local i _ln body - while IFS=$'\t' read -r i _ln body; do - [[ -n "$body" ]] || continue - local nm pt px - nm="$(sed -E 's/.*name:[[:space:]]*([^,}]*).*/\1/' <<<"$body")" - pt="$(sed -E 's/.*port:[[:space:]]*([^,}]*).*/\1/' <<<"$body")" - px="$(grep -o 'proxy:[^,}]*' <<<"$body" | sed -E 's/proxy:[[:space:]]*//' || true)" - printf ' %s%s.%s 端口 %-6s → %s %s(%s)%s\n' \ - "$C" "$i" "$N" "$pt" "${px:-按 rules 分流}" "$D" "$nm" "$N" >&2 - done <<<"$list" +ss_get_env() { # 从已装好的 unit 里读回某个环境变量的值 + [[ -f "$SS_UNIT" ]] || return 0 + sed -n -E "s/^Environment=\"$1=(.*)\"$/\1/p" "$SS_UNIT" | sed -n '1p' } -cfg_listener_add() { - cfg_require || return 1 - local port name type out - ask_default " 新入站的端口" "7801" port +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; } - if grep -q "port: ${port}," "$(conf_file)" 2>/dev/null; then - warn "端口 $port 已经被另一条入站占了"; return 1 - fi - [[ "$port" == "$(cfg_get 'mixed-port')" ]] && { warn "端口 $port 就是主代理端口,换一个"; return 1; } - - ask_default " 入站类型(mixed=HTTP+SOCKS5 / http / socks)" "mixed" type - case "$type" in mixed|http|socks) ;; *) warn "类型只能是 mixed / http / socks"; return 1 ;; esac log "" - log " ${D}这个端口的流量走哪个出站:${N}" - local -a groups=(); local g - while IFS= read -r g; do [[ -n "$g" ]] && groups+=("$g"); done < <(cfg_group_names) - local idx=1 - printf ' %s0.%s 按 rules 分流 %s(和主端口一样)%s\n' "$C" "$N" "$D" "$N" >&2 - for g in ${groups[@]+"${groups[@]}"}; do - printf ' %s%s.%s %s\n' "$C" "$idx" "$N" "$g" >&2; idx=$((idx + 1)) - done - printf ' %s%s.%s DIRECT %s(不走代理)%s\n' "$C" "$idx" "$N" "$D" "$N" >&2 - - local pick; ask_default " 选一个" "0" pick - [[ "$pick" =~ ^[0-9]+$ ]] || { warn "要填序号"; return 1; } - if (( pick == 0 )); then out="" - elif (( pick == idx )); then out="DIRECT" - elif (( pick >= 1 && pick < idx )); then out="${groups[pick-1]}" - else warn "没有这个序号"; return 1; fi - - name="in-${port}" - local line=" - { name: ${name}, type: ${type}, listen: 0.0.0.0, port: ${port}" - [[ -n "$out" ]] && line+=", proxy: ${out}" - line+=" } ${LISTENER_TAG}" - - local bak; bak="$(cfg_backup)" - local f tmp; f="$(conf_file)"; tmp="$(mktemp)" - if grep -q '^listeners:' "$f"; then - awk -v l="$line" '{ print } /^listeners:[[:space:]]*$/ && !d { print l; d=1 }' "$f" > "$tmp" + log " ${D}Sub-Store 的后端 API 没有鉴权,地址里那串随机路径就是唯一凭据。${N}" + if confirm " 要让局域网/公网直接访问吗?(不建议,默认只绑本机 + SSH 隧道)"; then + host="0.0.0.0" else - { cat "$f"; printf '\nlisteners:\n%s\n' "$line"; } > "$tmp" + host="127.0.0.1" fi - cat "$tmp" > "$f"; rm -f "$tmp" - if [[ -n "$out" ]]; then - ok "入站 $port → $out" - warn "注意: 指定了出站就完全跳过 rules,从这个端口进来的流量一律走 $out" + 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 - ok "入站 $port 已添加,按 rules 分流" + warn "Sub-Store 没起来: journalctl -u $SS_SERVICE -n 50 --no-pager" + return 1 fi - cfg_apply "$bak" } -cfg_listener_del() { - cfg_require || return 1 - local list; list="$(cfg_listener_list)" - [[ -n "$list" ]] || { warn "没有可删除的额外入站端口"; return 1; } - cfg_listener_show - local total; total="$(grep -c . <<<"$list")" - local pick; ask_default " 删除哪一条(序号)" "" pick - if [[ ! "$pick" =~ ^[0-9]+$ ]] || (( pick < 1 || pick > total )); then - warn "序号要在 1 到 $total 之间,收到的是「$pick」"; return 1 +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 bak; bak="$(cfg_backup)" - local f tmp; f="$(conf_file)"; tmp="$(mktemp)" - awk -v idx="$pick" -v tag="$LISTENER_TAG" ' - index($0, tag) { n++; if (n == idx) next } - { print } - ' "$f" > "$tmp" - # 删光了就把空的 listeners: 标题也去掉,留着它是个没有值的键 - if ! grep -q "$LISTENER_TAG" "$tmp"; then - awk '!/^listeners:[[:space:]]*$/' "$tmp" > "${tmp}.2" && mv "${tmp}.2" "$tmp" + # 备份旧的,装新的,起不来就换回去 —— 和内核升级同一套路 + 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 - cat "$tmp" > "$f"; rm -f "$tmp" - ok "第 $pick 条入站已删除" - cfg_apply "$bak" } -cfg_listener_menu() { +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 - log "" - log "${W} 入站端口${N} ${D}— 多开几个本地端口,各自绑不同出站${N}" - cfg_listener_show + 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) cfg_listener_add || true; pause ;; - 2) cfg_listener_del || true; pause ;; + 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 } -# ───────────────────────── GEO / 规则集 ───────────────────────── -# 这些数据文件决定 GEOIP / GEOSITE / RULE-SET 规则能不能匹配上。 -# 内核缺文件时会自己去下载,但下载失败是静默的:规则一条都匹配不上, -# 流量全落到最后的 MATCH,看起来就像"配置没生效"。所以这里给出可见的状态。 -ctrl_api() { # ctrl_api <方法> <路径> [数据];管理接口没开就返回 1 - local ctrl secret host port - ctrl="$(cfg_get 'external-controller')"; secret="$(cfg_get 'secret')" - [[ -n "$ctrl" ]] || return 1 - host="${ctrl%:*}"; port="${ctrl##*:}" - [[ -z "$host" || "$host" == "0.0.0.0" || "$host" == "[::]" ]] && host="127.0.0.1" - local -a args=(-fsS -m 30 -X "$1") - [[ -n "$secret" ]] && args+=(-H "Authorization: Bearer $secret") - [[ -n "${3:-}" ]] && args+=(-H "Content-Type: application/json" -d "$3") - curl "${args[@]}" "http://${host}:${port}$2" -} +ss_svc_menu() { + cat >&2 </dev/null | cut -f1)" "$D" "$(date -r "$p" '+%F %T')" "$N" >&2 - done - if (( found == 0 )); then - log " ${Y}一个都没有${N}" - log " ${D}配置里没用到 GEOIP/GEOSITE/RULE-SET 的话,这是正常的。${N}" - log " ${D}用到了却没有文件,说明内核还没下载成功,规则会全部匹配不上。${N}" - fi - local au; au="$(cfg_get 'geo-auto-update')" - log " ${D}自动更新 geo-auto-update: ${au:-未设置(默认关)}${N}" -} - -geo_refresh() { - cfg_require || return 1 - info "让内核重新下载 GEO 数据库…" - if ctrl_api POST /configs/geo '{"path":"","payload":""}' >/dev/null 2>&1; then - ok "已通过管理接口触发更新,不用重启服务" - return 0 - fi - if ctrl_api POST /upgrade/geo '{"path":"","payload":""}' >/dev/null 2>&1; then - ok "已通过管理接口触发更新,不用重启服务" - return 0 - fi - warn "管理接口没响应(没开启,或者服务没在运行),改用删文件 + 重启的办法" - confirm_default_yes " 删掉现有的 GEO 文件和 .mrs 规则集,然后重启服务重新下载?" || { info "已取消"; return 0; } - rm -f "$CONF_DIR"/geoip.dat "$CONF_DIR"/geosite.dat "$CONF_DIR"/geoip.metadb \ - "$CONF_DIR"/country.mmdb "$CONF_DIR"/ASN.mmdb 2>/dev/null || true - rm -f "$CONF_DIR"/rules/*.mrs 2>/dev/null || true - ok "已删除,内核下次启动会重新下载" - if [[ "$(svc_state)" == "running" ]]; then - systemctl restart "$SERVICE" && ok "$SERVICE 已重启" || warn "重启失败,看日志: journalctl -u $SERVICE -n 50" - fi -} - -geo_toggle_autoupdate() { - cfg_require || return 1 - local cur; cur="$(cfg_get 'geo-auto-update')" - local bak; bak="$(cfg_backup)" - if [[ "$cur" == "true" ]]; then - cfg_set 'geo-auto-update' 'false' - ok "已关闭自动更新,GEO 数据库只在缺文件时下载一次" - else - cfg_set 'geo-auto-update' 'true' - [[ -n "$(cfg_get 'geo-update-interval')" ]] || cfg_set 'geo-update-interval' '24' - ok "已打开自动更新,每 $(cfg_get 'geo-update-interval') 小时刷新一次" - fi - cfg_apply "$bak" -} - -geo_menu() { - while true; do - log "" - log "${W} GEO / 规则集${N} ${D}— GEOIP、GEOSITE、RULE-SET 规则靠这些数据文件匹配${N}" - geo_show - cat >&2 <%s ' "$W" "$N")" c - echo >&2 - case "$c" in - 1) geo_refresh || true; pause ;; - 2) geo_toggle_autoupdate || true; pause ;; - 0|q|"") return 0 ;; - *) warn "没有「$c」这个选项,输入上面列出的数字,或 0 返回"; sleep 1 ;; - esac - done + local c; ask "$(printf '\n %s>%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 } # ───────────────────────── 一键安装 ───────────────────────── @@ -1330,6 +1296,12 @@ do_uninstall() { fi fi + # Sub-Store 是独立服务,不跟着 mihomo 一起删 —— 它是你的订阅数据所在 + if ss_installed; then + log "" + info "Sub-Store 还装着($SS_DIR),它是独立服务,没有跟着删" + info "要一起卸载的话: 菜单第 9 项 → 卸载,或 $0 substore" + fi ok "卸载完成" } @@ -1400,9 +1372,10 @@ ${W} 选个操作吧:${N} ${C}4.${N} 📌 安装指定版本内核 ${C}5.${N} ⏪ 回滚到上一版内核 ${C}6.${N} 📦 查看内核备份 - ${C}7.${N} ⚙️ 配置管理 ${D}(订阅 / 端口 / 入站 / GEO / 面板)${N} + ${C}7.${N} ⚙️ 配置管理 ${D}(订阅 / 端口 / 面板密钥 / 校验)${N} ${C}8.${N} 🎛️ 服务控制 ${D}(启动 / 停止 / 日志 / 开机自启)${N} - ${C}9.${N} 🗑️ 卸载 + ${C}9.${N} 🧩 Sub-Store ${D}(订阅聚合, 可选)${N} + ${C}u.${N} 🗑️ 卸载 mihomo ${C}0.${N} 👋 退出 EOF local c; ask "$(printf '\n %s>%s ' "$W" "$N")" c @@ -1428,7 +1401,8 @@ EOF 6) echo >&2; do_list; pause ;; 7) conf_menu ;; 8) svc_menu ;; - 9) do_uninstall || true; pause ;; + 9) ss_menu ;; + u|U) do_uninstall || true; pause ;; 0|q|"") log " ${D}bye~${N}"; exit 0 ;; *) warn "没有「$c」这个选项,输入上面列出的数字,或 0 退出/返回"; sleep 1 ;; esac @@ -1437,47 +1411,35 @@ EOF conf_menu() { while true; do - local port lan ctrl nsub nlis + local port lan ctrl sub port="$(cfg_get 'mixed-port' || true)" - lan="$(cfg_get 'allow-lan' || true)"; ctrl="$(cfg_get 'external-controller' || true)" - nsub="$(cfg_sub_list 2>/dev/null | grep -c . || true)" - nlis="$(cfg_listener_list 2>/dev/null | grep -c . || true)" + 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_sub_menu || true; pause ;; - 3) cfg_change_port || true; pause ;; - 4) cfg_toggle_lan || true; pause ;; - 5) cfg_change_ctrl || true; pause ;; - 6) cfg_listener_menu ;; - 7) geo_menu ;; - 8) cfg_import_url || true; pause ;; - 9) cfg_validate || true; pause ;; - 10) cfg_edit || true; pause ;; - 11) cfg_backup_menu || true; pause ;; + 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 ;; + *) warn "没有「$c」这个选项,输入上面列出的数字,或 0 返回"; sleep 1 ;; esac done } @@ -1517,7 +1479,7 @@ if [[ $# -eq 0 ]]; then if [[ -t 0 ]] || { [[ -t 1 ]] && [[ -r /dev/tty ]]; }; then CMD="menu"; else CMD="check"; fi else case "$1" in - check|update|install|uninstall|config|service|shortcut|rollback|list|menu) CMD="$1"; shift ;; + 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" ;; @@ -1561,6 +1523,7 @@ case "$CMD" in 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)