From bd46227e4953a3089d9970fec8fab7e29e6cde66 Mon Sep 17 00:00:00 2001 From: 237899745 <237899745@users.noreply.git.workyai.cn> Date: Mon, 27 Jul 2026 21:04:12 +0800 Subject: [PATCH] fix: harden one-click installer --- backend/tests/architecture-tests.js | 37 ++ install.sh | 540 +++++++++++++++++++--------- 2 files changed, 403 insertions(+), 174 deletions(-) diff --git a/backend/tests/architecture-tests.js b/backend/tests/architecture-tests.js index e5941f1..1539fcc 100644 --- a/backend/tests/architecture-tests.js +++ b/backend/tests/architecture-tests.js @@ -140,6 +140,43 @@ test('PM2 部署没有启用 cluster 或多实例参数', () => { assert.strictEqual(/pm2\s+start[^\n]*(?:\s-i\s|--instances)/.test(installer), false); }); +test('install.sh 可在最小化系统完成预检且不会吞掉 NodeSource 下载错误', () => { + const installer = fs.readFileSync(path.join(projectRoot, 'install.sh'), 'utf8'); + assert.ok(installer.includes("/dev/tcp/git.workyai.cn/443")); + assert.strictEqual(installer.includes('ping -c 1 git.workyai.cn'), false); + assert.strictEqual(/^\s*clear\s*$/m.test(installer), false); + assert.ok(installer.includes('clear_screen')); + assert.ok(installer.includes('main "$@"')); + assert.ok(installer.includes('/etc/apt/sources.list.d/ubuntu.sources')); + for (const dependency of ['ca-certificates', 'iproute2', 'dnsutils', 'procps']) { + assert.ok(installer.includes(dependency), dependency); + } + assert.ok(installer.includes('download_and_run_setup_script')); + assert.strictEqual(/curl[^\n]+\|\s*(?:ba)?sh/.test(installer), false); + assert.ok(installer.includes('verify_nodejs_installation')); +}); + +test('install.sh 生成可用且受保护的生产环境配置', () => { + const installer = fs.readFileSync(path.join(projectRoot, 'install.sh'), 'utf8'); + assert.ok(installer.includes('PUBLIC_BASE_URL=${PUBLIC_BASE_URL_VALUE}')); + assert.ok(installer.includes('ALLOWED_HOSTS=${ALLOWED_HOSTS_VALUE}')); + assert.ok(installer.includes('ALLOWED_ORIGINS=${ALLOWED_ORIGINS_VALUE}')); + assert.ok(installer.includes('chmod 600 "${PROJECT_DIR}/backend/.env"')); + assert.ok(installer.includes('dotenv_quote')); + assert.strictEqual(installer.includes('ALLOWED_ORIGINS_VALUE=""'), false); + assert.strictEqual(installer.includes('type_count++'), false); + assert.ok(installer.includes('/api/health')); +}); + +test('install.sh 只展示已实现的 SSL 方案且 Certbot 分支可达', () => { + const installer = fs.readFileSync(path.join(projectRoot, 'install.sh'), 'utf8'); + assert.ok(installer.includes('deploy_certbot || ssl_fallback "1"')); + assert.ok(installer.includes('download_and_run_setup_script "https://get.acme.sh"')); + assert.strictEqual(installer.includes('证书申请功能开发中'), false); + assert.strictEqual(installer.includes('阿里云AccessKey ID'), false); + assert.strictEqual(installer.includes('腾讯云SecretId'), false); +}); + console.log('\n========================================'); console.log('测试总结'); console.log('========================================'); diff --git a/install.sh b/install.sh index 8ffe25b..e35289b 100644 --- a/install.sh +++ b/install.sh @@ -27,7 +27,7 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' PURPLE='\033[0;35m' CYAN='\033[0;36m' -WHITE='\033[1;37m' +GRAY='\033[0;37m' NC='\033[0m' # No Color # 全局变量 @@ -50,7 +50,7 @@ BACKEND_PORT="40001" ################################################################################ print_banner() { - clear + clear_screen echo -e "${CYAN}" echo "╔═══════════════════════════════════════════════════════════════╗" echo "║ ║" @@ -82,6 +82,129 @@ print_info() { echo -e "${CYAN}ℹ $1${NC}" } +# 非交互环境没有 TERM 时,clear 会在 set -e 下直接终止安装。 +clear_screen() { + if [[ -t 1 ]] && [[ -n "${TERM:-}" ]] && command -v clear &> /dev/null; then + clear 2>/dev/null || true + fi +} + +has_controlling_terminal() { + if [[ -t 0 ]]; then + return 0 + fi + + if [[ -e /dev/tty ]] && (exec 3/dev/null; then + return 0 + fi + + return 1 +} + +check_network_connectivity() { + if command -v timeout &> /dev/null; then + timeout 10 bash -c 'exec 3<>/dev/tcp/git.workyai.cn/443' 2>/dev/null + return $? + fi + + if command -v curl &> /dev/null; then + curl -fsS --connect-timeout 5 --max-time 10 -o /dev/null https://git.workyai.cn/ + return $? + fi + + if command -v wget &> /dev/null; then + wget -q --spider --timeout=10 https://git.workyai.cn/ + return $? + fi + + return 2 +} + +is_ip_address() { + local value="$1" + local part + local -a ipv4_parts + + if [[ "$value" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then + IFS='.' read -r -a ipv4_parts <<< "$value" + for part in "${ipv4_parts[@]}"; do + if ((10#$part > 255)); then + return 1 + fi + done + return 0 + fi + + if [[ "$value" == *:* ]] && [[ "$value" =~ ^[0-9a-fA-F:]+$ ]]; then + return 0 + fi + + return 1 +} + +get_public_ip() { + local endpoint + local ip="" + + for endpoint in "https://api.ipify.org" "https://ifconfig.me/ip" "https://icanhazip.com"; do + if command -v curl &> /dev/null; then + ip=$(curl -4fsS --connect-timeout 3 --max-time 5 "$endpoint" 2>/dev/null || true) + elif command -v wget &> /dev/null; then + ip=$(wget -qO- --timeout=5 "$endpoint" 2>/dev/null || true) + else + break + fi + + ip=${ip//$'\r'/} + ip=${ip//$'\n'/} + if is_ip_address "$ip"; then + printf '%s' "$ip" + return 0 + fi + done + + return 1 +} + +format_url_host() { + local host="$1" + if [[ "$host" == *:* ]] && [[ "$host" != \[*\] ]]; then + printf '[%s]' "$host" + else + printf '%s' "$host" + fi +} + +dotenv_quote() { + local value="$1" + value=${value//\\/\\\\} + value=${value//\"/\\\"} + value=${value//$'\r'/\\r} + value=${value//$'\n'/\\n} + printf '"%s"' "$value" +} + +download_and_run_setup_script() { + local url="$1" + shift + local setup_script + setup_script=$(mktemp) + + if ! curl -fsSL --connect-timeout 10 --max-time 120 "$url" -o "$setup_script"; then + rm -f "$setup_script" + print_error "下载远程安装脚本失败: $url" + return 1 + fi + + if ! bash "$setup_script" "$@"; then + rm -f "$setup_script" + print_error "执行远程安装脚本失败: $url" + return 1 + fi + + rm -f "$setup_script" +} + # 检测操作系统 detect_os() { if [[ -f /etc/os-release ]]; then @@ -190,18 +313,25 @@ system_check() { # 检测操作系统 detect_os - print_success "操作系统: $OS $OS_VERSION" + print_success "操作系统: ${OS_NAME:-$OS} $OS_VERSION" # 检测架构 detect_arch print_success "系统架构: $ARCH" - # 检测内存 - TOTAL_MEM=$(free -m | awk '/^Mem:/{print $2}') - if [[ $TOTAL_MEM -lt 512 ]]; then + # 检测内存;最小化系统可能尚未安装 procps/free。 + if command -v free &> /dev/null; then + TOTAL_MEM=$(free -m | awk '/^Mem:/{print $2}') + else + TOTAL_MEM=$(awk '/^MemTotal:/{printf "%d", $2 / 1024}' /proc/meminfo 2>/dev/null || true) + fi + TOTAL_MEM=${TOTAL_MEM:-0} + if [[ $TOTAL_MEM -eq 0 ]]; then + print_warning "无法检测系统内存" + elif [[ $TOTAL_MEM -lt 512 ]]; then print_warning "内存不足512MB,可能影响性能" else - print_success "可用内存: ${TOTAL_MEM}MB" + print_success "系统内存: ${TOTAL_MEM}MB" fi # 检测磁盘空间 @@ -212,17 +342,22 @@ system_check() { print_success "可用磁盘: ${DISK_AVAIL}MB" fi - # 检测网络 - if ping -c 1 git.workyai.cn &> /dev/null; then + # 直接检查实际需要访问的 Git HTTPS 服务,不依赖最小系统通常缺失的 ping。 + if check_network_connectivity; then print_success "网络连接正常" else - print_error "无法连接到网络" - exit 1 + network_status=$? + if [[ $network_status -eq 2 ]]; then + print_warning "缺少网络探测工具,将由软件包安装与 Git 下载继续验证网络" + else + print_error "无法连接到 git.workyai.cn:443" + exit 1 + fi fi # 检测公网IP - PUBLIC_IP=$(curl -s ifconfig.me || curl -s icanhazip.com || echo "未知") - print_info "公网IP: $PUBLIC_IP" + PUBLIC_IP=$(get_public_ip || true) + print_info "公网IP: ${PUBLIC_IP:-未知}" echo "" } @@ -266,33 +401,67 @@ configure_aliyun_mirror() { case $OS in ubuntu) - # 备份原有源 - if [[ ! -f /etc/apt/sources.list.bak ]]; then - cp /etc/apt/sources.list /etc/apt/sources.list.bak + local ubuntu_codename="${VERSION_CODENAME:-}" + if [[ -z "$ubuntu_codename" ]] && command -v lsb_release &> /dev/null; then + ubuntu_codename=$(lsb_release -cs) + fi + if [[ -z "$ubuntu_codename" ]]; then + print_error "无法确定 Ubuntu 版本代号,未修改软件源" + return 1 fi - # 配置Ubuntu阿里云源 - cat > /etc/apt/sources.list << EOF -deb http://mirrors.aliyun.com/ubuntu/ $(lsb_release -cs) main restricted universe multiverse -deb http://mirrors.aliyun.com/ubuntu/ $(lsb_release -cs)-updates main restricted universe multiverse -deb http://mirrors.aliyun.com/ubuntu/ $(lsb_release -cs)-backports main restricted universe multiverse -deb http://mirrors.aliyun.com/ubuntu/ $(lsb_release -cs)-security main restricted universe multiverse + # Ubuntu 24.04+ 默认使用 deb822 格式,旧版本仍使用 sources.list。 + if [[ -f /etc/apt/sources.list.d/ubuntu.sources ]]; then + if [[ ! -f /etc/apt/sources.list.d/ubuntu.sources.bak ]]; then + cp /etc/apt/sources.list.d/ubuntu.sources /etc/apt/sources.list.d/ubuntu.sources.bak + fi + sed -Ei \ + -e 's|https?://([^/]+\.)?archive\.ubuntu\.com/ubuntu/?|https://mirrors.aliyun.com/ubuntu/|g' \ + -e 's|https?://security\.ubuntu\.com/ubuntu/?|https://mirrors.aliyun.com/ubuntu/|g' \ + -e 's|https?://ports\.ubuntu\.com/ubuntu-ports/?|https://mirrors.aliyun.com/ubuntu-ports/|g' \ + /etc/apt/sources.list.d/ubuntu.sources + else + if [[ -f /etc/apt/sources.list ]] && [[ ! -f /etc/apt/sources.list.bak ]]; then + cp /etc/apt/sources.list /etc/apt/sources.list.bak + fi + cat > /etc/apt/sources.list << EOF +deb https://mirrors.aliyun.com/ubuntu/ ${ubuntu_codename} main restricted universe multiverse +deb https://mirrors.aliyun.com/ubuntu/ ${ubuntu_codename}-updates main restricted universe multiverse +deb https://mirrors.aliyun.com/ubuntu/ ${ubuntu_codename}-backports main restricted universe multiverse +deb https://mirrors.aliyun.com/ubuntu/ ${ubuntu_codename}-security main restricted universe multiverse EOF + fi print_success "阿里云源配置完成" ;; debian) - # 备份原有源 - if [[ ! -f /etc/apt/sources.list.bak ]]; then - cp /etc/apt/sources.list /etc/apt/sources.list.bak + local debian_codename="${VERSION_CODENAME:-}" + if [[ -z "$debian_codename" ]] && command -v lsb_release &> /dev/null; then + debian_codename=$(lsb_release -cs) + fi + if [[ -z "$debian_codename" ]]; then + print_error "无法确定 Debian 版本代号,未修改软件源" + return 1 fi - # 配置Debian阿里云源 - cat > /etc/apt/sources.list << EOF -deb http://mirrors.aliyun.com/debian/ $(lsb_release -cs) main contrib non-free non-free-firmware -deb http://mirrors.aliyun.com/debian/ $(lsb_release -cs)-updates main contrib non-free non-free-firmware -deb http://mirrors.aliyun.com/debian/ $(lsb_release -cs)-backports main contrib non-free non-free-firmware -deb http://mirrors.aliyun.com/debian-security $(lsb_release -cs)-security main contrib non-free non-free-firmware + if [[ -f /etc/apt/sources.list.d/debian.sources ]]; then + if [[ ! -f /etc/apt/sources.list.d/debian.sources.bak ]]; then + cp /etc/apt/sources.list.d/debian.sources /etc/apt/sources.list.d/debian.sources.bak + fi + sed -Ei \ + -e 's|https?://deb\.debian\.org/debian/?|https://mirrors.aliyun.com/debian/|g' \ + -e 's|https?://(security\.debian\.org|deb\.debian\.org)/debian-security/?|https://mirrors.aliyun.com/debian-security/|g' \ + /etc/apt/sources.list.d/debian.sources + else + if [[ -f /etc/apt/sources.list ]] && [[ ! -f /etc/apt/sources.list.bak ]]; then + cp /etc/apt/sources.list /etc/apt/sources.list.bak + fi + cat > /etc/apt/sources.list << EOF +deb https://mirrors.aliyun.com/debian/ ${debian_codename} main contrib non-free non-free-firmware +deb https://mirrors.aliyun.com/debian/ ${debian_codename}-updates main contrib non-free non-free-firmware +deb https://mirrors.aliyun.com/debian/ ${debian_codename}-backports main contrib non-free non-free-firmware +deb https://mirrors.aliyun.com/debian-security/ ${debian_codename}-security main contrib non-free non-free-firmware EOF + fi print_success "阿里云源配置完成" ;; centos) @@ -497,22 +666,31 @@ install_dependencies() { case $PKG_MANAGER in apt) apt-get update - apt-get install -y curl wget git unzip lsb-release build-essential python3 + apt-get install -y \ + ca-certificates curl wget git unzip lsb-release gnupg openssl \ + build-essential software-properties-common python3 \ + iproute2 iputils-ping dnsutils procps cron install_nodejs_apt install_nginx_apt ;; yum) - yum install -y curl wget git unzip redhat-lsb-core gcc-c++ make python3 + yum install -y \ + ca-certificates curl wget git unzip redhat-lsb-core openssl \ + gcc-c++ make python3 iproute bind-utils procps-ng cronie install_nodejs_yum install_nginx_yum ;; dnf) - dnf install -y curl wget git unzip redhat-lsb-core gcc-c++ make python3 + dnf install -y \ + ca-certificates curl wget git unzip redhat-lsb-core openssl \ + gcc-c++ make python3 iproute bind-utils procps-ng cronie install_nodejs_dnf install_nginx_dnf ;; zypper) - zypper install -y curl wget git unzip lsb-release gcc-c++ make python3 + zypper install -y \ + ca-certificates curl wget git unzip lsb-release openssl \ + gcc-c++ make python3 iproute2 bind-utils procps cron install_nodejs_zypper install_nginx_zypper ;; @@ -534,6 +712,14 @@ node_version_supported() { node -e "const [major, minor] = process.versions.node.split('.').map(Number); process.exit((major > 20 && major < 25) || (major === 20 && minor >= 19) ? 0 : 1)" 2>/dev/null } +verify_nodejs_installation() { + if ! command -v node &> /dev/null || ! node_version_supported; then + print_error "Node.js 安装结果不满足版本要求(需要 ${NODE_MIN_VERSION} 至 24.x)" + return 1 + fi + print_success "Node.js 安装完成: $(node -v)" +} + install_nodejs_apt() { if command -v node &> /dev/null; then if node_version_supported; then @@ -544,9 +730,9 @@ install_nodejs_apt() { fi print_info "正在安装 Node.js ${NODE_VERSION}.x..." - curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - + download_and_run_setup_script "https://deb.nodesource.com/setup_${NODE_VERSION}.x" apt-get install -y nodejs - print_success "Node.js 安装完成: $(node -v)" + verify_nodejs_installation } install_nodejs_yum() { @@ -559,9 +745,9 @@ install_nodejs_yum() { fi print_info "正在安装 Node.js ${NODE_VERSION}.x..." - curl -fsSL https://rpm.nodesource.com/setup_${NODE_VERSION}.x | bash - + download_and_run_setup_script "https://rpm.nodesource.com/setup_${NODE_VERSION}.x" yum install -y nodejs - print_success "Node.js 安装完成: $(node -v)" + verify_nodejs_installation } install_nginx_apt() { @@ -598,9 +784,9 @@ install_nodejs_dnf() { fi print_info "正在安装 Node.js ${NODE_VERSION}.x..." - curl -fsSL https://rpm.nodesource.com/setup_${NODE_VERSION}.x | bash - + download_and_run_setup_script "https://rpm.nodesource.com/setup_${NODE_VERSION}.x" dnf install -y nodejs - print_success "Node.js 安装完成: $(node -v)" + verify_nodejs_installation } install_nginx_dnf() { @@ -627,7 +813,7 @@ install_nodejs_zypper() { print_info "正在安装 Node.js ${NODE_VERSION}.x..." # openSUSE使用官方仓库的Node.js zypper install -y nodejs${NODE_VERSION} - print_success "Node.js 安装完成: $(node -v)" + verify_nodejs_installation } install_nginx_zypper() { @@ -645,12 +831,16 @@ install_nginx_zypper() { install_pm2() { if command -v pm2 &> /dev/null; then print_success "PM2 已安装: $(pm2 -v)" - return + else + print_info "正在安装 PM2..." + npm install -g pm2 fi - print_info "正在安装 PM2..." - npm install -g pm2 - pm2 startup + if command -v systemctl &> /dev/null && [[ -d /run/systemd/system ]]; then + pm2 startup systemd -u root --hp /root + else + print_warning "未检测到 systemd,无法自动配置 PM2 开机启动" + fi print_success "PM2 安装完成" } @@ -1041,8 +1231,18 @@ choose_access_mode() { ;; 2) USE_DOMAIN=false - PUBLIC_IP=$(curl -s ifconfig.me || curl -s icanhazip.com || echo "未知") - print_info "将使用 IP 模式访问: http://${PUBLIC_IP}" + PUBLIC_IP=$(get_public_ip || true) + if [[ -z "$PUBLIC_IP" ]]; then + while true; do + read -p "无法自动获取公网 IP,请手动输入服务器 IP: " PUBLIC_IP < /dev/tty + if is_ip_address "$PUBLIC_IP"; then + break + fi + print_error "IP 地址格式不正确" + done + fi + PUBLIC_URL_HOST=$(format_url_host "$PUBLIC_IP") + print_info "将使用 IP 模式访问: http://${PUBLIC_URL_HOST}" echo "" break ;; @@ -1071,7 +1271,7 @@ configure_domain() { # 验证域名解析 print_info "正在验证域名解析..." DOMAIN_IP=$(dig +short "$DOMAIN" | tail -n1) - PUBLIC_IP=$(curl -s ifconfig.me || curl -s icanhazip.com) + PUBLIC_IP=$(get_public_ip || true) if [[ "$DOMAIN_IP" == "$PUBLIC_IP" ]]; then print_success "域名已正确解析到当前服务器IP" @@ -1152,53 +1352,47 @@ choose_ssl_method() { print_step "选择SSL证书部署方式" echo "" echo -e "${YELLOW}【推荐方案】${NC}" - echo -e "${GREEN}[1]${NC} acme.sh + Let's Encrypt" - echo " - 纯Shell脚本,轻量级稳定" + echo -e "${GREEN}[1]${NC} Certbot + Let's Encrypt" + echo " - 官方客户端,自动续期" echo " - 自动续期,无需手动操作" echo "" echo -e "${YELLOW}【备选方案】${NC}" - echo -e "${GREEN}[2]${NC} acme.sh + ZeroSSL" - echo " - Let's Encrypt的免费替代品" - echo -e "${GREEN}[3]${NC} acme.sh + Buypass" + echo -e "${GREEN}[2]${NC} acme.sh + Let's Encrypt" + echo " - 纯Shell脚本,轻量级稳定" + echo -e "${GREEN}[3]${NC} acme.sh + ZeroSSL" + echo " - Let's Encrypt 的免费替代品" + echo -e "${GREEN}[4]${NC} acme.sh + Buypass" echo " - 挪威免费CA,有效期180天" echo "" - echo -e "${YELLOW}【云服务商证书】${NC}" - echo -e "${GREEN}[4]${NC} 阿里云免费证书 (需提供AccessKey)" - echo -e "${GREEN}[5]${NC} 腾讯云免费证书 (需提供SecretKey)" - echo "" echo -e "${YELLOW}【其他选项】${NC}" - echo -e "${GREEN}[6]${NC} 使用已有证书 (手动上传)" - echo -e "${GREEN}[7]${NC} 暂不配置HTTPS (可后续配置)" + echo -e "${GREEN}[5]${NC} 使用已有证书 (手动上传)" + echo -e "${GREEN}[6]${NC} 暂不配置HTTPS (可后续配置)" echo "" while true; do - read -p "请输入选项 [1-7]: " ssl_choice < /dev/tty + read -p "请输入选项 [1-6]: " ssl_choice < /dev/tty case $ssl_choice in 1) - SSL_METHOD="2" # acme.sh + Let's Encrypt + SSL_METHOD="1" # Certbot + Let's Encrypt break ;; 2) - SSL_METHOD="3" # acme.sh + ZeroSSL + SSL_METHOD="2" # acme.sh + Let's Encrypt break ;; 3) - SSL_METHOD="5" # acme.sh + Buypass + SSL_METHOD="3" # acme.sh + ZeroSSL break ;; 4) - SSL_METHOD="4" # 阿里云 + SSL_METHOD="5" # acme.sh + Buypass break ;; 5) - SSL_METHOD="6" # 腾讯云 - break - ;; - 6) SSL_METHOD="7" # 手动上传 break ;; - 7) + 6) SSL_METHOD="8" # 不配置HTTPS break ;; @@ -1216,21 +1410,18 @@ deploy_ssl() { fi case $SSL_METHOD in + 1) + deploy_certbot || ssl_fallback "1" + ;; 2) deploy_acme_letsencrypt || ssl_fallback "2" ;; 3) deploy_acme_zerossl || ssl_fallback "3" ;; - 4) - deploy_aliyun_ssl || ssl_fallback "4" - ;; 5) deploy_acme_buypass || ssl_fallback "5" ;; - 6) - deploy_tencent_ssl || ssl_fallback "6" - ;; 7) deploy_manual_ssl ;; @@ -1252,6 +1443,12 @@ ssl_fallback() { # 动态显示可用选项(排除已失败的) local available_options=() + # 方案1: Certbot + Let's Encrypt + if [[ "$failed_method" != "1" ]]; then + echo -e "${GREEN}[1]${NC} Certbot + Let's Encrypt" + available_options+=("1") + fi + # 方案2: acme.sh + Let's Encrypt if [[ "$failed_method" != "2" ]]; then echo -e "${GREEN}[2]${NC} acme.sh + Let's Encrypt" @@ -1282,12 +1479,17 @@ ssl_fallback() { read -p "请选择备选方案: " retry_choice < /dev/tty # 检查输入是否在可用选项中 - if [[ ! " ${available_options[@]} " =~ " ${retry_choice} " ]]; then + if [[ " ${available_options[*]} " != *" ${retry_choice} "* ]]; then print_error "无效选项或该方案已失败" continue fi case $retry_choice in + 1) + deploy_certbot && return 0 + ssl_fallback "1" + return $? + ;; 2) deploy_acme_letsencrypt && return 0 # 如果再次失败,继续调用fallback但排除方案2 @@ -1334,25 +1536,17 @@ deploy_certbot() { print_success "Certbot (snap版) 安装成功" else print_warning "snap安装失败,尝试apt安装..." - # 修复urllib3依赖问题 - apt-get remove -y python3-urllib3 2>/dev/null || true apt-get install -y certbot python3-certbot-nginx fi else print_info "snap不可用,使用apt安装..." - # 修复urllib3依赖问题 - apt-get remove -y python3-urllib3 2>/dev/null || true apt-get install -y certbot python3-certbot-nginx fi ;; yum) - # 修复urllib3依赖问题 - yum remove -y python3-urllib3 2>/dev/null || true yum install -y certbot python3-certbot-nginx ;; dnf) - # 修复urllib3依赖问题 - dnf remove -y python3-urllib3 2>/dev/null || true dnf install -y certbot python3-certbot-nginx ;; zypper) @@ -1374,11 +1568,9 @@ deploy_certbot() { print_warning "检测到Certbot依赖问题,正在修复..." case $PKG_MANAGER in apt) - apt-get remove -y python3-urllib3 2>/dev/null || true apt-get install --reinstall -y certbot python3-certbot-nginx ;; yum|dnf) - $PKG_MANAGER remove -y python3-urllib3 2>/dev/null || true $PKG_MANAGER reinstall -y certbot python3-certbot-nginx ;; esac @@ -1445,10 +1637,9 @@ deploy_acme_letsencrypt() { print_info "使用 GitHub 官方源(国内可能较慢,请耐心等待)" - # 使用官方安装方法:直接通过curl管道执行 print_info "正在下载并安装..." - if curl -fsSL https://get.acme.sh | sh -s email=admin@example.com; then + if download_and_run_setup_script "https://get.acme.sh" "email=admin@example.com"; then install_result=$? print_info "安装脚本执行完成,退出码: $install_result" else @@ -1456,9 +1647,6 @@ deploy_acme_letsencrypt() { print_error "安装脚本执行失败,退出码: $install_result" fi - # 重新加载环境变量 - source ~/.bashrc 2>/dev/null || source ~/.profile 2>/dev/null || true - # 等待文件系统同步 print_info "等待安装完成..." sleep 3 @@ -1478,7 +1666,7 @@ deploy_acme_letsencrypt() { echo "" if [[ -d ~/.acme.sh ]]; then - print_info "~/.acme.sh 目录内容:" + print_info "${HOME}/.acme.sh 目录内容:" ls -la ~/.acme.sh/ 2>&1 | head -15 || echo " 无法列出目录" echo "" fi @@ -1626,10 +1814,9 @@ deploy_acme_zerossl() { print_info "使用 GitHub 官方源(国内可能较慢,请耐心等待)" - # 使用官方安装方法:直接通过curl管道执行 print_info "正在下载并安装..." - if curl -fsSL https://get.acme.sh | sh -s email=admin@example.com; then + if download_and_run_setup_script "https://get.acme.sh" "email=admin@example.com"; then install_result=$? print_info "安装脚本执行完成,退出码: $install_result" else @@ -1637,9 +1824,6 @@ deploy_acme_zerossl() { print_error "安装脚本执行失败,退出码: $install_result" fi - # 重新加载环境变量 - source ~/.bashrc 2>/dev/null || source ~/.profile 2>/dev/null || true - # 等待文件系统同步 print_info "等待安装完成..." sleep 3 @@ -1657,7 +1841,7 @@ deploy_acme_zerossl() { echo "" if [[ -d ~/.acme.sh ]]; then - print_info "~/.acme.sh 目录内容:" + print_info "${HOME}/.acme.sh 目录内容:" ls -la ~/.acme.sh/ 2>&1 | head -15 || echo " 无法列出目录" echo "" fi @@ -1785,10 +1969,9 @@ deploy_acme_buypass() { print_info "使用 GitHub 官方源(国内可能较慢,请耐心等待)" - # 使用官方安装方法:直接通过curl管道执行 print_info "正在下载并安装..." - if curl -fsSL https://get.acme.sh | sh -s email=admin@example.com; then + if download_and_run_setup_script "https://get.acme.sh" "email=admin@example.com"; then install_result=$? print_info "安装脚本执行完成,退出码: $install_result" else @@ -1796,9 +1979,6 @@ deploy_acme_buypass() { print_error "安装脚本执行失败,退出码: $install_result" fi - # 重新加载环境变量 - source ~/.bashrc 2>/dev/null || source ~/.profile 2>/dev/null || true - # 等待文件系统同步 print_info "等待安装完成..." sleep 3 @@ -1816,7 +1996,7 @@ deploy_acme_buypass() { echo "" if [[ -d ~/.acme.sh ]]; then - print_info "~/.acme.sh 目录内容:" + print_info "${HOME}/.acme.sh 目录内容:" ls -la ~/.acme.sh/ 2>&1 | head -15 || echo " 无法列出目录" echo "" fi @@ -1928,34 +2108,6 @@ deploy_acme_buypass() { fi } -deploy_aliyun_ssl() { - print_step "使用阿里云免费证书..." - - print_warning "此功能需要您提供阿里云AccessKey" - echo "" - read -p "阿里云AccessKey ID: " ALIYUN_ACCESS_KEY_ID < /dev/tty - read -p "阿里云AccessKey Secret: " ALIYUN_ACCESS_KEY_SECRET < /dev/tty - - # 这里需要调用阿里云API申请证书 - # 暂时返回失败,提示用户使用其他方案 - print_error "阿里云证书申请功能开发中,请选择其他方案" - return 1 -} - -deploy_tencent_ssl() { - print_step "使用腾讯云免费证书..." - - print_warning "此功能需要您提供腾讯云SecretKey" - echo "" - read -p "腾讯云SecretId: " TENCENT_SECRET_ID < /dev/tty - read -p "腾讯云SecretKey: " TENCENT_SECRET_KEY < /dev/tty - - # 这里需要调用腾讯云API申请证书 - # 暂时返回失败,提示用户使用其他方案 - print_error "腾讯云证书申请功能开发中,请选择其他方案" - return 1 -} - deploy_manual_ssl() { print_step "使用已有证书..." @@ -2063,9 +2215,9 @@ configure_admin_account() { continue fi local type_count=0 - [[ "$ADMIN_PASSWORD" =~ [A-Za-z] ]] && ((type_count++)) - [[ "$ADMIN_PASSWORD" =~ [0-9] ]] && ((type_count++)) - [[ "$ADMIN_PASSWORD" =~ [^A-Za-z0-9] ]] && ((type_count++)) + [[ "$ADMIN_PASSWORD" =~ [A-Za-z] ]] && ((type_count += 1)) + [[ "$ADMIN_PASSWORD" =~ [0-9] ]] && ((type_count += 1)) + [[ "$ADMIN_PASSWORD" =~ [^A-Za-z0-9] ]] && ((type_count += 1)) if [[ ${type_count} -lt 2 ]]; then print_error "密码必须包含字母、数字、特殊字符中的至少两种" continue @@ -2191,21 +2343,39 @@ create_env_file() { ALLOWED_ORIGINS_VALUE="${PROTOCOL}://${DOMAIN}:${PORT_VALUE}" fi - print_info "CORS 配置: ${ALLOWED_ORIGINS_VALUE}" + PUBLIC_BASE_URL_VALUE="$ALLOWED_ORIGINS_VALUE" + ALLOWED_HOSTS_VALUE="$DOMAIN" + PUBLIC_PORT_VALUE="$PORT_VALUE" + print_info "公开访问地址: ${PUBLIC_BASE_URL_VALUE}" else - # IP 模式(开发/测试环境) - # 留空,后端默认允许所有来源(适合开发环境) - ALLOWED_ORIGINS_VALUE="" + # IP 模式也必须生成明确的生产来源白名单,否则浏览器 API 请求会被 CORS 拒绝。 + PUBLIC_IP=${PUBLIC_IP:-$(get_public_ip || true)} + if [[ -z "$PUBLIC_IP" ]]; then + print_error "无法确定公开访问 IP,不能生成安全的生产配置" + return 1 + fi + PUBLIC_URL_HOST=$(format_url_host "$PUBLIC_IP") + PORT_VALUE=${HTTP_PORT:-80} + if [[ "$PORT_VALUE" == "80" ]]; then + PUBLIC_BASE_URL_VALUE="http://${PUBLIC_URL_HOST}" + else + PUBLIC_BASE_URL_VALUE="http://${PUBLIC_URL_HOST}:${PORT_VALUE}" + fi + ALLOWED_ORIGINS_VALUE="$PUBLIC_BASE_URL_VALUE" + ALLOWED_HOSTS_VALUE="$PUBLIC_IP" + PUBLIC_PORT_VALUE="$PORT_VALUE" COOKIE_SECURE_VALUE="false" ENFORCE_HTTPS_VALUE="false" - print_warning "IP 模式下 CORS 将允许所有来源(仅适合开发环境)" - print_info "生产环境建议使用域名模式" + print_info "公开访问地址: ${PUBLIC_BASE_URL_VALUE}" fi + ADMIN_USERNAME_ENV=$(dotenv_quote "$ADMIN_USERNAME") + ADMIN_PASSWORD_ENV=$(dotenv_quote "$ADMIN_PASSWORD") + cat > "${PROJECT_DIR}/backend/.env" << EOF # 管理员账号 -ADMIN_USERNAME=${ADMIN_USERNAME} -ADMIN_PASSWORD=${ADMIN_PASSWORD} +ADMIN_USERNAME=${ADMIN_USERNAME_ENV} +ADMIN_PASSWORD=${ADMIN_PASSWORD_ENV} # JWT密钥 JWT_SECRET=${JWT_SECRET} @@ -2226,6 +2396,10 @@ PORT=${BACKEND_PORT} # 环境 NODE_ENV=production +# 公开访问地址(用于邮件、分享和直链) +PUBLIC_BASE_URL=${PUBLIC_BASE_URL_VALUE} +ALLOWED_HOSTS=${ALLOWED_HOSTS_VALUE} + # 强制HTTPS(生产环境建议开启) ENFORCE_HTTPS=${ENFORCE_HTTPS_VALUE} @@ -2248,9 +2422,9 @@ COOKIE_SECURE=${COOKIE_SECURE_VALUE} # 警告:不要设置为 true,这会信任所有代理,存在 IP/协议伪造风险! TRUST_PROXY=1 -# 公开端口(nginx监听的端口,用于生成分享链接) +# 公开端口(Nginx 监听端口) # 如果使用标准端口(80/443)或未配置,分享链接将不包含端口号 -PUBLIC_PORT=${HTTP_PORT} +PUBLIC_PORT=${PUBLIC_PORT_VALUE} # CSRF 保护(生产环境强烈建议开启) # 使用 Double Submit Cookie 模式防止跨站请求伪造攻击 @@ -2266,15 +2440,16 @@ LOG_LEVEL=info LOG_FORMAT=json EOF + chmod 600 "${PROJECT_DIR}/backend/.env" + print_success "配置文件创建完成" # 显示安全提示 - if [[ -z "$ALLOWED_ORIGINS_VALUE" ]]; then + if [[ "$USE_DOMAIN" != "true" ]]; then echo "" print_warning "⚠️ 安全提示:" - print_info " 当前配置允许所有域名访问(CORS: *)" - print_info " 这仅适合开发环境,生产环境存在安全风险" - print_info " 建议在生产环境使用域名模式部署" + print_info " IP 模式使用明文 HTTP,管理员密码和会话不受 TLS 保护" + print_info " 正式对外服务建议改用域名 + HTTPS 模式" echo "" fi echo "" @@ -3049,6 +3224,16 @@ health_check() { fi fi + # 进程在线和端口监听并不代表应用已完成初始化,继续验证真实健康接口。 + if curl -fsS --connect-timeout 3 --max-time 10 \ + "http://127.0.0.1:${BACKEND_PORT}/api/health" > /dev/null; then + print_success "后端健康接口响应正常" + else + print_error "后端健康接口响应异常" + print_info "查看日志: pm2 logs ${PROJECT_NAME}-backend" + return 1 + fi + # 检查数据库 if [[ -f "${PROJECT_DIR}/backend/data/database.db" ]]; then print_success "数据库初始化成功" @@ -3072,7 +3257,7 @@ health_check() { ################################################################################ print_completion() { - clear + clear_screen echo -e "${GREEN}" echo "╔═══════════════════════════════════════════════════════════════╗" echo "║ ║" @@ -3098,11 +3283,13 @@ print_completion() { fi fi else - PUBLIC_IP=$(curl -s ifconfig.me || curl -s icanhazip.com || echo "服务器IP") + PUBLIC_IP=$(get_public_ip || true) + PUBLIC_IP=${PUBLIC_IP:-服务器IP} + PUBLIC_URL_HOST=$(format_url_host "$PUBLIC_IP") if [[ "$HTTP_PORT" == "80" ]]; then - echo -e "${CYAN}访问地址:${NC} http://${PUBLIC_IP}" + echo -e "${CYAN}访问地址:${NC} http://${PUBLIC_URL_HOST}" else - echo -e "${CYAN}访问地址:${NC} http://${PUBLIC_IP}:${HTTP_PORT}" + echo -e "${CYAN}访问地址:${NC} http://${PUBLIC_URL_HOST}:${HTTP_PORT}" fi fi @@ -3158,7 +3345,7 @@ print_completion() { ################################################################################ print_uninstall_banner() { - clear + clear_screen echo -e "${RED}" echo "╔═══════════════════════════════════════════════════════════════╗" echo "║ ║" @@ -3395,7 +3582,7 @@ uninstall_check_residual() { } print_uninstall_completion() { - clear + clear_screen echo -e "${GREEN}" echo "╔═══════════════════════════════════════════════════════════════╗" echo "║ ║" @@ -3434,7 +3621,7 @@ print_uninstall_completion() { ################################################################################ print_update_banner() { - clear + clear_screen echo -e "${BLUE}" echo "╔═══════════════════════════════════════════════════════════════╗" echo "║ ║" @@ -3870,7 +4057,7 @@ update_migrate_database() { if [[ $REPLY =~ ^[Yy]$ ]]; then # 备份原配置 - cp .env .env.backup.$(date +%Y%m%d_%H%M%S) + cp .env ".env.backup.$(date +%Y%m%d_%H%M%S)" # 修复CORS配置 sed -i "s|^ALLOWED_ORIGINS=.*|ALLOWED_ORIGINS=${FIXED_CORS}|" .env @@ -3946,7 +4133,7 @@ update_check_version() { } print_update_completion() { - clear + clear_screen echo -e "${GREEN}" echo "╔═══════════════════════════════════════════════════════════════╗" echo "║ ║" @@ -4152,10 +4339,17 @@ main() { # 检查root权限 check_root + # 安装过程需要读取软件源、端口和管理员密码,必须有真实控制终端。 + if ! has_controlling_terminal; then + print_error "安装需要交互终端,当前环境无法读取 /dev/tty" + print_info "请先下载脚本,再在 SSH 终端中执行: bash install.sh" + exit 1 + fi + # 如果没有通过命令行参数指定模式,则显示交互式选择 if [[ "$MODE" == "install" ]] && [[ "$1" != "--skip-mode-select" ]]; then # 检测是否可以使用交互式输入 - if [[ -t 0 ]] || [[ -c /dev/tty ]]; then + if has_controlling_terminal; then print_step "请选择操作模式" echo "" echo -e "${GREEN}[1]${NC} 安装/部署 玩玩云" @@ -4324,7 +4518,7 @@ uninstall_main() { ################################################################################ print_repair_banner() { - clear + clear_screen echo -e "${BLUE}" echo "╔═══════════════════════════════════════════════════════════════╗" echo "║ ║" @@ -4525,7 +4719,7 @@ repair_verify_services() { } print_repair_completion() { - clear + clear_screen echo -e "${GREEN}" echo "╔═══════════════════════════════════════════════════════════════╗" echo "║ ║" @@ -4563,11 +4757,13 @@ print_repair_completion() { fi fi else - PUBLIC_IP=$(curl -s ifconfig.me || curl -s icanhazip.com || echo "服务器IP") + PUBLIC_IP=$(get_public_ip || true) + PUBLIC_IP=${PUBLIC_IP:-服务器IP} + PUBLIC_URL_HOST=$(format_url_host "$PUBLIC_IP") if [[ "$HTTP_PORT" == "80" ]]; then - echo -e "${CYAN}访问地址:${NC} http://${PUBLIC_IP}" + echo -e "${CYAN}访问地址:${NC} http://${PUBLIC_URL_HOST}" else - echo -e "${CYAN}访问地址:${NC} http://${PUBLIC_IP}:${HTTP_PORT}" + echo -e "${CYAN}访问地址:${NC} http://${PUBLIC_URL_HOST}:${HTTP_PORT}" fi fi echo "" @@ -4628,7 +4824,7 @@ repair_main() { ################################################################################ print_ssl_banner() { - clear + clear_screen echo -e "${GREEN}" echo "╔═══════════════════════════════════════════════════════════════╗" echo "║ ║" @@ -4782,7 +4978,7 @@ ssl_configure_domain() { # 验证域名解析 print_info "正在验证域名解析..." DOMAIN_IP=$(dig +short "$DOMAIN" 2>/dev/null | tail -n1 || nslookup "$DOMAIN" 2>/dev/null | grep -A1 "Name:" | tail -1 | awk '{print $2}') - PUBLIC_IP=$(curl -s ifconfig.me || curl -s icanhazip.com || echo "") + PUBLIC_IP=$(get_public_ip || true) if [[ -n "$DOMAIN_IP" ]] && [[ "$DOMAIN_IP" == "$PUBLIC_IP" ]]; then print_success "域名已正确解析到当前服务器IP" @@ -4818,10 +5014,6 @@ ssl_choose_method() { echo -e "${GREEN}[5]${NC} acme.sh + Buypass" echo " - 挪威免费CA,有效期180天" echo "" - echo -e "${YELLOW}【云服务商证书】${NC}" - echo -e "${GREEN}[4]${NC} 阿里云免费证书 (需提供AccessKey)" - echo -e "${GREEN}[6]${NC} 腾讯云免费证书 (需提供SecretKey)" - echo "" echo -e "${YELLOW}【其他选项】${NC}" echo -e "${GREEN}[7]${NC} 使用已有证书 (手动上传)" echo -e "${GREEN}[8]${NC} 移除HTTPS配置 (改回HTTP)" @@ -4831,7 +5023,7 @@ ssl_choose_method() { while true; do read -p "请输入选项 [0-8]: " ssl_choice < /dev/tty case $ssl_choice in - 1|2|3|4|5|6|7) + 1|2|3|5|7) SSL_METHOD=$ssl_choice break ;; @@ -4971,7 +5163,7 @@ ssl_verify_deployment() { } print_ssl_completion() { - clear + clear_screen echo -e "${GREEN}" echo "╔═══════════════════════════════════════════════════════════════╗" echo "║ ║" @@ -5089,5 +5281,5 @@ elif [[ "$MODE" == "repair" ]]; then elif [[ "$MODE" == "ssl" ]]; then ssl_main else - main + main "$@" fi