订阅的聚合、改名、筛选交给 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 确认存在。
1543 lines
65 KiB
Bash
1543 lines
65 KiB
Bash
#!/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
|