Files
mihomo-up/mihomo-up.sh
T
im0oandClaude Opus 5 e77ef3045a feat: 多订阅 / 地区分组 / 回国模式 / 多入站 / GEO 管理
多订阅:
- 向导的订阅那步改成可以连续填多个 (最多 8 个, 回车结束),每个一条独立的
  proxy-providers,节点合并进同一组
- 订阅行标记升级成 "# mihomo-up:sub:<provider名>",配置菜单里可按序号改任意一条
- 改完顺手删掉对应的节点缓存,否则内核会继续用旧节点

地区分组:
- 支持 HK TW JP SG US KR UK DE FR,每个地区一个 url-test 组,靠节点名关键词
  匹配 (中英文 + 旗帜 emoji)
- 提醒: 某地区匹配不到任何节点会让内核拒绝启动,紧接着的校验会指出来

分流规则从两种变三种:
- 3 = 指定地区走代理、其余直连,也就是回国 / 分区代理
- 回国模式生成 .mrs 规则集而不是 GEOIP: 域名维度打头,IP 维度兜底。
  国内站挂境外 CDN 时解析出的 IP 不在本国段,只靠 GEOIP 会整片漏掉
- 只有 CN 有成体系的域名列表,其它国家代码只能按 IP 匹配,脚本会当场说明后果

多入站 (listeners):
- 新增「入站端口」菜单: 加/删额外的本地端口,每个可绑一个策略组、DIRECT,
  或按 rules 分流
- 每条写成一行 YAML 流式映射并带标记,加删就是加一行删一行,不动别处
- 拦截端口冲突和非法类型;选了具体出站会提醒它完全跳过 rules
- 删到最后一条时连空的 listeners: 标题一起去掉

