进「配置管理 → 入站端口」而配置里一条入站都没有时,脚本会连报两次 错误然后退出。 cfg_listener_list 末尾是 grep 管道,grep 无匹配时退出码为 1,配上 pipefail 就把整个函数判为失败,触发 ERR trap。"没有入站"是正常状态, 不是错误,末尾补 || true。 两次报错是同一根因的两次现身:第一次在 $() 子 shell 里,只杀掉子 shell; 第二次是外层赋值继承了非零码,才真正终止脚本。
1580 lines
66 KiB
Bash
1580 lines
66 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 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> 原文件行号 <TAB> 整行内容」
|
||
local f; f="$(conf_file)"
|
||
[[ -f "$f" ]] || return 0
|
||
# 末尾的 || true 不能省: 一条入站都没有时 grep 退出码是 1,配上 pipefail
|
||
# 会让整个函数返回失败,进而触发 ERR trap —— 但"没有入站"是正常状态,不是错误
|
||
grep -n "$LISTENER_TAG" "$f" 2>/dev/null \
|
||
| sed -E 's/^([0-9]+):[[:space:]]*/\1\t/' \
|
||
| awk -F'\t' '{ printf "%d\t%s\t%s\n", NR, $1, $2 }' || true
|
||
}
|
||
|
||
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
|