码桶
发现社区成员的开源项目
install.sh52.1 KB
#!/usr/bin/env bash
# NodeNanny 一键安装脚本
# 支持:Ubuntu 20.04/22.04/24.04、Debian 11/12
set -e
# 发现27修复(本轮真机测试发现):真机上出现过安装到"流量池"这种耗时步骤
# (git clone + pip install)时,纯粹的网络抖动导致 SSH 连接断开
# (已用 uptime -s / last reboot / dmesg 排除是服务器自动重启或 OOM,
# 单纯就是网络断了),此时终端断开会给前台进程发 SIGHUP,
# 如果不处理,整个 install.sh 会被直接杀死,装到一半的东西不上不下。
# 这里忽略 SIGHUP:网络断线不会再杀死安装进程本身,它会在服务器上继续跑完;
# 如果断线时后面还有问答步骤没人来得及回答,会因为读不到输入自动走默认值
# 跳过/使用默认选项,不会卡死。仅靠这个不能保证"体验"完整
# (断线期间看不到过程输出),更稳妥的做法仍然是在服务器上用 tmux/screen
# 起一个会话再跑本脚本,这样断线重连后能 attach 回去继续看。
#
# 发现28修复(本轮真机测试发现):只忽略 SIGHUP 还不够——233boy 装机那步是
# `bash <(curl ...) | tee "$NN_PROXY_LOG"`,输出同时写终端和写日志文件。断线时
# 终端(tty)会被关闭,tee 写终端这一路会触发 SIGPIPE,默认处理是直接终止 tee 进程;
# tee 一死,管道另一端的 233boy 安装脚本再往这个已经没有读端的管道写东西,也会
# 收到 SIGPIPE 被杀死——整个装节点流程就断在中间,日志文件自然是不完整的,
# 订阅链接抓不到,面板"原始节点订阅地址"显示未检测到,本质原因在这里,
# 不是解析正则的问题。这里把 SIGPIPE 也一并忽略,断线时 tee/233boy
# 写终端这一路失败也不会把整个进程杀死,能继续把完整日志写进文件。
trap '' HUP PIPE
INSTALL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NODE_MIN_MAJOR=18
# shellcheck source=scripts/i18n.sh
source "$INSTALL_DIR/scripts/i18n.sh"
# ---------- 非交互/AI友好模式(v40新增)----------
# 详细设计说明和ask/ask_secret/ask_yn helper函数本体都在scripts/i18n.sh里
# (跟choose_language放在一起,因为两者都是"要不要真的调read"这同一层判断)。
# 这里只放一句面向阅读代码的人的提示:本文件从这里往下,所有原本的
# `read -rp`/`read -rsp` 都已经换成 `ask`/`ask_secret`/`ask_yn`,行为在
# NN_NONINTERACTIVE!=true时跟原来的read完全一样;NN_NONINTERACTIVE=true
# (或者stdin本来就不是终端)时才会走非交互分支。
choose_language
echo ""
m title
m installdir "$INSTALL_DIR"
echo ""
m safety_note
echo ""
# 本轮修复(复查发现:此前交接文档记录"已经加了终端提醒",但实际检查代码后发现
# 从来没有真正接线——上面第6-24行的SIGHUP/SIGPIPE处理说明只写在代码注释里,
# 从来没有在这里调用m把它打印给终端里的人看过。断线重连后看到"安装完成",
# 完全不知道中间有问答被默认值悄悄接管了。这里把它显示出来,不改变已有的
# "忽略SIGHUP/SIGPIPE、断线不杀进程"这个设计本身。)
m disconnect_warning
echo ""
if [ "$EUID" -ne 0 ]; then
m root_required
exit 1
fi
# 修复(本轮founder真机复现的"选IP还是域名之后卡住"bug,根因排查后发现):
# Ubuntu 22.04/24.04 默认装了 needrestart,凡是 apt 装/升级涉及后台服务的包
# (下面会装 nginx、certbot、git、nodejs 等),都会弹一个交互式"要不要重启这些
# 服务"的确认框——这个框不会像普通文字一样滚动打印,看起来就跟脚本卡死了一样,
# 得手动按回车/空格才能过去,装几个包可能连续弹好几次,跟真机复现的"卡住→回车→
# 又卡住→回车→跑完"完全对得上。这里在最开头统一关掉:
# DEBIAN_FRONTEND=noninteractive 让 apt/dpkg 本身不弹交互式问题(比如
# "配置文件被本地修改过,用哪个版本"这类),
# NEEDRESTART_MODE=a 让 needrestart 自动重启相关服务、不弹确认框。
# 用 export 导出,这样下面所有 apt-get 调用(包括 setup-reverse-proxy.sh、
# 流量池安装那段用 bash 调起的子脚本)都会继承到,不需要每处单独加。
export DEBIAN_FRONTEND=noninteractive
export NEEDRESTART_MODE=a
# ---------- 1. 检测系统 ----------
if [ -f /etc/os-release ]; then
. /etc/os-release
m os_detected "$PRETTY_NAME"
case "$ID" in
ubuntu|debian) ;;
*) m os_warn_untested ;;
esac
else
m os_warn_unknown
fi
# ---------- 2. 安装 Node.js(如未安装或版本过低) ----------
need_install_node=true
if command -v node >/dev/null 2>&1; then
current_major=$(node -v | sed 's/v//' | cut -d. -f1)
if [ "$current_major" -ge "$NODE_MIN_MAJOR" ]; then
m node_found "$(node -v)"
need_install_node=false
else
m node_low "$(node -v)"
fi
fi
if [ "$need_install_node" = true ]; then
m node_installing
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
fi
# ---------- 3. 安装 PM2 ----------
if ! command -v pm2 >/dev/null 2>&1; then
m pm2_installing
npm install -g pm2
else
m pm2_found
fi
# ---------- 3.5 安装编译工具链(build-essential/python3) ----------
# 本轮修复(全新服务器真机部署中发现的真实缺口):在线终端功能依赖的 node-pty
# 是需要本地编译的原生模块(依赖 node-gyp),全新服务器上通常还没装 make/gcc
# 这类编译工具,导致下面"安装项目依赖"这一步 npm install 直接失败、整个安装
# 中断。这里提前检测,缺了就自动装上 build-essential + python3,装失败也不
# 中断整个安装流程(继续往下走,让人自己看到提示后手动补装)。
if command -v make >/dev/null 2>&1 && command -v gcc >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1; then
m buildtools_found
else
m buildtools_installing
if ! (apt-get update -y && apt-get install -y build-essential python3) >/tmp/nodenanny-buildtools-install.log 2>&1; then
m buildtools_install_failed
fi
fi
# ---------- 4. 安装项目依赖 ----------
m deps_installing
cd "$INSTALL_DIR"
npm install --omit=dev
# 流量池的三层检测器(存活/测速/真实性验证)需要 sing-box 二进制作为检测后端
# (core/pool-checker.js 里硬依赖,缺了它每个候选节点都会在存活层直接报错)。
# 本轮修复(遗留欠账,之前几轮交接文档都记录过、一直没接线):不管流量池是通过
# 5b(legacy aggregator)还是5c(GitHub候选来源发现)哪一条路径启用的,都需要
# 这个二进制,所以做成一个幂等的独立函数,两边谁先启用了流量池就调用一次,
# 已经装过的话 command -v 直接短路,不会重复安装。
# 安装方式跟历史真机部署一致:SagerNet 官方 apt 源(deb.sagernet.org),不是
# 第三方一键脚本。
# v21修复:这个函数定义本轮从"5b小节内部"挪到了脚本顶层作用域——原因见下面
# 5b/5c挪出大if块的说明,函数必须在任何执行路径下都存在,不能只在"config.json
# 还不存在"这条分支里才被定义。
install_singbox_if_needed() {
if command -v sing-box >/dev/null 2>&1; then
m singbox_found "$(sing-box version 2>/dev/null | head -1)"
return 0
fi
m singbox_installing
if (
set -e
command -v curl >/dev/null 2>&1 || (apt-get update -y && apt-get install -y curl)
mkdir -p /etc/apt/keyrings
curl -fsSL https://sing-box.app/gpg.key -o /etc/apt/keyrings/sagernet.asc
chmod a+r /etc/apt/keyrings/sagernet.asc
{
echo "Types: deb"
echo "URIs: https://deb.sagernet.org/"
echo "Suites: *"
echo "Components: *"
echo "Enabled: yes"
echo "Signed-By: /etc/apt/keyrings/sagernet.asc"
} | tee /etc/apt/sources.list.d/sagernet.sources >/dev/null
apt-get update -y
apt-get install -y sing-box
) >/tmp/nodenanny-singbox-install.log 2>&1; then
m singbox_install_ok
return 0
else
m singbox_install_failed
return 1
fi
}
# ---------- 5. 初始化配置文件(交互式问答,不需要手动编辑任何文件)----------
if [ ! -f "$INSTALL_DIR/config/config.json" ]; then
echo ""
echo "=================================================================="
m config_intro
echo "=================================================================="
echo ""
ask NN_NODE_NAME "$(m node_name_prompt)" "$(m node_name_default)"
export NN_NODE_NAME="${NN_NODE_NAME:-$(m node_name_default)}"
# ---------- 5a.(可选)自动部署 Xray 节点(233boy 一键脚本)----------
# 发现1 修复(v14.0 真机测试):此前这里用 233boy 脚本的退出码(exit code)当唯一开关,
# 退出码非零就整体跳过端口/订阅链接解析——真机验证证明这个假设不成立:233boy 明明已经
# 把节点信息完整打印出来了,退出码却是非零的。现在把"判断退出码"和"从日志内容解析信息"
# 这两件事彻底解耦:不管退出码是什么,都先尝试正则解析;只有正则也解析不到任何东西时,
# 退出码才用来决定要不要多打一句"确认失败"的警告。解析失败不会中断安装,只会退回手动输入。
NN_DETECTED_PORT=""
NN_SUBSCRIPTION_URL=""
NN_PROXY_SKIPPED=""
echo ""
m proxy_title
m proxy_explain
ask_yn NN_PROXY_CHOICE "$(m proxy_ask)" "N"
if [[ "$NN_PROXY_CHOICE" =~ ^[Yy]$ ]]; then
m proxy_installing
NN_PROXY_LOG="/tmp/nodenanny-proxy-install.log"
bash <(curl -fsSL https://raw.githubusercontent.com/233boy/Xray/main/install.sh) 2>&1 | tee "$NN_PROXY_LOG"
NN_PROXY_EXIT="${PIPESTATUS[0]}"
# 发现15 修复(批次四第三轮真机测试):此前这里先用"port|端口"模糊正则去日志全文里
# 抓端口号,真机验证暴露出严重问题——这个正则只要求日志里出现"port"这四个字母连在一起,
# 不要求是独立单词,import/support/report/transport 等无关英文词中间一旦附近有数字就会被
# 误当成端口号抓走(真实案例:真实端口 29071,被误抓成 "41")。错误端口写进 config.json
# 后会导致 NodeNanny 永远监控一个不存在的端口、把好节点误判成坏节点、不断触发失败重启。
# 现在改成优先从订阅链接(vless://user@host:port?...)里直接抠端口号——这个来源结构固定、
# 远比在自由文本里做模糊正则可靠;只有链接本身解析不到端口时,才退回旧的模糊正则兜底。
# 顺手从同一份 233boy 安装日志里解析出完整的订阅分享链接(vless:// 等),
# 写入 NN_SUBSCRIPTION_URL,供下面的 write-config.js 写进 config.node.subscriptionUrl。
# 只取日志里出现的第一条链接:233boy 一次装一个节点,理论上日志里只会有一条。
#
# 发现18 修复(批次四第三轮真机测试):233boy 的终端输出带颜色,如果颜色重置符
# (\x1b[0m 等 ANSI 控制符)紧跟在链接/标签后面、中间没有空白字符,会被下面这条
# "抓到空白字符为止"的正则一起当成链接的一部分抓进来,最终变成节点名称显示时
# 结尾拖着一截"[0m"的乱码尾巴。这里在抓取之前,先把整份日志里的 ANSI 颜色控制符
# 统一剥离干净,再做后续解析,从根源上避免这类控制字符混进节点标签。
NN_PROXY_LOG_CLEAN="$(sed -E 's/\x1b\[[0-9;]*[a-zA-Z]//g' "$NN_PROXY_LOG")"
NN_SUBSCRIPTION_URL="$(printf '%s\n' "$NN_PROXY_LOG_CLEAN" | grep -oE '(vless|vmess|ss|ssr|trojan|hysteria2?|tuic)://[^[:space:]]+' | head -n1)"
if [ -n "$NN_SUBSCRIPTION_URL" ]; then
export NN_SUBSCRIPTION_URL
m proxy_sub_detected
else
m proxy_sub_not_detected
fi
# 优先尝试:从订阅链接的 host:port 部分直接抠出端口号。
# 链接形如 scheme://uuid@host:port?query#fragment(IPv6 host 会带中括号 [::1]:port),
# 先去掉 "scheme://uuid@" 前缀,再去掉第一个 /、?、# 之后的内容,剩下 host:port,
# 最后取末尾连续数字即为端口——不管 host 是 IPv4/域名还是带中括号的 IPv6 都成立。
NN_DETECTED_PORT=""
if [ -n "$NN_SUBSCRIPTION_URL" ]; then
NN_HOSTPORT="$(printf '%s' "$NN_SUBSCRIPTION_URL" \
| sed -E 's#^[A-Za-z0-9+.-]+://[^@]*@##' \
| sed -E 's#[/?#].*$##')"
NN_DETECTED_PORT="$(printf '%s' "$NN_HOSTPORT" | grep -oE '[0-9]{1,5}$')"
fi
# 兜底:订阅链接没解析到、或者链接里没解析到端口,才退回旧的模糊正则在日志全文里找。
# 这套正则仍然可能因为"port"子串误命中而抓错,只作为最后手段,不再是首选来源。
if [ -z "$NN_DETECTED_PORT" ]; then
# 注意 NN_PROXY_LOG_CLEAN 是已经读进内存的字符串,不是文件路径,
# 要用 printf 管道传给 grep,不能直接当文件名传进去。
NN_DETECTED_PORT="$(printf '%s\n' "$NN_PROXY_LOG_CLEAN" | grep -oiE '(port|端口)[^0-9]{0,10}[0-9]{2,5}' | grep -oE '[0-9]{2,5}' | head -n1)"
fi
if [ -n "$NN_DETECTED_PORT" ]; then
m proxy_port_detected "$NN_DETECTED_PORT"
else
m proxy_port_not_detected
fi
# 退出码只在"内容也确实没解析到任何东西"时才用来提示彻底失败;
# 如果退出码非零但内容里已经解析到了端口或订阅链接,只提醒一句,不当成失败处理。
if [ "$NN_PROXY_EXIT" -ne 0 ]; then
if [ -z "$NN_DETECTED_PORT" ] && [ -z "$NN_SUBSCRIPTION_URL" ]; then
m proxy_install_failed
else
m proxy_exit_nonzero_but_parsed
fi
fi
else
m proxy_skip_note
NN_PROXY_SKIPPED=1
fi
# 发现10 修复(本轮真机测试新发现):跳过233boy自动装节点时,port_prompt 之前一直
# 承诺"下一步会帮你自动查",但代码里从没真正实现过端口自动检测——check-service.sh
# 只查服务名/容器名,不查端口,导致用户直接回车会静默拿到错误的默认端口443(跟发现2
# 是同一类问题)。这里补一次轻量的端口自动检测:用 ss -tlnp 找匹配关键词的代理进程
# 正在监听的端口,找不到再如实告诉用户自动检测失败,而不是继续承诺代码做不到的事。
#
# 发现20 修复(用户真机第二轮测试新发现):xray-core 常见部署会同时监听两个端口——
# 一个是只绑在 127.0.0.1(回环地址)上的内部端口(通常给自己的统计/API用,不对外),
# 另一个才是绑在 0.0.0.0/* 或具体公网IP上、真正对外服务的端口。真机上出现过:
# ss -tlnp 里 127.0.0.1:57565(内部)排在 *:4629(真实对外端口)前面,旧逻辑直接
# head -n1 抓第一条,抓到的是内部端口,跟发现15是同一类"抓到看起来像但其实不对的
# 端口"问题,只是触发路径不同(这次是走 ss 扫描分支,不是走日志正则分支)。
# 修复方式:扫描时先排除本地地址是 127.0.0.1 或 [::1](IPv6回环)的行,只在剩下的
# "对外监听"候选里取第一个端口。
if [ -z "$NN_DETECTED_PORT" ]; then
NN_PORT_SCAN="$(ss -tlnp 2>/dev/null \
| grep -Ei 'xray|sing-box|singbox|v2ray|hysteria|trojan|shadowsocks|ss-|clash' \
| grep -v '127\.0\.0\.1:' | grep -v '\[::1\]:' \
| grep -oE ':[0-9]{2,5}[[:space:]]' | grep -oE '[0-9]{2,5}' | head -n1)"
if [ -n "$NN_PORT_SCAN" ]; then
NN_DETECTED_PORT="$NN_PORT_SCAN"
m proxy_port_detected_by_scan "$NN_DETECTED_PORT"
fi
fi
# 发现11 修复(批次四第三轮真机测试):如果用户选择跳过233boy一键装节点(选N),
# 脚本以前会无条件相信用户"已经自己装好了代理",不做任何验证。真实情况可能是裸机,
# 这样的话端口/服务名/订阅链接后面全会是空的或瞎填的,NodeNanny会一直监控不存在的东西、
# 持续判定异常、不断触发失败重启——跟发现2/10是同一类"没验证就往下走"的问题。
# 这里只在"用户选择跳过"且"ss扫描也确实什么都没扫到"这两个条件同时成立时才二次确认,
# 避免对已经真机验证过、扫描到端口的正常情况多此一问。
if [ -n "$NN_PROXY_SKIPPED" ] && [ -z "$NN_DETECTED_PORT" ]; then
echo ""
m proxy_skip_no_service_found
ask_yn_risky NN_SKIP_CONFIRM "$(m proxy_skip_confirm_prompt)" "N"
if [[ ! "$NN_SKIP_CONFIRM" =~ ^[Yy]$ ]]; then
m proxy_skip_abort
if [ "$NN_NONINTERACTIVE" = "true" ]; then
echo "[nodenanny] 非交互模式下检测不到任何代理服务、也没有显式设置 NN_SKIP_CONFIRM=Y,出于安全默认不继续(避免装出一个监控着不存在端口的实例)。确认要继续的话,加上 NN_SKIP_CONFIRM=Y 重跑。" >&2
fi
exit 1
fi
fi
if [ -n "$NN_DETECTED_PORT" ]; then
ask NN_CHECK_PORT "$(m port_prompt_detected "$NN_DETECTED_PORT")" "$NN_DETECTED_PORT"
export NN_CHECK_PORT="${NN_CHECK_PORT:-$NN_DETECTED_PORT}"
else
ask NN_CHECK_PORT "$(m port_prompt)" "443"
export NN_CHECK_PORT="${NN_CHECK_PORT:-443}"
fi
export NN_CHECK_HOST="127.0.0.1"
echo ""
m checking_services_title
bash "$INSTALL_DIR/scripts/check-service.sh" || true
echo ""
# 发现3 修复:读取 check-service.sh 扫描到的候选 systemd 服务名,
# 把它直接带进选项文案和默认值里,而不是让用户自己去把"刚才看到的服务名"跟"选项1"对上号。
NN_CANDIDATE_FILE="/tmp/nodenanny-check-service-candidate.txt"
NN_CANDIDATE_SERVICE=""
if [ -f "$NN_CANDIDATE_FILE" ]; then
NN_CANDIDATE_SERVICE="$(cat "$NN_CANDIDATE_FILE" 2>/dev/null)"
rm -f "$NN_CANDIDATE_FILE"
fi
m mgmt_question
if [ -n "$NN_CANDIDATE_SERVICE" ]; then
m mgmt_opt1_candidate "$NN_CANDIDATE_SERVICE"
else
m mgmt_opt1
fi
m mgmt_opt2
m mgmt_opt3
ask NN_MGMT_TYPE "$(m mgmt_choose)" "1"
NN_MGMT_TYPE="${NN_MGMT_TYPE:-1}"
case "$NN_MGMT_TYPE" in
2)
ask NN_SERVICE_NAME "$(m docker_name_prompt)" "xray"
export NN_SERVICE_NAME="${NN_SERVICE_NAME:-xray}"
export NN_RESTART_CMD="docker restart ${NN_SERVICE_NAME}"
# 修复记录:核验这个容器名是不是真的存在,不存在就警告+二次确认,
# 跟下面 systemd 分支、以及已有的自定义命令路径检查是同一个模式。
# 非交互模式说明:这里不是while循环,只会执行一次,所以不存在"卡死重复问"
# 的风险——ask_yn默认给Y(继续用检测不到的这个名字),跟原来"人直接按回车
# 走默认值"效果一致,不会无限循环,只是没有人能在这一步真的换一个更对的名字。
if command -v docker >/dev/null 2>&1; then
if ! docker inspect "$NN_SERVICE_NAME" >/dev/null 2>&1; then
docker ps -a --format ' - {{.Names}}' 2>/dev/null
m mgmt_docker_not_found "$NN_SERVICE_NAME"
ask_yn_risky NN_MGMT_CONFIRM "$(m custom_cmd_confirm_anyway)" "Y"
if [[ ! "$NN_MGMT_CONFIRM" =~ ^[Yy]$ ]]; then
ask NN_SERVICE_NAME "$(m docker_name_prompt)" "xray"
export NN_SERVICE_NAME="${NN_SERVICE_NAME:-xray}"
export NN_RESTART_CMD="docker restart ${NN_SERVICE_NAME}"
fi
fi
else
m mgmt_docker_unavailable "$NN_SERVICE_NAME"
fi
;;
3)
ask NN_SERVICE_NAME "$(m custom_name_prompt)" "xray"
export NN_SERVICE_NAME="${NN_SERVICE_NAME:-xray}"
m custom_cmd_prompt
# 发现3(b) 修复:如果填的是一条看起来像文件路径的命令,且这个路径在文件系统里
# 并不存在,给一次二次确认,拦住"把题目示例文本原样抄成真实命令"这类低级错误。
#
# 非交互模式的真实风险点,本轮加固:这是本文件里唯一一处"校验失败就continue
# 回去重新问"的while循环。如果NN_RESTART_CMD通过环境变量被设成了一个不存在的
# 路径、且NN_CMD_CONFIRM又被显式设成非Y——ask()在非交互模式下不会真的重新
# 读到不同的值(环境变量已经有值就不会被覆盖),会导致原本"continue回去再问
# 一遍"的设计在这里变成真正的死循环。加一个明确的非交互模式出口:这种情况下
# 不再continue,直接报错退出,而不是卡死。
while true; do
ask NN_RESTART_CMD "> " ""
NN_CMD_FIRST_TOKEN="${NN_RESTART_CMD%% *}"
if [[ "$NN_CMD_FIRST_TOKEN" == /* ]] && [ ! -e "$NN_CMD_FIRST_TOKEN" ]; then
m custom_cmd_path_not_found "$NN_CMD_FIRST_TOKEN"
ask_yn_risky NN_CMD_CONFIRM "$(m custom_cmd_confirm_anyway)" "Y"
if [[ "$NN_CMD_CONFIRM" =~ ^[Yy]$ ]]; then
break
fi
if [ "$NN_NONINTERACTIVE" = "true" ]; then
echo "[nodenanny] 非交互模式下 NN_RESTART_CMD=\"$NN_RESTART_CMD\" 指向的路径不存在,且 NN_CMD_CONFIRM 被显式设为否,没有人能在这里重新输入一个更对的命令,安装中止。请通过环境变量提供一个真实存在的 NN_RESTART_CMD 后重跑。" >&2
exit 1
fi
continue
fi
break
done
export NN_RESTART_CMD
;;
*)
if [ -n "$NN_CANDIDATE_SERVICE" ]; then
ask NN_SERVICE_NAME "$(m systemd_name_prompt_candidate "$NN_CANDIDATE_SERVICE")" "$NN_CANDIDATE_SERVICE"
export NN_SERVICE_NAME="${NN_SERVICE_NAME:-$NN_CANDIDATE_SERVICE}"
else
ask NN_SERVICE_NAME "$(m systemd_name_prompt)" "xray"
export NN_SERVICE_NAME="${NN_SERVICE_NAME:-xray}"
fi
export NN_RESTART_CMD="systemctl restart ${NN_SERVICE_NAME}"
# 修复记录:核验这个 systemd 服务名是不是真的存在(systemctl cat 对不存在的
# unit 会返回非零退出码,不管这个服务当前是运行中还是已停止都能正确识别存在性)。
# 不存在就警告+二次确认,避免打错字直接静默往下走、装完才发现在监控一个不存在的服务。
# (非交互模式说明同docker分支:这里不是while循环,只执行一次,ask_yn默认Y
# 不会造成死循环。)
if ! systemctl cat "$NN_SERVICE_NAME" >/dev/null 2>&1; then
m mgmt_systemd_not_found "$NN_SERVICE_NAME"
ask_yn_risky NN_MGMT_CONFIRM "$(m custom_cmd_confirm_anyway)" "Y"
if [[ ! "$NN_MGMT_CONFIRM" =~ ^[Yy]$ ]]; then
ask NN_SERVICE_NAME "$(m systemd_name_prompt)" "xray"
export NN_SERVICE_NAME="${NN_SERVICE_NAME:-xray}"
export NN_RESTART_CMD="systemctl restart ${NN_SERVICE_NAME}"
fi
fi
;;
esac
echo ""
m panel_pw_title
# 面板密码是安全关键字段,非交互模式下不能沿用"没填就悄悄用空密码"这种退让——
# 面板没密码等于服务器管理界面直接对公网敞开。这里单独处理:非交互模式必须
# 已经通过环境变量 NN_PANEL_PASSWORD 提供了非空密码,没提供就直接报错退出,
# 不静默继续;提供了就不需要再走"确认一遍"那道交互专属的手误保护(没有人在
# 打字,也就没有"手滑打错第二遍"这个问题)。
if [ "$NN_NONINTERACTIVE" = "true" ]; then
if [ -z "${NN_PANEL_PASSWORD:-}" ]; then
echo "[nodenanny] 非交互模式下必须通过环境变量 NN_PANEL_PASSWORD 提供一个非空的面板密码,不允许留空(留空等于面板对公网完全不设防)。请设置后重跑,例如:NN_NONINTERACTIVE=true NN_PANEL_PASSWORD='你的密码' bash install.sh" >&2
exit 1
fi
printf '[non-interactive] NN_PANEL_PASSWORD = (已从环境变量读取,内容不打印)\n' >&2
else
while true; do
read -rsp "$(m panel_pw_prompt)" NN_PANEL_PASSWORD
echo ""
if [ -z "$NN_PANEL_PASSWORD" ]; then
m panel_pw_empty
continue
fi
read -rsp "$(m panel_pw_confirm)" NN_PANEL_PASSWORD_CONFIRM
echo ""
if [ "$NN_PANEL_PASSWORD" != "$NN_PANEL_PASSWORD_CONFIRM" ]; then
m panel_pw_mismatch
continue
fi
break
done
fi
export NN_PANEL_PASSWORD
export NN_PANEL_PORT=8787
export NN_PANEL_BINDHOST="127.0.0.1"
echo ""
m smtp_title
m smtp_opt1
m smtp_opt2
m smtp_opt3
m smtp_opt4
m smtp_opt5
ask SMTP_CHOICE "$(m smtp_choose)" "5"
SMTP_CHOICE="${SMTP_CHOICE:-5}"
case "$SMTP_CHOICE" in
1) export NN_SMTP_HOST="smtp.qq.com"; export NN_SMTP_PORT=465; export NN_SMTP_SECURE=true
m smtp_qq_note ;;
2) export NN_SMTP_HOST="smtp.163.com"; export NN_SMTP_PORT=465; export NN_SMTP_SECURE=true
m smtp_163_note ;;
3) export NN_SMTP_HOST="smtp.gmail.com"; export NN_SMTP_PORT=465; export NN_SMTP_SECURE=true
m smtp_gmail_note ;;
4)
# 发现7 修复:如果用户在"服务器地址(host)"这一项里填的内容包含 @ 符号,
# 大概率是把"发信邮箱地址"当成了"SMTP 服务器地址"两个问题填混了,给一次警告确认。
#
# 非交互模式风险点,跟前面自定义重启命令那处一样:这是本文件第二处、
# 也是最后一处"校验失败就continue回去重新问"的while循环。同样加一个
# 非交互模式的明确出口,避免NN_SMTP_HOST_CONFIRM被显式设成非Y时死循环。
while true; do
ask NN_SMTP_HOST "$(m smtp_host_prompt)" ""
if [[ "$NN_SMTP_HOST" == *"@"* ]]; then
m smtp_host_looks_like_email "$NN_SMTP_HOST"
ask_yn_risky NN_SMTP_HOST_CONFIRM "$(m smtp_host_confirm_anyway)" "Y"
if [[ "$NN_SMTP_HOST_CONFIRM" =~ ^[Yy]$ ]]; then
break
fi
if [ "$NN_NONINTERACTIVE" = "true" ]; then
echo "[nodenanny] 非交互模式下 NN_SMTP_HOST=\"$NN_SMTP_HOST\" 看起来是邮箱地址而不是SMTP服务器地址,且 NN_SMTP_HOST_CONFIRM 被显式设为否,没有人能在这里重新输入,安装中止。请通过环境变量提供正确的 NN_SMTP_HOST 后重跑。" >&2
exit 1
fi
continue
fi
break
done
export NN_SMTP_HOST
ask NN_SMTP_PORT "$(m smtp_port_prompt)" "465"; export NN_SMTP_PORT="${NN_SMTP_PORT:-465}"
ask NN_SMTP_SECURE "$(m smtp_secure_prompt)" "true"
export NN_SMTP_SECURE="${NN_SMTP_SECURE:-true}" ;;
*) m smtp_skip_note ;;
esac
if [ "$SMTP_CHOICE" != "5" ]; then
ask NN_SMTP_USER "$(m smtp_user_prompt)" ""
export NN_SMTP_USER
ask_secret NN_SMTP_PASS "$(m smtp_pass_prompt)" ""
export NN_SMTP_PASS
ask NN_SMTP_TO "$(m smtp_to_prompt)" "$NN_SMTP_USER"
export NN_SMTP_TO="${NN_SMTP_TO:-$NN_SMTP_USER}"
if [ "$NN_NONINTERACTIVE" = "true" ] && { [ -z "$NN_SMTP_USER" ] || [ -z "$NN_SMTP_PASS" ]; }; then
echo "[nodenanny] 提示:SMTP已选择启用(SMTP_CHOICE=$SMTP_CHOICE),但 NN_SMTP_USER/NN_SMTP_PASS 至少有一项没有从环境变量拿到值——config.json里SMTP相关字段会先留空写入,装完之后记得手动补上再重启 nodenanny-monitor,否则邮件通知发不出去。" >&2
fi
fi
echo ""
m ai_title
m ai_explain
ask_yn NN_AI_CHOICE "$(m ai_ask)" "N"
if [[ "$NN_AI_CHOICE" =~ ^[Yy]$ ]]; then
export NN_AI_ENABLED=true
m ai_opt1
m ai_opt2
m ai_opt3
ask NN_AI_PROVIDER_CHOICE "$(m ai_provider_choose)" "1"
case "$NN_AI_PROVIDER_CHOICE" in
2) export NN_AI_PROVIDER="openai" ;;
3) export NN_AI_PROVIDER="openai-compatible" ;;
*) export NN_AI_PROVIDER="anthropic" ;;
esac
ask_secret NN_AI_APIKEY "$(m ai_apikey_prompt)" ""
export NN_AI_APIKEY
# 本轮新增(Addendum 8 bug#2修复):只有选了第三方/OpenAI兼容接口才需要问
# baseUrl/apiPath——anthropic/openai这两条官方路径代码里本来就是硬编码好的
# 域名,不需要用户填。
export NN_AI_BASEURL=""
export NN_AI_APIPATH=""
if [ "$NN_AI_PROVIDER" = "openai-compatible" ]; then
ask NN_AI_BASEURL "$(m ai_baseurl_prompt)" ""
export NN_AI_BASEURL
ask NN_AI_APIPATH "$(m ai_apipath_prompt)" ""
export NN_AI_APIPATH
# model在这条路径下必须非空(ai-provider.js对openai-compatible没有默认模型名,
# 留空会在真正诊断时才报错,而不是装机时就发现)——这里用循环强制填,
# 不像anthropic/openai分支的ai_model_prompt那样可以直接回车走运行时兜底默认值。
while true; do
ask NN_AI_MODEL "$(m ai_model_required_prompt)" ""
if [ -n "${NN_AI_MODEL:-}" ]; then
break
fi
if [ "$NN_NONINTERACTIVE" = "true" ]; then
# 非交互模式没法真的再问一遍人——打印醒目警告后继续,跟项目里其它
# "宁可不阻断整个安装流程"的校验失败处理原则一致,而不是死循环卡住。
echo "[nodenanny] 警告:AI供应商选择了第三方/OpenAI兼容接口(openai-compatible),但 NN_AI_MODEL 没有从环境变量拿到值。这个供应商没有默认模型名,config.json里model字段会先写成空——AI诊断功能实际不会生效,装完后记得手动补上模型名(例如智谱的 glm-4.7-flash)再重启 nodenanny-panel。" >&2
break
fi
m ai_model_required_empty_warn
done
export NN_AI_MODEL
else
ask NN_AI_MODEL "$(m ai_model_prompt)" ""
export NN_AI_MODEL
fi
# 本轮修改(founder本轮要求的UX变更):不再问触发阈值这道题——对非技术用户
# 来说这个数字意义不大,直接静默用默认值3,不占用一轮问答。
export NN_AI_TRIGGER_AFTER="3"
m ai_enabled_note
if [ "$NN_NONINTERACTIVE" = "true" ] && [ -z "$NN_AI_APIKEY" ]; then
echo "[nodenanny] 提示:AI诊断已选择启用,但 NN_AI_APIKEY 没有从环境变量拿到值——config.json里会先写成空key,AI诊断这个功能实际不会生效,装完后记得手动补上API Key再重启 nodenanny-panel。" >&2
fi
else
export NN_AI_ENABLED=false
m ai_skip_note
fi
# 发现8 修复:写入 config.json 之前,把关键字段汇总打印一遍,让用户有机会在写入前
# 发现自己填错了(此前发现3、发现7都是"填错了但没有任何撤回机会"这同一个根源)。
# 完整的"选N之后回到具体某一问重新填"实现起来会让整个脚本结构复杂很多,
# 这里采用退而求其次的方案:选N就直接安全退出(config.json此时还没写入,
# 重新运行一遍 install.sh 即可从头再答一遍,不会有残留的半成品配置)。
echo ""
echo "=================================================================="
m confirm_summary_title
echo "=================================================================="
m confirm_summary_node "$NN_NODE_NAME"
m confirm_summary_port "$NN_CHECK_PORT"
m confirm_summary_service "$NN_SERVICE_NAME" "$NN_RESTART_CMD"
if [ -n "$NN_PANEL_PASSWORD" ]; then
m confirm_summary_panel_pw_set
fi
if [ "$SMTP_CHOICE" != "5" ]; then
m confirm_summary_smtp "$NN_SMTP_HOST"
else
m confirm_summary_smtp_skipped
fi
if [ "$NN_AI_ENABLED" = "true" ]; then
m confirm_summary_ai_on
else
m confirm_summary_ai_off
fi
echo ""
ask NN_CONFIRM_SUMMARY "$(m confirm_summary_ask)" "Y"
NN_CONFIRM_SUMMARY="${NN_CONFIRM_SUMMARY:-Y}"
if [[ ! "$NN_CONFIRM_SUMMARY" =~ ^[Yy]$ ]]; then
echo ""
m confirm_summary_restart_hint
exit 0
fi
echo ""
node "$INSTALL_DIR/scripts/write-config.js"
m config_written
else
m config_exists_skip
fi
# ---------- 5b. 流量池(默认开启,不再询问是否启用)----------
# v21修复,对应两个真实问题:
# 1)【真实bug】此前5b/5c是写在"config.json不存在"这个大if块内部的——config.json
# 其实在5b/5c之前(write-config.js那一步)就已经生成了,如果5b(装aggregator,
# git clone+pip install,耗时几分钟)执行到一半SSH断线,重连重跑install.sh时,
# 脚本检测到config.json已存在,会把5b/5c这一整段问答全部跳过,连问都不问,
# 流量池永远没机会真正装上——这正是这次真机复现的情况。改法:5b/5c挪到
# config.json大if块外面,不再用"文件存不存在"当完成标记,改成直接读config.json
# 里pool.enabled当前的真实值——已经成功装过的话跳过、不重复装;没成功的话
# (不管是这次全新装的、还是断线重跑的)都会重新尝试,具备断点续跑能力。
# 2)【创始人本轮明确要求】流量池默认开启,不再询问"要不要启用",跳过这个选项,
# 直接尝试安装;GitHub候选来源发现(5c)属于"进阶功能",保留原来的单独询问。
POOL_ALREADY_ENABLED="$(node -e "
try {
const c = JSON.parse(require('fs').readFileSync('$INSTALL_DIR/config/config.json', 'utf-8'));
console.log(c.pool && c.pool.enabled ? 'true' : 'false');
} catch (e) {
console.log('false');
}
")"
if [ "$POOL_ALREADY_ENABLED" = "true" ]; then
m pool_already_enabled
else
echo ""
m pool_title
m pool_explain
m pool_installing
POOL_DIR="/root/aggregator"
if (
set -e
command -v git >/dev/null 2>&1 || (apt-get update -y && apt-get install -y git)
# 发现25修复:不能只在 python3 缺失时才装 python3-pip。
# 真机上出现过 python3 本来就存在(系统自带),但 pip3 命令并不存在的情况,
# 旧逻辑会因为"python3 已经找到"而跳过安装,导致后面 pip3 直接失败。
# 这里改成分别检查 python3 和 pip3 两个命令本身。
command -v python3 >/dev/null 2>&1 || (apt-get update -y && apt-get install -y python3)
command -v pip3 >/dev/null 2>&1 || (apt-get update -y && apt-get install -y python3-pip)
if [ ! -d "$POOL_DIR" ]; then
git clone --depth 1 https://github.com/wzdnzd/aggregator.git "$POOL_DIR"
fi
cd "$POOL_DIR"
pip3 install -r requirements.txt --break-system-packages 2>/dev/null || pip3 install -r requirements.txt
# 本轮真机测试确认修复:即使上面这行看起来跑完了,requirements.txt 里的
# tqdm 依赖仍然可能没有真正装上(真实报错:ModuleNotFoundError: tqdm,
# 发生在实际执行 collect.py -s 时,不只是理论推测)。之前只加了下面的
# --help 自检,这次真机验证不够可靠,这里额外显式补装一次 tqdm 兜底,
# 不依赖 requirements.txt 这一份文件本身是否完整。
pip3 install tqdm --break-system-packages 2>/dev/null || pip3 install tqdm
# 发现26修复:pip install 退出码为0,不代表脚本真的能跑起来(真机实测过
# ModuleNotFoundError: tqdm 这种要等 import 阶段才暴露的问题)。
# 这里额外跑一次轻量的功能性自检:只触发 collect.py 的参数解析/顶层 import,
# 不会真的跑完整抓取,能提前捕捉缺依赖的情况。
# 诚实说明:--help 是否被 collect.py 支持,沙盒没有网络、没有真机验证过;
# 如果真机上这一步报的是"unrecognized arguments"而不是 ModuleNotFoundError,
# 说明自检参数本身需要调整,不代表依赖没装好,以 selfcheck 日志内容为准。
python3 subscribe/collect.py --help >/tmp/nodenanny-pool-selfcheck.log 2>&1
if grep -qi "ModuleNotFoundError\|ImportError" /tmp/nodenanny-pool-selfcheck.log; then
echo "[pool-install] 功能性自检发现缺失的 Python 依赖,详见 /tmp/nodenanny-pool-selfcheck.log" >&2
exit 1
fi
) >/tmp/nodenanny-pool-install.log 2>&1; then
NN_POOL_ENABLED=true node -e "
const fs=require('fs');
const p='$INSTALL_DIR/config/config.json';
const c=JSON.parse(fs.readFileSync(p,'utf-8'));
c.pool.enabled=true;
c.pool.aggregatorDir='$POOL_DIR';
fs.writeFileSync(p, JSON.stringify(c,null,2));
"
m pool_install_ok
install_singbox_if_needed || true
else
m pool_install_failed
fi
fi
# ---------- 5c. GitHub候选来源自动发现(可选,进阶功能,自愈生态,试验性功能)----------
# 跟5b彼此独立:这个功能本身不依赖wzdnzd/aggregator,是批次三新增的"自愈生态"能力
# (GitHub扫描+试用期状态机)。v21同样挪到大if块外面、改用config.json里discovery.enabled
# 的真实值当完成标记(原因同5b:避免断线重跑后被"文件已存在"误判成"已经问过了")。
# 这是"进阶功能",创始人本轮明确要求保留询问,不跟5b一起默认开启。
DISCOVERY_ALREADY_ENABLED="$(node -e "
try {
const c = JSON.parse(require('fs').readFileSync('$INSTALL_DIR/config/config.json', 'utf-8'));
console.log(c.pool && c.pool.discovery && c.pool.discovery.enabled ? 'true' : 'false');
} catch (e) {
console.log('false');
}
")"
if [ "$DISCOVERY_ALREADY_ENABLED" = "true" ]; then
m discovery_already_enabled
else
echo ""
m discovery_title
m discovery_explain
ask_yn NN_DISCOVERY_CHOICE "$(m discovery_ask)" "N"
if [[ "$NN_DISCOVERY_CHOICE" =~ ^[Yy]$ ]]; then
m discovery_token_explain
ask_secret NN_GITHUB_TOKEN "$(m discovery_token_ask)" ""
# 用环境变量传给node -e,不在shell里把token拼进JS字符串——原因跟write-config.js
# 顶部注释一样:token内容不可控,直接拼字符串遇到引号/反斜杠会把JSON拼坏。
NN_GITHUB_TOKEN="${NN_GITHUB_TOKEN:-}" node -e "
const fs=require('fs');
const p='$INSTALL_DIR/config/config.json';
const c=JSON.parse(fs.readFileSync(p,'utf-8'));
c.pool.enabled=true;
c.pool.discovery.enabled=true;
c.pool.discovery.githubToken=process.env.NN_GITHUB_TOKEN || '';
fs.writeFileSync(p, JSON.stringify(c,null,2));
"
m discovery_enabled_note
install_singbox_if_needed || true
else
m discovery_skip_note
fi
fi
# ---------- 5d. 手动种子来源(跟5c的GitHub自动发现彼此独立)----------
# 本轮修复的真实缺口(交接文档反复记录过、此前一直没接线):config.example.json里
# 样例默认值其实已经带了几条创始人自己验证过的社区订阅(旺财等),但write-config.js
# 从来没写过manualSources这个字段,install.sh也从来没问过用户要不要用——导致
# 只有"手动改已经部署好的服务器config.json"这一条路能用上,新装的服务器永远
# 拿不到这几条来源。这里补上一个独立的问答步骤,跟5c一样用config.json里的真实值
# (而不是文件是否存在)当完成标记,支持断线重跑续接。
# 默认值说明(founder本轮明确要求改成默认开启,区别于5c的discovery默认关闭):
# 这些是创始人自己手动验证过、值得信任的种子来源(不是完全无人工把关的自动发现),
# 默认答"是"能让新装的服务器一开始就有一批可用的备用节点,不用额外多问一遍。
MANUAL_SOURCES_ALREADY_SET="$(node -e "
try {
const c = JSON.parse(require('fs').readFileSync('$INSTALL_DIR/config/config.json', 'utf-8'));
console.log((c.pool && Array.isArray(c.pool.manualSources) && c.pool.manualSources.length > 0) ? 'true' : 'false');
} catch (e) {
console.log('false');
}
")"
if [ "$MANUAL_SOURCES_ALREADY_SET" = "true" ]; then
m manual_source_already_enabled
else
echo ""
m manual_source_title
m manual_source_explain
ask_yn NN_MANUAL_SOURCE_CHOICE "$(m manual_source_ask)" "Y"
if [[ "$NN_MANUAL_SOURCE_CHOICE" =~ ^[Yy]$ ]]; then
NN_POOL_MANUAL_SOURCES_ENABLED=true node -e "
const fs=require('fs');
const path=require('path');
const p='$INSTALL_DIR/config/config.json';
const c=JSON.parse(fs.readFileSync(p,'utf-8'));
let manualSources=[];
try {
const example=JSON.parse(fs.readFileSync(path.join('$INSTALL_DIR','config','config.example.json'),'utf-8'));
manualSources=(example.pool && Array.isArray(example.pool.manualSources)) ? example.pool.manualSources : [];
} catch (e) {}
c.pool.enabled=true;
c.pool.manualSources=manualSources;
fs.writeFileSync(p, JSON.stringify(c,null,2));
"
m manual_source_enabled_note
else
m manual_source_skip_note
fi
fi
# ---------- 5e. 在线终端(本轮新增,修复真实缺口:config.terminal.password这个字段
# 一直存在、代码里也写死了"留空且enabled=true会拒绝挂载",但install.sh从头到尾
# 从来没问过这个密码,config.example.json默认也是空——导致这个功能永远没法在
# 装机时直接可用,只能装完手动改config.json。这里补一个独立问答,密码为空时
# 绝不写enabled:true,避免开一个没有密码保护的终端出去。默认关闭(涉及安全,
# 让用户自己决定要不要开,不像5d那样默认信任)。----------
TERMINAL_ALREADY_SET="$(node -e "
try {
const c = JSON.parse(require('fs').readFileSync('$INSTALL_DIR/config/config.json', 'utf-8'));
console.log((c.terminal && c.terminal.enabled) ? 'true' : 'false');
} catch (e) {
console.log('false');
}
")"
if [ "$TERMINAL_ALREADY_SET" = "true" ]; then
m terminal_already_enabled
else
echo ""
m terminal_title
m terminal_explain
ask_yn NN_TERMINAL_CHOICE "$(m terminal_ask)" "Y"
if [[ "$NN_TERMINAL_CHOICE" =~ ^[Yy]$ ]]; then
ask_secret NN_TERMINAL_PASSWORD "$(m terminal_password_prompt)" ""
if [ -n "${NN_TERMINAL_PASSWORD:-}" ] && [ "$NN_NONINTERACTIVE" != "true" ]; then
# 密码输入不回显,打错了自己也看不出来——这里加一次确认输入,跟前面AI供应商
# 必填model字段用的是同一个思路(循环+不匹配就重新问),避免创始人事后才发现
# 密码设错、把自己锁在终端外面。非交互模式没有人能重新输入,跳过这个循环。
while true; do
NN_TERMINAL_PASSWORD_CONFIRM=""
ask_secret NN_TERMINAL_PASSWORD_CONFIRM "$(m terminal_password_confirm_prompt)" ""
if [ "$NN_TERMINAL_PASSWORD_CONFIRM" = "$NN_TERMINAL_PASSWORD" ]; then
break
fi
m terminal_password_mismatch_warn
ask_secret NN_TERMINAL_PASSWORD "$(m terminal_password_prompt)" ""
if [ -z "${NN_TERMINAL_PASSWORD:-}" ]; then
break
fi
done
fi
if [ -n "${NN_TERMINAL_PASSWORD:-}" ]; then
# 密码内容不可控,用环境变量传给node -e,不在shell里拼进JS字符串
# (原因跟write-config.js顶部注释一致:内容带引号/反斜杠会拼坏JSON)。
NN_TERMINAL_PASSWORD="$NN_TERMINAL_PASSWORD" node -e "
const fs=require('fs');
const p='$INSTALL_DIR/config/config.json';
const c=JSON.parse(fs.readFileSync(p,'utf-8'));
c.terminal=c.terminal||{};
c.terminal.enabled=true;
c.terminal.password=process.env.NN_TERMINAL_PASSWORD;
fs.writeFileSync(p, JSON.stringify(c,null,2));
"
m terminal_enabled_note
else
m terminal_password_missing_warn
fi
else
m terminal_skip_note
fi
fi
# ---------- 5f. 内容同步来源(修复真实缺口:kbSync/wikiSync/sourceListSync这三个
# 同步功能面板和代码都已经做好了,但write-config.js从来没写过这些字段,
# install.sh也从来没问过——config.example.json里默认是空,导致这些已经做好的
# 功能新装的服务器永远用不上。这三个字段指向的是NodeNanny官方仓库本身的公开内容
# (data/knowledge-base.json、data/wiki/、data/source-list.json),不涉及任何密钥,
# 所以不用像终端密码那样单独问用户填owner/repo——默认直接指向官方仓库,只问一句
# "要不要用"即可,fork了自己维护内容的人选"否",以后自己去config.json改成自己的仓库。
# sourceListSync是本轮补上的第三个(此前只有kbSync/wikiSync被这一步覆盖,见Addendum 7/8的
# 遗留事项)。----------
KBSYNC_SET="$(node -e "
try {
const c = JSON.parse(require('fs').readFileSync('$INSTALL_DIR/config/config.json', 'utf-8'));
console.log((c.kbSync && c.kbSync.rawUrl) ? 'true' : 'false');
} catch (e) { console.log('false'); }
")"
WIKISYNC_SET="$(node -e "
try {
const c = JSON.parse(require('fs').readFileSync('$INSTALL_DIR/config/config.json', 'utf-8'));
console.log((c.wikiSync && c.wikiSync.owner && c.wikiSync.repo) ? 'true' : 'false');
} catch (e) { console.log('false'); }
")"
SOURCELISTSYNC_SET="$(node -e "
try {
const c = JSON.parse(require('fs').readFileSync('$INSTALL_DIR/config/config.json', 'utf-8'));
console.log((c.sourceListSync && c.sourceListSync.rawUrl) ? 'true' : 'false');
} catch (e) { console.log('false'); }
")"
if [ "$KBSYNC_SET" = "true" ] && [ "$WIKISYNC_SET" = "true" ] && [ "$SOURCELISTSYNC_SET" = "true" ]; then
m sync_already_enabled
else
echo ""
m sync_title
m sync_explain
ask_yn NN_SYNC_CHOICE "$(m sync_ask)" "Y"
if [[ "$NN_SYNC_CHOICE" =~ ^[Yy]$ ]]; then
node -e "
const fs=require('fs');
const p='$INSTALL_DIR/config/config.json';
const c=JSON.parse(fs.readFileSync(p,'utf-8'));
c.kbSync=c.kbSync||{};
c.kbSync.rawUrl='https://raw.githubusercontent.com/yibentq/nodenanny/main/data/knowledge-base.json';
c.wikiSync=c.wikiSync||{};
c.wikiSync.owner='yibentq';
c.wikiSync.repo='nodenanny';
c.wikiSync.ref=c.wikiSync.ref||'main';
c.wikiSync.path=c.wikiSync.path||'data/wiki';
c.sourceListSync=c.sourceListSync||{};
c.sourceListSync.rawUrl='https://raw.githubusercontent.com/yibentq/nodenanny/main/data/source-list.json';
fs.writeFileSync(p, JSON.stringify(c,null,2));
"
m sync_enabled_note
else
m sync_skip_note
fi
fi
# ---------- 6. (已在 5a 步实现,此处不再需要)----------
# 233boy 一键装 Xray 的接入已经挪到第 5a 步(节点命名之后、端口问答之前),
# 会自动尝试跑 233boy 脚本并解析 xray info 输出取端口。
# 首次真机验证时如果发现 233boy 脚本参数/输出格式跟这里假设的不一样,改第 5a 步,不要在这里重新加逻辑。
# ---------- 7. 用 PM2 启动 ----------
m starting
pm2 start "$INSTALL_DIR/ecosystem.config.js"
pm2 save
# 发现"pm2 startup cosmetic bug"的真机验证结论:
# 老写法是 `pm2 startup ... | tail -n 1 | bash`,假设最后一行永远是一条要执行的 sudo 命令。
# 真机上用的是新版 PM2(7.0.3),它自己就会把 systemd 配置做完(内部执行 systemctl enable),
# 最后一行打印的其实是"如何撤销这个配置"的提示文字(例如 `$ pm2 unstartup systemd`),
# 不是要执行的命令——旧写法把这行提示喂给 bash 会报一个无害但吓人的错误。
# 这里改成:只有输出里真的出现一行以 sudo 开头的命令时,才去执行它(这是老版本 PM2
# 需要用户手动执行 sudo 命令的情况);新版 PM2 自动处理完的情况,不再额外执行任何东西。
PM2_STARTUP_OUTPUT=$(pm2 startup systemd -u root --hp /root 2>&1) || true
echo "$PM2_STARTUP_OUTPUT"
SUDO_CMD=$(echo "$PM2_STARTUP_OUTPUT" | grep -m1 '^sudo ' || true)
if [ -n "$SUDO_CMD" ]; then
eval "$SUDO_CMD" || true
fi
# 不管上面走了哪条分支,最后都重新冻结一次当前进程列表,
# 确保"服务器/PM2 重启后自动恢复"这件事真的生效,不依赖人手动再跑一次 pm2 save。
pm2 save
PANEL_PORT=$(node -e "console.log(require('$INSTALL_DIR/config/config.json').panel.port)")
echo ""
m install_done
m pm2_hint
echo ""
# ---------- 8. 自动配置面板访问方式(解决"装完看不到面板"的问题)----------
m access_title
m access_intro
echo ""
m access_opt1
m access_opt2
m access_opt3
ask NN_ACCESS_CHOICE "$(m access_choose)" "2"
NN_ACCESS_CHOICE="${NN_ACCESS_CHOICE:-2}"
SERVER_IP_HINT=$(curl -fsSL -4 ifconfig.me 2>/dev/null || hostname -I | awk '{print $1}')
ACCESS_OUTPUT=""
ACCESS_ATTEMPTED=false
ACCESS_STATUS=0
ACCESS_RETRY_CMD=""
case "$NN_ACCESS_CHOICE" in
1)
ask NN_DOMAIN "$(m access_domain_prompt)" ""
# 域名没有一个"安全的默认值"可以兜底(不像端口/服务名那些至少有个合理猜测),
# 非交互模式下如果选了"域名模式"却没给域名,与其往下传一个空字符串让
# setup-reverse-proxy.sh用一种未经测试过的方式失败,不如在这里就报清楚。
if [ "$NN_NONINTERACTIVE" = "true" ] && [ -z "$NN_DOMAIN" ]; then
echo "[nodenanny] 非交互模式下 NN_ACCESS_CHOICE=1(域名模式)但没有提供 NN_DOMAIN,无法继续。请通过环境变量提供域名,或改用 NN_ACCESS_CHOICE=2(IP模式,无需域名)后重跑。" >&2
exit 1
fi
ACCESS_ATTEMPTED=true
ACCESS_RETRY_CMD="bash \"$INSTALL_DIR/scripts/setup-reverse-proxy.sh\" domain \"$NN_DOMAIN\""
# 发现26修复(本轮真机测试发现):原来这里用 `... || true`,子脚本因为
# apt-get/dpkg 被占用等原因提前退出时,失败会被整个吞掉,下面的代码只看
# "$ACCESS_OUTPUT 是否非空",哪怕子脚本只吐出了一两行还没配置完的日志就
# 挂了,也会被当成"跑过了",导致后面无条件打印"已重启面板进程",造成假成功。
# 现在用 if 结构真实拿到 exit code,不再用 || true 掩盖。
if ACCESS_OUTPUT=$(NN_LANG="$NN_LANG" bash "$INSTALL_DIR/scripts/setup-reverse-proxy.sh" domain "$NN_DOMAIN"); then
ACCESS_STATUS=0
else
ACCESS_STATUS=$?
fi
;;
3)
echo ""
m ssh_tunnel_hint "$PANEL_PORT"
echo " ssh -L ${PANEL_PORT}:127.0.0.1:${PANEL_PORT} root@${SERVER_IP_HINT}"
;;
*)
ACCESS_ATTEMPTED=true
ACCESS_RETRY_CMD="bash \"$INSTALL_DIR/scripts/setup-reverse-proxy.sh\" nodomain"
if ACCESS_OUTPUT=$(NN_LANG="$NN_LANG" bash "$INSTALL_DIR/scripts/setup-reverse-proxy.sh" nodomain); then
ACCESS_STATUS=0
else
ACCESS_STATUS=$?
fi
;;
esac
if [ "$ACCESS_ATTEMPTED" = true ]; then
[ -n "$ACCESS_OUTPUT" ] && echo "$ACCESS_OUTPUT"
ACCESS_URL=$(echo "$ACCESS_OUTPUT" | grep 'NODENANNY_ACCESS_URL=' | cut -d= -f2-)
ACCESS_NOTE=$(echo "$ACCESS_OUTPUT" | grep 'NODENANNY_ACCESS_NOTE=' | cut -d= -f2-)
# 只有真的拿到 NODENANNY_ACCESS_URL 这一行,才算配置成功——不再仅凭
# "子脚本跑过、没报错退出 install.sh" 就当作成功(ACCESS_STATUS 非0时
# 也可能有部分输出,同样不能算成功,必须以有没有这一行为准)。
if [ "$ACCESS_STATUS" -eq 0 ] && [ -n "$ACCESS_URL" ]; then
echo ""
echo "=================================================================="
echo " $ACCESS_URL"
echo "=================================================================="
if [ "$ACCESS_NOTE" = "selfsigned" ]; then
m selfsigned_note
fi
echo "$ACCESS_URL" > "$INSTALL_DIR/PANEL_ACCESS_URL.txt"
m url_saved "$INSTALL_DIR/PANEL_ACCESS_URL.txt"
# 修复记录(本轮真机测试发现):nodenanny-panel 在上面第7步已经用 PM2 启动,把当时的
# config.json 读进了内存缓存;setup-reverse-proxy.sh 是在那之后才运行、才把订阅专用
# 地址(access.subUrlBase,nodomain 模式下才会写)写进 config.json 的。面板进程不会自动
# 重新读取配置文件,如果不重启,会一直使用旧配置、订阅链接退回到错误的地址(跟面板本体
# 同一个自签HTTPS端口)——这正好绕开了"发现23"当初专门为兼容 Shadowrocket 这类客户端
# 做的方案。这里补一次重启:只有确认上面真的拿到访问地址、配置成功了才重启,
# 避免子脚本压根没跑完时也去重启面板、还打印"已重启"这种误导性的成功提示。
pm2 restart nodenanny-panel >/dev/null 2>&1 || true
m panel_restarted_for_sub
else
echo ""
echo "=================================================================="
echo " [nodenanny] 面板访问方式没有配置成功(reverse-proxy 脚本提前退出,exit=${ACCESS_STATUS})"
echo " 面板进程本身仍在正常运行(PM2 没有动它),只是还不能从浏览器/手机直接访问。"
echo " 常见原因:apt/dpkg 被其它进程占用(比如系统自带的 unattended-upgrades 正在跑),"
echo " 等它结束或确认锁已释放后,手动重跑下面这条命令即可,不需要重装或重跑整个 install.sh:"
echo ""
echo " $ACCESS_RETRY_CMD"
echo ""
echo " 重跑后请确认输出里出现了 NODENANNY_ACCESS_URL= 这一行,才算真正配置成功。"
echo "=================================================================="
fi
fi
echo ""
m panel_pw_reminder