GEO / 规则集:
- 新增菜单: 列出 geoip.dat / geosite.dat / *.mmdb / rules/*.mrs 的大小和
  更新时间。下载失败是静默的 —— 规则全不匹配、流量落到 MATCH,看起来像
  "配置没生效",所以状态必须可见
- 立即更新优先走管理接口 POST /configs/geo,不重启内核;接口不可用时回退到
  删文件 + 重启
- 自动更新开关写 geo-auto-update / geo-update-interval

验证: 三种规则模式生成的配置都用 PyYAML 解析通过,分组、rule-providers、
listeners 结构和字段类型均符合预期;两个 .mrs 地址实测 200。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpjLPV4JvAsuq9ACb3u6B4
2026-08-09 17:55:59 +08:00

1578 lines
66 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 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
v="$(curl -fsSL --retry 3 --connect-timeout 10 \
"$(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"
elif [[ -f "$UNIT_FILE" ]] || systemctl list-unit-files 2>/dev/null | grep -q "^${SERVICE}\.service"; then echo "stopped"
else echo "未安装"; fi
}
svc_enabled() { # 是否开机自启
has_systemd || { echo "-"; return; }
systemctl is-enabled "$SERVICE" 2>/dev/null || echo "disabled"
}
conf_file() { printf '%s/config.yaml' "$CONF_DIR"; }
# ───────────────────────── 内核二进制 ─────────────────────────
stage_binary() { # $1 = 目标版本;成功后 STAGED 指向校验过的新二进制
local target="$1" asset url
[[ -n "${TMPDIR_:-}" && -d "${TMPDIR_:-}" ]] || TMPDIR_="$(mktemp -d)"
asset="$(asset_name "$target")"
url="$(gh_url "${REPO}/releases/download/${target}/${asset}")"
info "下载 $asset"
curl -fL -# --retry 3 --connect-timeout 15 -o "$TMPDIR_/m.gz" "$url" \
|| die "下载失败: $url(网络不通就加 --mirror 换个下载源,版本号写错也会 404)"
gzip -dc "$TMPDIR_/m.gz" > "$TMPDIR_/mihomo" || die "解压失败,下载到的不是有效的 .gz 文件"
chmod +x "$TMPDIR_/mihomo"
# 闸门 1: 新二进制能运行起来,且自报版本号和目标一致
local line ver
line="$(version_line "$TMPDIR_/mihomo")"
[[ -n "$line" ]] || die "新二进制执行不了,多半是架构选错了(这次按 $(detect_arch) 下载,可用 --arch 覆盖)"
ver="$(awk '{print $3}' <<<"$line")"
[[ "$ver" == "$target" ]] || die "版本对不上: 期望 $target,实际 $ver"
ok "校验通过: $line"
STAGED="$TMPDIR_/mihomo"
}
test_conf_with() { # test_conf_with <二进制路径>;配置无效返回 1,输出错误
local b="$1"
[[ -f "$(conf_file)" ]] || return 0 # 没配置就没什么可校验的
"$b" -t -d "$CONF_DIR" 2>&1
}
do_update() { # $1 = 目标版本;只换二进制,不动配置和服务定义
local target="$1" cur; cur="$(current_version)"
if [[ "$cur" == "$target" && $FORCE -eq 0 ]]; then
ok "已是最新 ($cur),无需操作"; return 0
fi
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"; }
install -m 0755 "$STAGED" "$BIN"
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; }
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; }
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() {
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 -a "$f" "$bak"
printf '%s' "$bak"
}
cfg_restore() { # cfg_restore <备份路径>
[[ -n "${1:-}" && -f "$1" ]] || return 0
cp -a "$1" "$(conf_file)"
warn "已还原到改动前的配置"
}
cfg_require() { # 没有配置文件时,给一句能照着做的提示
[[ -f "$(conf_file)" ]] && return 0
warn "还没有配置文件 $(conf_file),先运行一遍配置向导生成(菜单「配置管理 → 生成配置」,命令行是 $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; f="$(conf_file)"
[[ -f "$f" ]] || die "找不到 $f,先生成配置"
local esc; esc="$(printf '%s' "$val" | sed -e 's/[\\&|]/\\&/g')"
if grep -qE "^${key}:" "$f"; then
sed -i -E "0,/^${key}:.*/s|^${key}:.*|${key}: ${esc}|" "$f"
else
printf '%s: %s\n' "$key" "$val" >> "$f"
fi
}
cfg_validate() { # 校验配置;无效时打印错误并返回 1
local f; f="$(conf_file)"
[[ -f "$f" ]] || { warn "还没有配置文件 $f"; return 1; }
[[ -x "$BIN" ]] || { warn "内核还没安装($BIN 不存在),无法校验,先「一键安装」"; return 1; }
local out
if out="$("$BIN" -t -d "$CONF_DIR" 2>&1)"; then
ok "配置有效"; return 0
else
warn "配置有问题:"; sed 's/^/ /' <<<"$out" >&2; return 1
fi
}
cfg_apply() { # cfg_apply <备份路径> — 校验并按需重启;失败给还原选项
local bak="${1:-}"
if cfg_validate; then
if [[ "$(svc_state)" == "running" ]]; then
confirm_default_yes " 重启 $SERVICE 让新配置生效?" && { systemctl restart "$SERVICE" && ok "$SERVICE 已重启"; }
fi
return 0
fi
if [[ -n "$bak" ]]; then
confirm_default_yes " 配置校验没过,还原到改动前?" && cfg_restore "$bak"
fi
return 1
}
# 订阅里的节点名千奇百怪,用一串关键词把常见写法都罩住。(?i) 是忽略大小写。
region_filter() {
case "$1" in
HK) printf '(?i)香港|hk|hong ?kong|🇭🇰' ;;
TW) printf '(?i)台湾|台灣|tw|taiwan|🇹🇼' ;;
JP) printf '(?i)日本|jp|japan|东京|大阪|🇯🇵' ;;
SG) printf '(?i)新加坡|sg|singapore|狮城|獅城|🇸🇬' ;;
US) printf '(?i)美国|美國|us|united ?states|usa|🇺🇸' ;;
KR) printf '(?i)韩国|韓國|kr|korea|首尔|首爾|🇰🇷' ;;
UK|GB) printf '(?i)英国|英國|uk|united ?kingdom|london|🇬🇧' ;;
DE) printf '(?i)德国|德國|de|germany|🇩🇪' ;;
FR) printf '(?i)法国|法國|fr|france|🇫🇷' ;;
*) return 1 ;;
esac
}
REGIONS_SUPPORTED="HK TW JP SG US KR UK DE FR"
cc_normalize() { # 两位国家代码,转大写;不合格式就返回空并说明
local cc; cc="$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]')"
if [[ "$cc" =~ ^[A-Z]{2}$ ]]; then
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_group_names() { # 配置里现有的策略组名(入站选出站时用)
local f; f="$(conf_file)"
[[ -f "$f" ]] || return 0
# 组名后面常跟着行内注释,先切到第一个 # 为止,再去掉尾部空格
sed -n -E 's/^ - name:[[:space:]]*([^#]*).*$/\1/p' "$f" | sed -E 's/[[:space:]]+$//'
}
cfg_wizard() { # 生成一份全新的 config.yaml
local f; f="$(conf_file)"
mkdir -p "$CONF_DIR"
log ""
log "${W} 配置向导${N} ${D}— 回车用方括号里的默认值${N}"
log "${D} 生成的是一份最小可用配置:一个订阅、一组选择/自动测速分组、内网直连其余走代理。${N}"
log ""
local port lan ctrl_mode ctrl secret ui rules_mode
local old_list old_port old_ctrl old_secret
old_list="$(cfg_sub_list || true)"; old_port="$(cfg_get 'mixed-port' || true)"
old_ctrl="$(cfg_get 'external-controller' || true)"; old_secret="$(cfg_get 'secret' || true)"
# ── 订阅:可以填多个 ──
local -a subs=()
if [[ -n "$old_list" ]]; then
log " ${D}现有订阅(回车=沿用,输入新地址=覆盖):${N}"
while IFS=$'\t' read -r _i _n _u; do [[ -n "$_u" ]] && log " $_i. $_u"; done <<<"$old_list"
fi
local n=1 s dflt tries=0
while :; do
dflt="$(sed -n "${n}p" <<<"$old_list" | cut -f3)"
if (( n == 1 )); then
ask_default " 订阅 #1(机场给的 http(s) 地址)" "$dflt" s
if [[ -z "$s" ]]; then
(( ++tries >= 3 )) && { warn " 一直没拿到订阅链接,向导先退出了,配置没有改动"; return 1; }
warn " 至少要有一个订阅,一个节点都没有 mihomo 启动不了"
continue
fi
else
ask_default " 订阅 #${n}(有别的机场就继续加,回车=到此为止)" "$dflt" s
[[ -z "$s" ]] && break
fi
subs+=("$s"); n=$((n + 1))
(( n > 8 )) && { info " 最多 8 个订阅,够用了"; break; }
done
(( ${#subs[@]} > 1 )) && ok " 共 ${#subs[@]} 个订阅,节点会合并到同一组里"
ask_default " 本地代理端口(HTTP 和 SOCKS5 共用)" "${old_port:-7890}" port
[[ "$port" =~ ^[0-9]+$ ]] || die "端口要是数字,收到的是: $port"
if confirm " 允许同局域网的其它设备用这个代理?"; then lan="true"; else lan="false"; fi
log ""
log " ${D}管理接口用来接 Web 面板和 API。开在 0.0.0.0 等于对外网暴露,务必设密钥。${N}"
ask_default " 管理接口: 1=只允许本机 2=局域网/公网可访问 3=不开启" "1" ctrl_mode
case "$ctrl_mode" in
2) local cport; ask_default " 管理接口端口" "9090" cport; ctrl="0.0.0.0:${cport}" ;;
3) ctrl="" ;;
*) local cport; ask_default " 管理接口端口" "9090" cport; ctrl="127.0.0.1:${cport}" ;;
esac
secret=""
if [[ -n "$ctrl" ]]; then
ask_default " 管理接口密钥(回车=自动生成一个)" "${old_secret:-$(gen_secret)}" secret
if confirm_default_yes " 顺带安装一个 Web 面板(metacubexd,内核启动时自动下载)?"; then ui="yes"; else ui="no"; fi
else
ui="no"
fi
# ── 按地区拆分组 ──
log ""
log " ${D}可以按地区把节点拆成几个组,每组自动挑该地区里延迟最低的那个。${N}"
log " ${D}支持 ${REGIONS_SUPPORTED},留空=不拆,所有节点放一组。${N}"
local regions_in; ask_default " 拆哪些地区(空格分隔)" "" regions_in
local -a rlist=(); local r
for r in $regions_in; do
r="$(printf '%s' "$r" | tr '[:lower:]' '[:upper:]')"
if region_filter "$r" >/dev/null 2>&1; then
rlist+=("$r")
else
warn " 不认识地区代码 «$r»,跳过它(支持: ${REGIONS_SUPPORTED})"
fi
done
(( ${#rlist[@]} )) && warn " 提醒: 某个地区一个节点都没匹配上的话,内核会拒绝启动。下面的校验会告诉你"
# ── 分流规则 ──
log ""
log " ${D}分流规则。三种模式下内网地址都是直连。${N}"
log " ${C}1${N} 全部走代理"
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' # 不做进程匹配,省一点开销(加引号防止被当成布尔值)
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
# 订阅:每个机场一个 provider,行尾的标记是脚本回头找它的依据
local i use_list=""
printf '\nproxy-providers:\n'
for ((i = 1; i <= ${#subs[@]}; i++)); do
use_list+="${use_list:+, }sub${i}"
printf ' sub%d:\n' "$i"
printf ' type: http\n'
printf ' url: "%s" # mihomo-up:sub:sub%d\n' "${subs[i-1]}" "$i"
printf ' path: ./providers/sub%d.yaml\n' "$i"
printf ' interval: 86400 # 每 24 小时自动拉取一次订阅\n'
printf ' health-check:\n enable: true\n url: %s\n interval: 300\n' "$HEALTH_URL"
done
# 分组:PROXY 是总开关,AUTO 自动测速,地区组各管一片
local proxies_list="AUTO"
for r in ${rlist[@]+"${rlist[@]}"}; do proxies_list+=", $r"; done
proxies_list+=", DIRECT"
printf '\nproxy-groups:\n'
printf ' - name: PROXY # 总开关,手动选;默认跟着 AUTO 走\n'
printf ' type: select\n proxies: [%s]\n use: [%s]\n' "$proxies_list" "$use_list"
printf ' - name: AUTO # 全部节点里自动挑延迟最低的\n'
printf ' type: url-test\n use: [%s]\n url: %s\n interval: 300\n tolerance: 50\n' \
"$use_list" "$HEALTH_URL"
for r in ${rlist[@]+"${rlist[@]}"}; do
printf ' - name: %s # 只看名字里带 %s 关键词的节点\n' "$r" "$r"
printf ' type: url-test\n use: [%s]\n filter: "%s"\n' "$use_list" "$(region_filter "$r")"
printf ' url: %s\n interval: 300\n tolerance: 50\n' "$HEALTH_URL"
done
# 回国模式要按域名和 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"
local bak=""
if [[ -f "$f" ]]; then
confirm " $f 已存在,覆盖它?(旧的会先备份)" || { rm -f "$tmp"; warn " 已取消,配置没动"; return 1; }
bak="$(cfg_backup)"
[[ -n "$bak" ]] && info "旧配置已备份 → $bak"
fi
install -m 0644 "$tmp" "$f"; rm -f "$tmp"
ok "配置已写入 $f"
cfg_apply "$bak" || warn "配置写进去了但校验没过,可以用「编辑配置」改,或重新运行一遍向导"
}
# 订阅行由本脚本写入,行尾带 "# mihomo-up:sub:<provider名>" 标记。
# 认这个标记而不是猜 YAML 结构,所以手写配置里别处的 url: 不会被误伤。
cfg_sub_list() { # 每行输出「序号 <TAB> provider名 <TAB> 订阅地址」
local f; f="$(conf_file)"
[[ -f "$f" ]] || return 0
sed -n -E 's|^[[:space:]]*url:[[:space:]]*"?([^"#]*)"?[[:space:]]*#[[:space:]]*mihomo-up:sub:?([A-Za-z0-9_-]*).*|\2\t\1|p' "$f" \
| sed -E 's/\t[[:space:]]*/\t/; s/[[:space:]]+$//' \
| awk -F'\t' '{ printf "%d\t%s\t%s\n", NR, ($1 == "" ? "subs" : $1), $2 }'
}
cfg_set_sub() { # cfg_set_sub <序号> <新地址>
local idx="$1" new="$2" f tmp; f="$(conf_file)"; tmp="$(mktemp)"
# 整行重建(缩进 + 新地址 + 原样保留行尾标记),比在原行上做替换更不容易出错
awk -v idx="$idx" -v url="$new" '
($0 ~ /url:/ && $0 ~ /mihomo-up:sub/) {
n++
if (n == idx) {
indent = $0; sub(/[^ ].*$/, "", indent)
tag = $0; sub(/^.*#/, "#", tag)
print indent "url: \"" url "\" " tag
next
}
}
{ print }
' "$f" > "$tmp" && cat "$tmp" > "$f"
rm -f "$tmp"
}
cfg_sub_menu() {
cfg_require || return 1
local f list; f="$(conf_file)"
list="$(cfg_sub_list)"
if [[ -z "$list" ]]; then
warn "在 $f 里没找到本脚本写的订阅行(标记是行尾的 # mihomo-up:sub:...)"
warn "这份配置多半是手写或外部导入的。改订阅请用「手动编辑配置」,或重新运行向导生成一份新的"
return 1
fi
log ""
log " ${D}当前订阅:${N}"
local i nm u
while IFS=$'\t' read -r i nm u; do
[[ -n "$u" ]] && printf ' %s%s.%s %s %s(%s)%s\n' "$C" "$i" "$N" "$u" "$D" "$nm" "$N" >&2
done <<<"$list"
log " ${D}增加或删除订阅要重新生成配置,向导会预先填好现有这几条。${N}"
local total; total="$(grep -c . <<<"$list")"
local pick="1"
(( total > 1 )) && ask_default " 改哪一条(序号)" "1" pick
if [[ ! "$pick" =~ ^[0-9]+$ ]] || (( pick < 1 || pick > total )); then
warn "序号要在 1 到 $total 之间,收到的是「$pick」"; return 1
fi
nm="$(sed -n "${pick}p" <<<"$list" | cut -f2)"
local cur; cur="$(sed -n "${pick}p" <<<"$list" | cut -f3)"
local new; ask_default " 第 $pick 条的新地址" "$cur" new
[[ -n "$new" && "$new" != "$cur" ]] || { info "没有改动"; return 0; }
local bak; bak="$(cfg_backup)"
cfg_set_sub "$pick" "$new"
ok "第 $pick 条订阅已更新"
# 旧节点缓存留着的话内核会继续用它,删掉才会去拉取新地址
rm -f "$CONF_DIR/providers/${nm}.yaml" 2>/dev/null || true
cfg_apply "$bak"
}
cfg_change_port() {
cfg_require || return 1
local cur; cur="$(cfg_get 'mixed-port')"
local new; ask_default " 本地代理端口" "${cur:-7890}" new
[[ "$new" =~ ^[0-9]+$ ]] || { warn "端口要是数字"; return 1; }
[[ "$new" == "$cur" ]] && { info "没有改动"; return 0; }
local bak; bak="$(cfg_backup)"
cfg_set 'mixed-port' "$new"
ok "代理端口 → $new"
cfg_apply "$bak"
}
cfg_toggle_lan() {
cfg_require || return 1
local cur; cur="$(cfg_get 'allow-lan')"
local new="true"; [[ "$cur" == "true" ]] && new="false"
local bak; bak="$(cfg_backup)"
cfg_set 'allow-lan' "$new"
if [[ "$new" == "true" ]]; then
ok "已允许局域网设备连本机代理。还要在防火墙放行 $(cfg_get 'mixed-port') 端口,否则外面照样连不进来"
else
ok "已改回只允许本机使用"
fi
cfg_apply "$bak"
}
cfg_change_ctrl() {
cfg_require || return 1
local cur_ctrl cur_secret
cur_ctrl="$(cfg_get 'external-controller')"; cur_secret="$(cfg_get 'secret')"
log " ${D}当前: ${cur_ctrl:-未开启} 密钥: ${cur_secret:-无}${N}"
local mode; ask_default " 管理接口: 1=只允许本机 2=局域网/公网可访问 3=不开启" "1" mode
local bak; bak="$(cfg_backup)"
case "$mode" in
3) cfg_set 'external-controller' '""'; ok "已关闭管理接口(面板也就连不上了)" ;;
2) local p; ask_default " 端口" "9090" p
cfg_set 'external-controller' "0.0.0.0:${p}"
local s; ask_default " 访问密钥(对外暴露必须设)" "${cur_secret:-$(gen_secret)}" s
cfg_set 'secret' "\"$s\""
ok "管理接口 → 0.0.0.0:${p},密钥 ${s}"
warn "0.0.0.0 意味着任何能连到这台机器的人都能访问它,请用防火墙只放行你信任的来源" ;;
*) local p; ask_default " 端口" "9090" p
cfg_set 'external-controller' "127.0.0.1:${p}"
local s; ask_default " 访问密钥" "${cur_secret:-$(gen_secret)}" s
cfg_set 'secret' "\"$s\""
ok "管理接口 → 127.0.0.1:${p}(只有本机能连)" ;;
esac
cfg_apply "$bak"
}
cfg_import_url() {
local url; ask " 完整 config.yaml 的下载地址: " url
[[ -n "$url" ]] || { info "已取消"; return 0; }
mkdir -p "$CONF_DIR"
local tmp; tmp="$(mktemp)"
curl -fsSL --retry 2 --connect-timeout 15 -o "$tmp" "$url" || { rm -f "$tmp"; warn "下载失败: $url"; return 1; }
[[ -s "$tmp" ]] || { rm -f "$tmp"; warn "下载到的是空文件,没有覆盖任何东西"; return 1; }
local bak=""
[[ -f "$(conf_file)" ]] && { bak="$(cfg_backup)"; info "旧配置已备份 → $bak"; }
install -m 0644 "$tmp" "$(conf_file)"; rm -f "$tmp"
ok "已导入到 $(conf_file)"
cfg_apply "$bak"
}
cfg_edit() {
local f; f="$(conf_file)"
[[ -f "$f" ]] || { warn "还没有配置文件,先用「生成配置」运行一遍向导"; return 1; }
local ed="${EDITOR:-}"
[[ -n "$ed" ]] || { command -v nano >/dev/null 2>&1 && ed=nano || ed=vi; }
command -v "${ed%% *}" >/dev/null 2>&1 || { warn "找不到编辑器 $ed,可以先 export EDITOR=你的编辑器 再回来"; return 1; }
local bak; bak="$(cfg_backup)"
# 编辑器要直接接管终端,否则 bash <(curl ...) 这种场景下它读不到按键
if [[ -w /dev/tty ]]; then "$ed" "$f" </dev/tty >/dev/tty 2>&1 || true
else "$ed" "$f" || true; fi
cfg_apply "$bak"
}
cfg_backup_menu() {
local f; f="$(conf_file)"
log ""
log " ${D}配置备份(每次改动前自动生成):${N}"
if compgen -G "${f}.bak-*" >/dev/null 2>&1; then
ls -1t "${f}".bak-* | while read -r b; do
printf ' %s %s%s%s\n' "$(basename "$b")" "$D" "$(date -r "$b" '+%F %T')" "$N"
done
else
log " ${D}还没有备份${N}"; return 0
fi
confirm " 还原到最近的一份备份?" || return 0
local last; last="$(ls -1t "${f}".bak-* | sed -n '1p')"
cp -a "$last" "$f"
ok "已还原 $(basename "$last")"
cfg_apply ""
}
# ───────────────────────── 入站端口 ─────────────────────────
# 一个端口对应一个出站:从 7801 进来的全走香港,从 7802 进来的全走日本。
# 每条入站写成一行 YAML 流式映射并带标记,加/删就是加一行/删一行,不动别处。
LISTENER_TAG="# mihomo-up:listener"
cfg_listener_list() { # 每行输出「序号 <TAB> 整行内容」
local f; f="$(conf_file)"
[[ -f "$f" ]] || return 0
grep -n "$LISTENER_TAG" "$f" 2>/dev/null \
| sed -E 's/^([0-9]+):[[:space:]]*/\1\t/' \
| awk -F'\t' '{ printf "%d\t%s\t%s\n", NR, $1, $2 }'
}
cfg_listener_show() {
local list; list="$(cfg_listener_list)"
if [[ -z "$list" ]]; then
log " ${D}还没有额外的入站端口。主代理端口 $(cfg_get 'mixed-port') 照常走 rules。${N}"
return 0
fi
log " ${D}额外的入站端口:${N}"
local i _ln body
while IFS=$'\t' read -r i _ln body; do
[[ -n "$body" ]] || continue
local nm pt px
nm="$(sed -E 's/.*name:[[:space:]]*([^,}]*).*/\1/' <<<"$body")"
pt="$(sed -E 's/.*port:[[:space:]]*([^,}]*).*/\1/' <<<"$body")"
px="$(grep -o 'proxy:[^,}]*' <<<"$body" | sed -E 's/proxy:[[:space:]]*//' || true)"
printf ' %s%s.%s 端口 %-6s → %s %s(%s)%s\n' \
"$C" "$i" "$N" "$pt" "${px:-按 rules 分流}" "$D" "$nm" "$N" >&2
done <<<"$list"
}
cfg_listener_add() {
cfg_require || return 1
local port name type out
ask_default " 新入站的端口" "7801" port
[[ "$port" =~ ^[0-9]+$ ]] || { warn "端口要是数字,收到的是「$port」"; return 1; }
if grep -q "port: ${port}," "$(conf_file)" 2>/dev/null; then
warn "端口 $port 已经被另一条入站占了"; return 1
fi
[[ "$port" == "$(cfg_get 'mixed-port')" ]] && { warn "端口 $port 就是主代理端口,换一个"; return 1; }
ask_default " 入站类型(mixed=HTTP+SOCKS5 / http / socks)" "mixed" type
case "$type" in mixed|http|socks) ;; *) warn "类型只能是 mixed / http / socks"; return 1 ;; esac
log ""
log " ${D}这个端口的流量走哪个出站:${N}"
local -a groups=(); local g
while IFS= read -r g; do [[ -n "$g" ]] && groups+=("$g"); done < <(cfg_group_names)
local idx=1
printf ' %s0.%s 按 rules 分流 %s(和主端口一样)%s\n' "$C" "$N" "$D" "$N" >&2
for g in ${groups[@]+"${groups[@]}"}; do
printf ' %s%s.%s %s\n' "$C" "$idx" "$N" "$g" >&2; idx=$((idx + 1))
done
printf ' %s%s.%s DIRECT %s(不走代理)%s\n' "$C" "$idx" "$N" "$D" "$N" >&2
local pick; ask_default " 选一个" "0" pick
[[ "$pick" =~ ^[0-9]+$ ]] || { warn "要填序号"; return 1; }
if (( pick == 0 )); then out=""
elif (( pick == idx )); then out="DIRECT"
elif (( pick >= 1 && pick < idx )); then out="${groups[pick-1]}"
else warn "没有这个序号"; return 1; fi
name="in-${port}"
local line=" - { name: ${name}, type: ${type}, listen: 0.0.0.0, port: ${port}"
[[ -n "$out" ]] && line+=", proxy: ${out}"
line+=" } ${LISTENER_TAG}"
local bak; bak="$(cfg_backup)"
local f tmp; f="$(conf_file)"; tmp="$(mktemp)"
if grep -q '^listeners:' "$f"; then
awk -v l="$line" '{ print } /^listeners:[[:space:]]*$/ && !d { print l; d=1 }' "$f" > "$tmp"
else
{ cat "$f"; printf '\nlisteners:\n%s\n' "$line"; } > "$tmp"
fi
cat "$tmp" > "$f"; rm -f "$tmp"
if [[ -n "$out" ]]; then
ok "入站 $port → $out"
warn "注意: 指定了出站就完全跳过 rules,从这个端口进来的流量一律走 $out"
else
ok "入站 $port 已添加,按 rules 分流"
fi
cfg_apply "$bak"
}
cfg_listener_del() {
cfg_require || return 1
local list; list="$(cfg_listener_list)"
[[ -n "$list" ]] || { warn "没有可删除的额外入站端口"; return 1; }
cfg_listener_show
local total; total="$(grep -c . <<<"$list")"
local pick; ask_default " 删除哪一条(序号)" "" pick
if [[ ! "$pick" =~ ^[0-9]+$ ]] || (( pick < 1 || pick > total )); then
warn "序号要在 1 到 $total 之间,收到的是「$pick」"; return 1
fi
local bak; bak="$(cfg_backup)"
local f tmp; f="$(conf_file)"; tmp="$(mktemp)"
awk -v idx="$pick" -v tag="$LISTENER_TAG" '
index($0, tag) { n++; if (n == idx) next }
{ print }
' "$f" > "$tmp"
# 删光了就把空的 listeners: 标题也去掉,留着它是个没有值的键
if ! grep -q "$LISTENER_TAG" "$tmp"; then
awk '!/^listeners:[[:space:]]*$/' "$tmp" > "${tmp}.2" && mv "${tmp}.2" "$tmp"
fi
cat "$tmp" > "$f"; rm -f "$tmp"
ok "第 $pick 条入站已删除"
cfg_apply "$bak"
}
cfg_listener_menu() {
while true; do
log ""
log "${W} 入站端口${N} ${D}— 多开几个本地端口,各自绑不同出站${N}"
cfg_listener_show
cat >&2 <<EOF
${C}1.${N} ➕ 添加一个入站端口
${C}2.${N} ➖ 删除一个入站端口
${C}0.${N} ↩️ 返回
EOF
local c; ask "$(printf '\n %s>%s ' "$W" "$N")" c
echo >&2
case "$c" in
1) cfg_listener_add || true; pause ;;
2) cfg_listener_del || true; pause ;;
0|q|"") return 0 ;;
*) warn "没有「$c」这个选项,输入上面列出的数字,或 0 返回"; sleep 1 ;;
esac
done
}
# ───────────────────────── GEO / 规则集 ─────────────────────────
# 这些数据文件决定 GEOIP / GEOSITE / RULE-SET 规则能不能匹配上。
# 内核缺文件时会自己去下载,但下载失败是静默的:规则一条都匹配不上,
# 流量全落到最后的 MATCH,看起来就像"配置没生效"。所以这里给出可见的状态。
ctrl_api() { # ctrl_api <方法> <路径> [数据];管理接口没开就返回 1
local ctrl secret host port
ctrl="$(cfg_get 'external-controller')"; secret="$(cfg_get 'secret')"
[[ -n "$ctrl" ]] || return 1
host="${ctrl%:*}"; port="${ctrl##*:}"
[[ -z "$host" || "$host" == "0.0.0.0" || "$host" == "[::]" ]] && host="127.0.0.1"
local -a args=(-fsS -m 30 -X "$1")
[[ -n "$secret" ]] && args+=(-H "Authorization: Bearer $secret")
[[ -n "${3:-}" ]] && args+=(-H "Content-Type: application/json" -d "$3")
curl "${args[@]}" "http://${host}:${port}$2"
}
geo_show() {
log " ${D}$CONF_DIR 里的数据文件:${N}"
local found=0 p
for p in "$CONF_DIR"/geoip.dat "$CONF_DIR"/geosite.dat "$CONF_DIR"/geoip.metadb \
"$CONF_DIR"/country.mmdb "$CONF_DIR"/ASN.mmdb "$CONF_DIR"/rules/*.mrs; do
[[ -f "$p" ]] || continue
found=1
printf ' %-22s %8s %s%s%s\n' "$(basename "$p")" \
"$(du -h "$p" 2>/dev/null | cut -f1)" "$D" "$(date -r "$p" '+%F %T')" "$N" >&2
done
if (( found == 0 )); then
log " ${Y}一个都没有${N}"
log " ${D}配置里没用到 GEOIP/GEOSITE/RULE-SET 的话,这是正常的。${N}"
log " ${D}用到了却没有文件,说明内核还没下载成功,规则会全部匹配不上。${N}"
fi
local au; au="$(cfg_get 'geo-auto-update')"
log " ${D}自动更新 geo-auto-update: ${au:-未设置(默认关)}${N}"
}
geo_refresh() {
cfg_require || return 1
info "让内核重新下载 GEO 数据库…"
if ctrl_api POST /configs/geo '{"path":"","payload":""}' >/dev/null 2>&1; then
ok "已通过管理接口触发更新,不用重启服务"
return 0
fi
if ctrl_api POST /upgrade/geo '{"path":"","payload":""}' >/dev/null 2>&1; then
ok "已通过管理接口触发更新,不用重启服务"
return 0
fi
warn "管理接口没响应(没开启,或者服务没在运行),改用删文件 + 重启的办法"
confirm_default_yes " 删掉现有的 GEO 文件和 .mrs 规则集,然后重启服务重新下载?" || { info "已取消"; return 0; }
rm -f "$CONF_DIR"/geoip.dat "$CONF_DIR"/geosite.dat "$CONF_DIR"/geoip.metadb \
"$CONF_DIR"/country.mmdb "$CONF_DIR"/ASN.mmdb 2>/dev/null || true
rm -f "$CONF_DIR"/rules/*.mrs 2>/dev/null || true
ok "已删除,内核下次启动会重新下载"
if [[ "$(svc_state)" == "running" ]]; then
systemctl restart "$SERVICE" && ok "$SERVICE 已重启" || warn "重启失败,看日志: journalctl -u $SERVICE -n 50"
fi
}
geo_toggle_autoupdate() {
cfg_require || return 1
local cur; cur="$(cfg_get 'geo-auto-update')"
local bak; bak="$(cfg_backup)"
if [[ "$cur" == "true" ]]; then
cfg_set 'geo-auto-update' 'false'
ok "已关闭自动更新,GEO 数据库只在缺文件时下载一次"
else
cfg_set 'geo-auto-update' 'true'
[[ -n "$(cfg_get 'geo-update-interval')" ]] || cfg_set 'geo-update-interval' '24'
ok "已打开自动更新,每 $(cfg_get 'geo-update-interval') 小时刷新一次"
fi
cfg_apply "$bak"
}
geo_menu() {
while true; do
log ""
log "${W} GEO / 规则集${N} ${D}— GEOIP、GEOSITE、RULE-SET 规则靠这些数据文件匹配${N}"
geo_show
cat >&2 <<EOF
${C}1.${N} 🔄 立即更新
${C}2.${N} ⏱️ 自动更新 开/关
${C}0.${N} ↩️ 返回
EOF
local c; ask "$(printf '\n %s>%s ' "$W" "$N")" c
echo >&2
case "$c" in
1) geo_refresh || true; pause ;;
2) geo_toggle_autoupdate || true; pause ;;
0|q|"") return 0 ;;
*) warn "没有「$c」这个选项,输入上面列出的数字,或 0 返回"; sleep 1 ;;
esac
done
}
# ───────────────────────── 一键安装 ─────────────────────────
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
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}(订阅 / 端口 / 入站 / GEO / 面板)${N}
${C}8.${N} 🎛️ 服务控制 ${D}(启动 / 停止 / 日志 / 开机自启)${N}
${C}9.${N} 🗑️ 卸载
${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) 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 nsub nlis
port="$(cfg_get 'mixed-port' || true)"
lan="$(cfg_get 'allow-lan' || true)"; ctrl="$(cfg_get 'external-controller' || true)"
nsub="$(cfg_sub_list 2>/dev/null | grep -c . || true)"
nlis="$(cfg_listener_list 2>/dev/null | grep -c . || true)"
cat >&2 <<EOF
${W} 配置管理${N} ${D}$(conf_file)${N}
${D}代理端口 ${N}${port:-—} ${D}局域网共享 ${N}${lan:-—} ${D}管理接口 ${N}${ctrl:-未开}
${D}订阅 ${N}${nsub:-0} 个 ${D}额外入站 ${N}${nlis:-0} 个
${C}1.${N} 🧙 生成配置 ${D}(向导, 会覆盖现有配置)${N}
${C}2.${N} 🔗 订阅管理 ${D}(查看 / 更换地址)${N}
${C}3.${N} 🔌 修改代理端口
${C}4.${N} 📡 局域网共享 开/关
${C}5.${N} 🎚️ 管理接口 / 面板密钥
${C}6.${N} 🚪 入站端口 ${D}(多开端口, 各绑各的出站)${N}
${C}7.${N} 🌍 GEO / 规则集 ${D}(状态 / 更新)${N}
${C}8.${N} 📥 从 URL 导入完整配置
${C}9.${N} 🧪 校验配置
${C}10.${N} 📝 手动编辑配置
${C}11.${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_sub_menu || true; pause ;;
3) cfg_change_port || true; pause ;;
4) cfg_toggle_lan || true; pause ;;
5) cfg_change_ctrl || true; pause ;;
6) cfg_listener_menu ;;
7) geo_menu ;;
8) cfg_import_url || true; pause ;;
9) cfg_validate || true; pause ;;
10) cfg_edit || true; pause ;;
11) cfg_backup_menu || true; pause ;;
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|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 ;;
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