Files
mihomo-up/mihomo-up.sh
T
im0o c9b410c65e refactor: 砍掉非刚需功能,修若干静默失败,新增 Sub-Store 模块
订阅的聚合、改名、筛选交给 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 确认存在。
2026-08-09 22:22:18 +08:00

1543 lines
65 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <v> 指定内核版本,如 v1.19.28
--no-restart 安装完成后不启动服务
--purge uninstall 时连配置目录和备份一起删除
--skip-conf-check 升级时不用新版本预校验现有配置(配置本来就有问题时用)
--json check 时额外输出一行 JSON
--arch <a> amd64 | arm64 | armv7
--variant <v> amd64 微架构: v1 | v2 | v3 | compatible
--mirror <url> GitHub 下载前缀,如 https://ghfast.top/
--script-url <u> 快捷命令每次去拉的脚本地址(fork 之后指到你自己那份)
--bin <path> 内核路径,默认 /usr/local/bin/mihomo
--conf-dir <dir> 配置目录,默认 /etc/mihomo
--service <name> 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" <<EOF
[Unit]
Description=mihomo Daemon, Another Clash Kernel.
Documentation=https://wiki.metacubex.one
After=network-online.target nss-lookup.target
Wants=network-online.target
[Service]
Type=simple
LimitNPROC=500
LimitNOFILE=1000000
# 这些 capability 让 mihomo 不用完整 root 也能开 TUN、绑低端口、读系统时间
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_TIME CAP_SYS_PTRACE CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_TIME CAP_SYS_PTRACE CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE
Restart=always
RestartSec=3
ExecStart=${BIN} -d ${CONF_DIR}
ExecReload=/bin/kill -HUP \$MAINPID
[Install]
WantedBy=multi-user.target
EOF
}
install_unit() { # 写入或刷新 systemd 服务文件
has_systemd || { warn "这台机器没有 systemd,跳过服务安装(内核仍可手动运行: $BIN -d $CONF_DIR)"; return 0; }
(( DRY_RUN )) || require_root "写入 $UNIT_FILE"
if (( DRY_RUN )); then ok "[dry-run] 会写入 $UNIT_FILE"; return 0; fi
local tmp; tmp="$(mktemp)"
write_unit "$tmp"
if [[ -f "$UNIT_FILE" ]] && cmp -s "$tmp" "$UNIT_FILE"; then
rm -f "$tmp"; info "服务文件已是最新,没改动"
else
if [[ -f "$UNIT_FILE" ]]; then
local bak="${UNIT_FILE}.bak-$(date +%Y%m%d%H%M%S)"
cp -a "$UNIT_FILE" "$bak"; info "旧的服务文件已备份 → $bak"
fi
install -m 0644 "$tmp" "$UNIT_FILE"; rm -f "$tmp"
ok "已写入 $UNIT_FILE"
fi
systemctl daemon-reload
}
start_service() {
has_systemd || return 0
systemctl enable "$SERVICE" >/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 <<EOF
# mihomo 配置 — 由 mihomo-up.sh v${SELF_VERSION} 生成于 $(date '+%F %T')
# 端口、订阅这类常用项可以回脚本菜单的「配置管理」里改;手改也行,改完记得校验。
mixed-port: ${port} # HTTP 和 SOCKS5 共用这一个端口
allow-lan: ${lan} # true = 同局域网的其它设备也能连这个代理
mode: rule
log-level: info
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 # 记住你在面板里选的节点,重启不丢
EOF
if [[ -n "$ctrl" ]]; then
printf '\nexternal-controller: %s\n' "$ctrl"
printf 'secret: "%s"\n' "$secret"
if [[ "$ui" == "yes" ]]; then
printf 'external-ui: ui\n'
printf 'external-ui-url: "%s"\n' "$UI_ZIP"
fi
fi
if [[ -n "$MIRROR" ]]; then
cat <<EOF
# GeoIP/GeoSite 数据库走镜像下载(因为你指定了 --mirror)
geox-url:
geoip: "${MIRROR%/}/https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.dat"
geosite: "${MIRROR%/}/https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat"
mmdb: "${MIRROR%/}/https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country.mmdb"
EOF
fi
# 订阅:行尾那个标记是脚本下次回来找它的依据
cat <<EOF
proxy-providers:
subs:
type: http
url: "${sub}" # mihomo-up:sub
path: ./providers/subs.yaml
interval: 86400 # 每 24 小时自动拉取一次订阅
health-check:
enable: true
url: ${HEALTH_URL}
interval: 300
proxy-groups:
- name: PROXY # 总开关,手动选;默认跟着 AUTO 走
type: select
proxies: [AUTO, DIRECT]
use: [subs]
- name: AUTO # 全部节点里自动挑延迟最低的
type: url-test
use: [subs]
url: ${HEALTH_URL}
interval: 300
tolerance: 50
EOF
# 回国模式要按域名和 IP 两个维度匹配,用 .mrs 规则集,只下这一两个列表
local cc_l=""
if [[ -n "$proxy_cc" ]]; then
cc_l="$(printf '%s' "$proxy_cc" | tr '[:upper:]' '[:lower:]')"
printf '\n# 规则集:内核按 interval 自动更新;proxy: DIRECT 指下载规则集本身走直连\n'
printf 'rule-providers:\n'
if [[ "$proxy_cc" == "CN" ]]; then
printf ' %s-domain:\n type: http\n behavior: domain\n format: mrs\n' "$cc_l"
printf ' url: "%s"\n path: ./rules/%s-domain.mrs\n interval: 86400\n proxy: DIRECT\n' \
"$(mrs_url geosite "$cc_l")" "$cc_l"
fi
printf ' %s-ip:\n type: http\n behavior: ipcidr\n format: mrs\n' "$cc_l"
printf ' url: "%s"\n path: ./rules/%s-ip.mrs\n interval: 86400\n proxy: DIRECT\n' \
"$(mrs_url geoip "$cc_l")" "$cc_l"
fi
printf '\nrules:\n'
cat <<'EOF'
# 本机和内网地址一律直连,不然会绕出去再回来
- IP-CIDR,127.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,10.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,172.16.0.0/12,DIRECT,no-resolve
- IP-CIDR,192.168.0.0/16,DIRECT,no-resolve
EOF
if [[ -n "$proxy_cc" ]]; then
# 回国:命中的走代理,其余直连。域名规则排在前面,因为挂境外 CDN 的
# 国内站点解析出来的 IP 不在本国段,只靠 GEOIP 会漏。
[[ "$proxy_cc" == "CN" ]] && printf ' - RULE-SET,%s-domain,PROXY\n' "$cc_l"
printf ' - RULE-SET,%s-ip,PROXY,no-resolve\n' "$cc_l"
printf ' - MATCH,DIRECT\n'
elif [[ -n "$direct_cc" ]]; then
printf ' # 目标 IP 属于 %s 就直连。用到 GEOIP,内核首次启动会去下载 GeoIP 数据库\n' "$direct_cc"
printf ' - GEOIP,%s,DIRECT\n' "$direct_cc"
printf ' - MATCH,PROXY\n'
else
printf ' - MATCH,PROXY\n'
fi
} > "$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 >/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" <<EOF
[Unit]
Description=Sub-Store — 订阅聚合与转换
Documentation=https://github.com/sub-store-org/Sub-Store
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
LimitNOFILE=32767
Restart=always
RestartSec=5
# 这串随机路径就是后端 API 的唯一凭据,泄露等于配置被读走
Environment="SUB_STORE_FRONTEND_BACKEND_PATH=${api_path}"
Environment="SUB_STORE_FRONTEND_PATH=${SS_DIR}/frontend"
Environment="SUB_STORE_FRONTEND_HOST=${host}"
Environment="SUB_STORE_FRONTEND_PORT=${port}"
Environment="SUB_STORE_DATA_BASE_PATH=${SS_DIR}"
Environment="SUB_STORE_BACKEND_API_HOST=127.0.0.1"
Environment="SUB_STORE_BACKEND_API_PORT=3000"
Environment="SUB_STORE_BACKEND_CRON=0 0 * * *"
ExecStart=${node} ${SS_DIR}/sub-store.bundle.js
WorkingDirectory=${SS_DIR}
[Install]
WantedBy=multi-user.target
EOF
}
ss_stage() { # 下载并校验两个产物到 $1;任一不过就整体放弃
local dir="$1"
info "下载后端 sub-store.bundle.js"
curl -fL -# --retry 3 --connect-timeout 15 --max-time 300 \
-o "$dir/sub-store.bundle.js" "$(gh_url "$SS_BACKEND_URL")" \
|| { warn "后端下载失败(GitHub 不通?可用 --mirror)"; return 1; }
# 闸门 1: 它是 JS,node --check 能在装上去之前发现半截文件或返回的登录页
node --check "$dir/sub-store.bundle.js" 2>/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 <<EOF
${W} Sub-Store${N} ${D}— 把多个订阅和自建节点聚成一条地址,带 Web UI${N}
${D}状态 ${N}${st} ${D}目录 ${N}${SS_DIR}
${C}1.${N} 📦 安装 / 重新安装
${C}2.${N} ⬆️ 更新到最新版 ${D}(失败自动回滚)${N}
${C}3.${N} 🔑 访问地址和密钥
${C}4.${N} 🎛️ 启动 / 停止 / 日志
${C}5.${N} 🗑️ 卸载
${C}0.${N} ↩️ 返回
EOF
local c; ask "$(printf '\n %s>%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 <<EOF
${C}1.${N} ▶️ 启动 ${C}2.${N} ⏹️ 停止 ${C}3.${N} 🔄 重启
${C}4.${N} 📊 状态 ${C}5.${N} 📜 最近日志 ${C}0.${N} ↩️ 返回
EOF
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
}
# ───────────────────────── 一键安装 ─────────────────────────
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 <<EOF
${C} ╭──────────────────────────────────────────╮
│ ${W}mihomo 内核管理${N}${C} ╭( ・ㅂ・)و ✧ │
╰──────────────────────────────────────────╯${N}
EOF
}
status_panel() {
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_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="${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 <<EOF
${W} 选个操作吧:${N}
${C}1.${N} $first
${C}2.${N} 🔍 检查更新
${C}3.${N} ⬆️ 升级内核到最新版
${C}4.${N} 📌 安装指定版本内核
${C}5.${N} ⏪ 回滚到上一版内核
${C}6.${N} 📦 查看内核备份
${C}7.${N} ⚙️ 配置管理 ${D}(订阅 / 端口 / 面板密钥 / 校验)${N}
${C}8.${N} 🎛️ 服务控制 ${D}(启动 / 停止 / 日志 / 开机自启)${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
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 <<EOF
${W} 配置管理${N} ${D}$(conf_file)${N}
${D}代理端口 ${N}${port:-—} ${D}局域网共享 ${N}${lan:-—} ${D}管理接口 ${N}${ctrl:-未开}
${D}订阅 ${N}${sub:-—}
${C}1.${N} 🧙 生成配置 ${D}(向导, 会覆盖现有配置)${N}
${C}2.${N} 🔌 修改代理端口
${C}3.${N} 🔑 面板地址和密钥 ${D}(只看不改)${N}
${C}4.${N} 🧪 校验配置
${C}5.${N} 📝 手动编辑配置
${C}0.${N} ↩️ 返回
EOF
local c; ask "$(printf '\n %s>%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 <<EOF
${C}1.${N} ▶️ 启动 ${C}2.${N} ⏹️ 停止 ${C}3.${N} 🔄 重启
${C}4.${N} 📊 状态 ${C}5.${N} 📜 最近日志
${C}6.${N} ⏻ 开机自启 开/关 ${C}7.${N} 🧾 重写服务文件
${C}8.${N} 🔗 安装 / 刷新 $(basename "$LAUNCHER") 快捷命令
${C}0.${N} ↩️ 返回
EOF
local c; ask "$(printf '\n %s>%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