From 3bcad138a1544831869bb19533289df30cf7406f Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sun, 9 Aug 2026 21:29:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(ops):=20=E5=8A=A0=E5=9B=BA=E9=83=A8?= =?UTF-8?q?=E7=BD=B2/=E4=BB=A3=E7=90=86/=E8=BF=81=E7=A7=BB=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=EF=BC=8C=E7=94=9F=E4=BA=A7=E7=8E=AF=E5=A2=83=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E9=BB=98=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复: - serve-proxy:API_TARGET 生效、SPA 404 语义、流式静态文件、hop-by-hop/超时/断连/穿越防护 - migrate.sh:密码不进 argv/不泄漏 pm2、原子锁防重入、DML-only 事务说明、就绪诊断 - deploy.sh/CI:保护 .env、总是 npm ci(迁移依赖 devDeps)、健康检查、并发锁 - ecosystem/oxlint/.env.example:优雅停机、React 版本对齐、TRUST_PROXY 说明 Reviewed-by: OCR (open-codereview.ai) --- .env.example | 8 ++ .gitea/workflows/deploy.yml | 20 ++- deploy.sh | 92 +++++++++++-- ecosystem.config.cjs | 12 +- migrate.sh | 147 +++++++++++++++++++-- oxlint.config.ts | 5 +- serve-proxy.js | 249 ++++++++++++++++++++++++++++++++---- 7 files changed, 476 insertions(+), 57 deletions(-) diff --git a/.env.example b/.env.example index dc86107..1fd26cc 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,11 @@ AI_CONFIG_ENCRYPTION_KEY= # 允许内网地址作为 OPENAI_COMPATIBLE 的 baseUrl(仅内网部署使用) # AI_ALLOW_PRIVATE_BASE_URL=true + +# ---- 安全 ---- +# 是否信任反向代理的 X-Forwarded-For / X-Real-IP(仅当部署在可信代理/Nginx 后时才设 true; +# 不设置时服务端只用 TCP socket 地址,防止伪造客户端 IP) +# TRUST_PROXY=true + +# 说明:第三方集成配置(钉钉/企微 appSecret)的静态加密复用 AI_CONFIG_ENCRYPTION_KEY, +# 存量明文可用 `cd apps/server && npm run encrypt:integration-secrets` 一次性加密。 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index fd42e20..aa8958a 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -16,6 +16,10 @@ name: PM2 部署 on: workflow_dispatch: +concurrency: + group: deploy + cancel-in-progress: false + jobs: deploy: runs-on: ubuntu-latest @@ -37,7 +41,10 @@ jobs: - name: 配置 SSH run: | mkdir -p ~/.ssh - echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key + # 单引号 EOF 防止 key 中的 $ / 反引号被 shell 插值(YAML 会剥掉缩进,bash 实际收到列首 EOF) + cat > ~/.ssh/deploy_key <<'EOF' + ${{ secrets.SSH_PRIVATE_KEY }} + EOF chmod 600 ~/.ssh/deploy_key cat >> ~/.ssh/config <<'EOF' Host deploy-server @@ -59,17 +66,20 @@ jobs: --exclude='.turbo/' \ --exclude='.claude/' \ --exclude='.codegraph/' \ + --exclude='.env*' \ + --exclude='data.sql' \ + --exclude='uploads/' \ ./ deploy-server:${{ secrets.REMOTE_DIR }}/ - name: 安装依赖 → 迁移 → PM2 重载 run: | ssh deploy-server " + set -e cd ${{ secrets.REMOTE_DIR }} mkdir -p logs - if [ ! -d node_modules ]; then - echo '首次部署,安装生产依赖...' - npm ci --omit=dev - fi + echo '安装依赖...' + # 注意:migration:run 依赖 ts-node/tsconfig-paths(devDependencies),不能 --omit=dev + npm ci echo '执行数据库迁移...' npm run migration:run -w @gongxue/server echo 'PM2 重载...' diff --git a/deploy.sh b/deploy.sh index 45a0e90..1da249c 100755 --- a/deploy.sh +++ b/deploy.sh @@ -5,40 +5,112 @@ set -euo pipefail SSH_HOST="${1:-tencent}" + +# 校验 SSH_HOST:非空、不以 - 开头(防被 ssh/rsync 当作选项)、仅允许安全字符(防命令注入) +if [[ -z "${SSH_HOST}" ]]; then + echo "错误:SSH_HOST 不能为空" >&2 + exit 1 +fi +if [[ "${SSH_HOST}" == -* ]]; then + echo "错误:SSH_HOST 不能以 - 开头(会被 ssh/rsync 当作命令行选项)" >&2 + exit 1 +fi +if [[ ! "${SSH_HOST}" =~ ^[A-Za-z0-9._:@%+=,-]+$ ]]; then + echo "错误:SSH_HOST 含空白或 shell 元字符(仅允许字母、数字及 . _ : @ % + = , -)" >&2 + exit 1 +fi REMOTE_DIR="/opt/gongxue" -echo "=== 1/4 本地构建后端 ===" +# 调用锁:mkdir 原子创建,已存在即代表已有部署在跑,直接退出 +LOCK_DIR="/tmp/gongxue-deploy.lock" +if ! mkdir "$LOCK_DIR" 2>/dev/null; then + echo "检测到 $LOCK_DIR,已有部署在运行。若确认没有其他部署,请手动删除该锁目录。" >&2 + exit 1 +fi +release_deploy_lock() { + rmdir "$LOCK_DIR" 2>/dev/null || true +} +trap release_deploy_lock EXIT + +echo "=== 1/5 本地构建后端 ===" npm run build -w @gongxue/server -echo "=== 2/4 本地构建前端 ===" +echo "=== 2/5 本地构建前端 ===" npm run build -w @gongxue/admin -echo "=== 3/4 同步到 ${SSH_HOST} ===" +echo "=== 3/5 同步到 ${SSH_HOST} ===" +# --delete 保留,但必须排除服务器端独有文件(.env、数据、上传目录),防止被清掉 rsync -avz --delete \ + -e "ssh -o BatchMode=yes -o ConnectTimeout=10" \ --exclude='node_modules' \ --exclude='.git' \ --exclude='*.db' \ --exclude='.DS_Store' \ --exclude='logs/' \ --exclude='.turbo/' \ + --exclude='.env*' \ + --exclude='data.sql' \ + --exclude='uploads/' \ ./ "${SSH_HOST}:${REMOTE_DIR}/" -echo "=== 4/4 安装依赖 → 迁移 → PM2 重载 ===" -ssh "${SSH_HOST}" " +echo "=== 4/5 安装依赖 → 迁移 → PM2 重载 ===" +ssh -o BatchMode=yes -o ConnectTimeout=10 "${SSH_HOST}" " + set -e cd ${REMOTE_DIR} mkdir -p logs - if [ ! -d node_modules ]; then - echo '首次部署,安装依赖...' - npm ci --omit=dev - fi + echo '安装依赖...' + # 注意:migration:run 依赖 ts-node/tsconfig-paths(devDependencies),不能 --omit=dev + npm ci echo '执行数据库迁移...' npm run migration:run -w @gongxue/server echo 'PM2 重载...' pm2 startOrReload ecosystem.config.cjs --update-env pm2 save + echo '=== PM2 状态 ===' pm2 status " +echo "=== 5/5 健康检查 ===" +# 后端未提供 /api/health 等专用健康端点,改用 pm2 jlist(JSON)按 name 查找 gongxue-backend: +# 判断 status === 'online' 且 unstable_restarts === 0。 +# 说明: +# - restart_time 是累计重启次数(正常滚动/长期运行也会累计),不适合做健康阈值,改用 unstable_restarts。 +# - online ≠ healthy:online 只代表 PM2 认为进程存活;若后续后端提供健康端点,应优先 curl 探测。 +# 带重试以覆盖 PM2 reload 的 listen_timeout(8s)窗口;应用不在线则 exit 1,部署失败。 +if ! ssh -o BatchMode=yes -o ConnectTimeout=10 "${SSH_HOST}" bash -s <<'REMOTE_HEALTH' +set -euo pipefail +HEALTHY=0 +for _i in $(seq 1 15); do + if pm2 jlist 2>/dev/null | node -e ' + let data = ""; + process.stdin.on("data", (c) => (data += c)); + process.stdin.on("end", () => { + const apps = JSON.parse(data || "[]"); + const app = apps.find((a) => a && a.name === "gongxue-backend"); + const env = (app || {}).pm2_env || {}; + if (env.status === "online" && Number(env.unstable_restarts || 0) === 0) { + process.exit(0); + } + process.exit(1); + }); + '; then + HEALTHY=1 + break + fi + sleep 2 +done +if [ "${HEALTHY}" -ne 1 ]; then + echo "错误:后端进程未就绪(gongxue-backend 非 online 或存在不稳定重启)" >&2 + pm2 status >&2 + exit 1 +fi +echo "健康检查通过:gongxue-backend 在线" +REMOTE_HEALTH +then + echo "错误:健康检查失败,部署中止" >&2 + exit 1 +fi + echo "" echo "部署完成!" -echo "访问: http://$(ssh "${SSH_HOST}" 'hostname -I 2>/dev/null | awk "{print \$1}" || curl -s ifconfig.me')" +echo "访问: http://$(ssh -o BatchMode=yes -o ConnectTimeout=10 "${SSH_HOST}" 'hostname -I 2>/dev/null | awk "{print \$1}" || curl -s ifconfig.me')" diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index a1bce54..5bc2f8e 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -4,7 +4,10 @@ // 生产环境配置写在项目根目录 .env,由 Nest ConfigModule 读取 // 前端构建产物 apps/admin/dist 交给 Nginx 托管,不再用 PM2 启动前端 +const path = require('path'); + const DEPLOY_DIR = process.env.DEPLOY_DIR || __dirname; +const LOG_DIR = path.join(DEPLOY_DIR, 'logs'); module.exports = { apps: [ @@ -21,9 +24,12 @@ module.exports = { max_memory_restart: '512M', max_restarts: 10, restart_delay: 5000, - // 日志 - error_file: 'logs/backend-error.log', - out_file: 'logs/backend-out.log', + // 优雅停机:给 Nest 时间排空请求/连接 + kill_timeout: 5000, + listen_timeout: 8000, + // 日志(绝对路径,与启动目录无关) + error_file: path.join(LOG_DIR, 'backend-error.log'), + out_file: path.join(LOG_DIR, 'backend-out.log'), log_date_format: 'YYYY-MM-DD HH:mm:ss', autorestart: true, watch: false, diff --git a/migrate.sh b/migrate.sh index 6d303cc..bd01ce9 100755 --- a/migrate.sh +++ b/migrate.sh @@ -5,33 +5,158 @@ # 2. ./migrate.sh /path/to/dump.sql.gz set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# 数据库名:旧库(导入源)与新库(应用目标)。 +# 注意:migrate-legacy.sql 内部仍固定写入 gongxue(未改 SQL 文件), +# 若修改 TARGET_DB 需同步调整该 SQL 文件,保持两者一致。 +LEGACY_DB="dorm_billing" +TARGET_DB="gongxue" + DUMP="${1:-}" if [ -z "$DUMP" ]; then echo "用法: ./migrate.sh /path/to/dump.sql.gz" echo "示例: ./migrate.sh ~/Downloads/dorm_billing_2026-07-06_01-30-02_mysql_data.sql.gz" exit 1 fi +# 相对路径转绝对路径:必须在 cd 之前转换,否则 cd 到脚本目录后相对路径会失效 +if [[ "$DUMP" != /* ]]; then + DUMP="$(pwd)/$DUMP" +fi +# 校验 dump 文件确实存在,避免 gunzip 报出难以理解的错误 +if [ ! -f "$DUMP" ]; then + echo "错误: dump 文件不存在: $DUMP" >&2 + exit 1 +fi -# 从 .env 读取密码 -MYSQL_PASS="${MYSQL_ROOT_PASSWORD:-gongxue_2024}" +# 先切到脚本目录:docker compose exec 与 pm2 startOrReload 都按调用者 cwd 解析, +# 不 cd 的话从其他目录调用会找不到 compose 文件 / ecosystem.config.cjs。 +cd "$SCRIPT_DIR" + +# 关键文件存在性预检,避免 set -euo pipefail 下出现难懂的错误 +if [ ! -f "$SCRIPT_DIR/migrate-legacy.sql" ]; then + echo "错误: 缺少 $SCRIPT_DIR/migrate-legacy.sql" >&2 + exit 1 +fi +if [ ! -f "$SCRIPT_DIR/ecosystem.config.cjs" ]; then + echo "错误: 缺少 $SCRIPT_DIR/ecosystem.config.cjs" >&2 + exit 1 +fi + +# .migrate.done 仅作为「已完成」提示:脚本成功结束时 touch; +# 开头若已存在则提示并退出(真正的防重入由下面的原子锁保证)。 +if [ -f "$SCRIPT_DIR/.migrate.done" ]; then + echo "检测到 $SCRIPT_DIR/.migrate.done,迁移已完成。如需重新迁移,请先删除该标记文件。" >&2 + # “已完成”是正常提示,不是错误,返回 0 + exit 0 +fi + +# 防重入:mkdir 原子锁。mkdir 创建目录是原子的,同一路径已存在即失败, +# 失败即代表已有实例正在运行。macOS 不自带 flock(1)(flock 是 Linux 专有命令), +# 所以不用 flock,改用目录锁;trap EXIT 保证成功/失败(含 ERR/INT/TERM 后的退出)都 rmdir 释放。 +if ! mkdir "$SCRIPT_DIR/.migrate.lock" 2>/dev/null; then + echo "检测到 $SCRIPT_DIR/.migrate.lock,已有迁移实例在运行。若确认无实例,请手动删除该锁目录。" >&2 + exit 1 +fi +release_lock() { + rmdir "$SCRIPT_DIR/.migrate.lock" 2>/dev/null || true +} +trap release_lock EXIT + +# 密码只通过 MYSQL_PWD 环境变量传递,绝不拼进命令行(避免出现在 ps aux / 日志) +if [ -z "${MYSQL_ROOT_PASSWORD:-}" ]; then + echo "错误: 请先设置环境变量 MYSQL_ROOT_PASSWORD(不要使用硬编码兜底密码)" >&2 + exit 1 +fi + +# .env 缺失时 sed 会非零退出,在 set -euo pipefail 下会直接终止脚本; +# 用 || true 兜底,缺失时回退到默认 ${LEGACY_DB}(现有逻辑保持不变)。 +# 用子 shell 限定 MYSQL_PWD 作用域: +# - 不拼进命令行(避免出现在 ps aux / cmdline) +# - 不全局 export(避免泄漏给 pm2 启动的后端进程) +run_mysql() { + ( + export MYSQL_PWD="$MYSQL_ROOT_PASSWORD" + docker compose exec -T -e MYSQL_PWD mysql mysql -u root "$@" + ) +} + +APP_STARTED=0 +# 失败时确保应用停止,避免留下半启动状态 +cleanup_on_error() { + if [ "$APP_STARTED" = "1" ]; then + echo "检测到失败,停止 gongxue-backend..." >&2 + pm2 stop gongxue-backend 2>/dev/null || true + fi +} +trap cleanup_on_error ERR INT TERM echo "=== 1/4 导入旧 dump ===" -gunzip -c "$DUMP" | docker compose exec -T mysql mysql -u root -p"${MYSQL_PASS}" dorm_billing -echo "旧库导入完成: $(docker compose exec -T mysql mysql -u root -p"${MYSQL_PASS}" -e 'SELECT COUNT(*) AS cnt FROM dorm_billing.students' -N)" +# 全新 MySQL 可能还没有 ${LEGACY_DB} schema,直接 mysql "${LEGACY_DB}" 会失败; +# run_mysql 是连 mysql 不带库名,先确保库存在再导入。 +run_mysql -e "CREATE DATABASE IF NOT EXISTS ${LEGACY_DB}; CREATE DATABASE IF NOT EXISTS ${TARGET_DB}" +gunzip -c "$DUMP" | run_mysql "$LEGACY_DB" +IMPORTED_COUNT="$(run_mysql -N -e "SELECT COUNT(*) FROM ${LEGACY_DB}.students" 2>/dev/null || true)" +if [ -z "${IMPORTED_COUNT}" ] || ! [[ "${IMPORTED_COUNT}" =~ ^[0-9]+$ ]]; then + echo "警告: 无法读取 ${LEGACY_DB}.students 计数(dump 可能不含该表或查询失败)" >&2 + IMPORTED_COUNT="未知" +fi +echo "旧库导入完成: ${IMPORTED_COUNT}" echo "=== 2/4 启动应用(创建新表结构) ===" -# 短暂启动让 TypeORM synchronize 创建表,然后停掉 -DB_SYNCHRONIZE=true pm2 start ecosystem.config.cjs --only gongxue-backend -sleep 8 -pm2 stop gongxue-backend +# 短暂启动让 TypeORM synchronize 创建表,然后停掉。 +# --update-env 确保 DB_SYNCHRONIZE=true 真正传给已在运行的 pm2 进程(startOrReload 默认不刷新环境变量)。 +APP_STARTED=1 +DB_SYNCHRONIZE=true pm2 startOrReload ecosystem.config.cjs --only gongxue-backend --update-env + +wait_for_tables() { + local tries=40 + # migrate-legacy.sql 内部仍固定写入 gongxue(未改 SQL 文件),默认值必须与 TARGET_DB 一致; + # 就绪检查只校验 ${TARGET_DB},不回退到 DB_DATABASE。 + echo "等待应用创建表结构(${TARGET_DB})..." + local i table_count err_file + err_file="$SCRIPT_DIR/.migrate.err" + : > "$err_file" + for i in $(seq 1 "$tries"); do + # 读入变量判断,避免 grep -q 触发 SIGPIPE;stderr 先收集,超时时输出便于诊断(如密码错误/容器未启动) + table_count="$(run_mysql -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='${TARGET_DB}' AND table_name IN ('users','tenants','operation_logs','students','rooms')" 2>>"$err_file" || true)" + if [ "$table_count" = "5" ]; then + echo "表结构就绪(${TARGET_DB}: users/tenants/operation_logs/students/rooms)" + return 0 + fi + sleep 2 + done + echo "错误: 等待应用创建表结构超时(${TARGET_DB})" >&2 + if [ -s "$err_file" ]; then + echo "--- 期间 MySQL 错误(可能原因:密码错误/容器未启动/连接失败)---" >&2 + cat "$err_file" >&2 + fi + return 1 +} +wait_for_tables +pm2 stop gongxue-backend 2>/dev/null || true +APP_STARTED=0 echo "=== 3/4 执行数据迁移 ===" -docker compose exec -T mysql mysql -u root -p"${MYSQL_PASS}" < migrate-legacy.sql +# 包进事务,中途失败时自动回滚(连接断开未提交即回滚)。 +# 注意:事务只对 DML 生效;migrate-legacy.sql 目前全部是 INSERT(无 DDL), +# 若将来加入 CREATE/ALTER/DROP 等 DDL,MySQL 会隐式提交,该回滚保证不再成立,需拆分 DDL/DML。 +{ + echo 'START TRANSACTION;' + cat "$SCRIPT_DIR/migrate-legacy.sql" + # 用 printf 保证 COMMIT 前有换行,避免粘到 SQL 最后一行 + printf '\nCOMMIT;\n' +} | run_mysql + +# 数据已提交:立即写「已完成」标记,之后重启失败也不应重跑导入(重跑会重复导入/重复 INSERT) +touch "$SCRIPT_DIR/.migrate.done" echo "=== 4/4 重启应用 ===" +# --update-env 确保 .env / 环境变量变更(如 DB_SYNCHRONIZE=false)对已在运行的进程生效 pm2 startOrReload ecosystem.config.cjs --update-env pm2 save + echo "" -echo "迁移完成!运行以下验证:" -echo " docker compose exec mysql mysql -u root -p${MYSQL_PASS} gongxue -e 'SELECT COUNT(*) FROM students'" +echo "迁移完成!运行以下验证(密码通过 MYSQL_PWD 传入,不会出现在进程列表):" +echo " export MYSQL_PWD=\"\$MYSQL_ROOT_PASSWORD\"; docker compose exec -T -e MYSQL_PWD mysql mysql -u root "${TARGET_DB}" -e 'SELECT COUNT(*) FROM students'" diff --git a/oxlint.config.ts b/oxlint.config.ts index 0241dbd..f328af3 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -3,12 +3,13 @@ import type { OxlintConfig } from 'oxlint'; const config: OxlintConfig = { plugins: ['typescript', 'react', 'import'], rules: { - 'typescript/no-explicit-any': 'off', + 'typescript/no-explicit-any': 'warn', 'typescript/no-non-null-assertion': 'warn', }, settings: { react: { - version: '19.0.0', + // 与仓库实际安装的 React 版本保持一致(apps/admin: ^19.2.5 → 19.2.7) + version: '19.2.7', }, }, }; diff --git a/serve-proxy.js b/serve-proxy.js index c9baf86..8a70f72 100644 --- a/serve-proxy.js +++ b/serve-proxy.js @@ -1,12 +1,28 @@ // 轻量静态文件 + API 代理服务器 // PM2 启动: node serve-proxy.js const http = require('http'); -const fs = require('fs'); +const https = require('https'); +const fs = require('fs/promises'); +const fsStream = require('fs'); const path = require('path'); -const PORT = process.env.FRONTEND_PORT || 5173; -const API_TARGET = process.env.API_TARGET || 'http://127.0.0.1:3000'; -const STATIC_DIR = path.join(__dirname, 'apps/admin/dist'); +// FRONTEND_PORT 非法(非整数)或 ≤0 时回退 5173,不抛错 +const parsedPort = Number(process.env.FRONTEND_PORT || 5173); +const PORT = Number.isInteger(parsedPort) && parsedPort > 0 ? parsedPort : 5173; +// API_TARGET 解析失败或协议不是 http/https 时回退默认值,不抛错(与 FRONTEND_PORT 回退风格一致) +let API_TARGET; +try { + const parsed = new URL(process.env.API_TARGET || 'http://127.0.0.1:3000'); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`API_TARGET 协议仅支持 http/https: ${parsed.protocol}`); + } + API_TARGET = parsed; +} catch { + API_TARGET = new URL('http://127.0.0.1:3000'); +} +const STATIC_DIR = process.env.FRONTEND_DIR + ? path.resolve(process.env.FRONTEND_DIR) + : path.join(__dirname, 'apps/admin/dist'); const MIME = { '.html': 'text/html; charset=utf-8', @@ -19,49 +35,230 @@ const MIME = { '.woff2': 'font/woff2', }; -function serveStatic(res, filePath) { +// RFC 7230 hop-by-hop headers — 不能透传给客户端 +const HOP_BY_HOP = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); + +function stripHopByHop(headers) { + const connection = headers.connection; + const out = { ...headers }; + for (const name of HOP_BY_HOP) delete out[name]; + if (typeof connection === 'string') { + for (const name of connection.split(',')) delete out[name.trim().toLowerCase()]; + } + return out; +} + +async function serveStatic(res, filePath) { + // 客户端断开/写失败兜底:所有分支(成功/404/SPA fallback/500)共用,避免 uncaughtException + res.on('error', () => {}); const ext = path.extname(filePath); const mime = MIME[ext] || 'application/octet-stream'; + let stat; try { - const content = fs.readFileSync(filePath); - res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=604800' }); - res.end(content); + stat = await fs.stat(filePath); } catch { - // SPA fallback: return index.html - const index = fs.readFileSync(path.join(STATIC_DIR, 'index.html')); - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(index); + // SPA fallback 只用于导航请求(无扩展名或 index.html);缺失的静态资源(.js/.css 等)必须 404 + const isNavigation = ext === '' || path.basename(filePath) === 'index.html'; + if (!isNavigation) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + return; + } + // SPA fallback 的 index.html 单文件较小,保留 readFile 全量读取 + let content; + try { + content = await fs.readFile(path.join(STATIC_DIR, 'index.html')); + } catch { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + return; + } + // SPA fallback 固定按 HTML 返回,且不能长缓存 + res.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-cache', + }); + res.end(content); + return; } + // 目录请求:stat 会成功但 createReadStream 会 EISDIR,必须在写头前拦截 + if (!stat.isFile()) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + return; + } + // 防 symlink 穿越:stat/createReadStream 会跟随符号链接,realpath 解析后必须仍落在 STATIC_DIR 内 + try { + const [realRoot, realFile] = await Promise.all([ + fs.realpath(STATIC_DIR), + fs.realpath(filePath), + ]); + if (realFile !== realRoot && !realFile.startsWith(realRoot + path.sep)) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + return; + } + } catch { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + return; + } + // 静态文件流式化:先 stat 拿大小并写头(含 Content-Length),再 pipe 读流,避免全量缓冲 + res.writeHead(200, { + 'Content-Type': mime, + 'Content-Length': stat.size, + 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=604800', + }); + const stream = fsStream.createReadStream(filePath); + // 读流中途出错:头未发送时回 404/500,已发送则只能销毁连接 + stream.on('error', () => { + if (!res.headersSent) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + return; + } + res.destroy(); + }); + // 客户端提前断开/连接关闭:销毁底层读流,避免 fd/socket 泄漏 + res.on('close', () => stream.destroy()); + stream.pipe(res); } const server = http.createServer((req, res) => { + // /api 精确匹配(不带尾斜杠):不代理,也不走 SPA fallback。 + // 若落到 SPA fallback 会返回 HTML 200,误导 API 客户端以为存在资源,直接 404 更明确。 + if (req.url.split('?')[0] === '/api') { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + return; + } + // API 代理 if (req.url.startsWith('/api/')) { + const client = API_TARGET.protocol === 'https:' ? https : http; const opts = { - hostname: '127.0.0.1', - port: 3000, - path: req.url, + hostname: API_TARGET.hostname, + port: API_TARGET.port || (API_TARGET.protocol === 'https:' ? 443 : 80), + // 去掉 API_TARGET.pathname 的尾斜杠,避免拼出 /base//api/... 双斜杠;pathname 为 '/' 时得到空串 + path: (API_TARGET.pathname.replace(/\/+$/, '') || '') + req.url, method: req.method, - headers: { ...req.headers, host: '127.0.0.1:3000' }, + headers: { + ...stripHopByHop(req.headers), + host: API_TARGET.host, + // 代理是部署网络内的可信边界:补充客户端真实 IP/协议供后端审计与限流使用 + // 注意:不能赋 undefined(Node setHeader 会抛 ERR_HTTP_INVALID_HEADER_VALUE),为空时干脆不设 + ...(req.socket?.remoteAddress + ? { 'x-forwarded-for': String(req.socket.remoteAddress).split(',')[0].trim() } + : {}), + 'x-forwarded-proto': 'http', + }, }; - const proxy = http.request(opts, (proxyRes) => { - res.writeHead(proxyRes.statusCode, proxyRes.headers); - proxyRes.pipe(res); - }); + let proxy; + let timedOut = false; + try { + proxy = client.request(opts, (proxyRes) => { + // 响应头一到就清除 30s 超时:超时只在「等待响应头」阶段生效, + // 避免大文件下载中途被空闲超时截断 + proxy.setTimeout(0); + // 上游中途断开:避免未监听 error 事件导致进程崩溃。 + // res 可能已被 proxy.on('error') 分支销毁(双触发),先判断避免二次 destroy。 + proxyRes.on('error', () => { + if (res.destroyed) return; + res.destroy(); + }); + try { + // 上游响应头含非法字符(如 ERR_INVALID_CHAR)时 writeHead 会抛错,需兜底 + res.writeHead(proxyRes.statusCode, stripHopByHop(proxyRes.headers)); + proxyRes.pipe(res); + } catch { + // 异常时若头未发送回 502,已发送则销毁连接;同时释放上游 socket + proxyRes.unpipe(res); + proxy.destroy(); + proxyRes.destroy(); + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('API unavailable'); + } else { + res.destroy(); + } + } + }); + } catch { + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('API unavailable'); + } else { + res.destroy(); + } + return; + } + // 客户端断开/上游异常时 res 可能抛错,兜底监听防 uncaughtException + res.on('error', () => {}); proxy.on('error', () => { - res.writeHead(502); - res.end('API unavailable'); + // 超时回调已自行回 504 并 destroy,这里直接忽略,避免竞态下重复写响应 + if (timedOut) return; + // proxyRes.on('error') 分支可能已销毁 res(双触发),避免对已销毁响应二次写/destroy + if (res.destroyed) return; + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('API unavailable'); + } else { + res.destroy(); + } + }); + // 客户端断开:销毁上游请求,避免 socket 泄漏。 + // 注意:Node 中 req 在「请求体正常接收完毕」时也会触发 close(并非只有客户端断开), + // 无条件销毁会把仍在等待上游响应的正常请求误杀(上游 ECONNRESET → 502), + // 因此仅在连接确实已关闭(socket 已销毁或响应侧已销毁)时才销毁上游。 + req.on('close', () => { + if (req.socket?.destroyed || res.destroyed) proxy.destroy(); + }); + req.on('error', () => proxy.destroy()); + // 响应侧兜底:连接在响应写完前关闭(writableFinished=false)即客户端提前断开, + // 这是最可靠的断开信号(覆盖 req close/aborted 场景),此时销毁上游避免 socket 泄漏。 + res.on('close', () => { + if (!res.writableFinished) proxy.destroy(); + }); + // 上游 30s 无响应视为超时:先标记并 destroy(不带 error,避免触发 error 处理器二次写响应),再显式回 504 + proxy.setTimeout(30000, () => { + timedOut = true; + proxy.destroy(); + if (!res.headersSent) { + res.writeHead(504, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('API timeout'); + } else { + res.destroy(); + } }); req.pipe(proxy); return; } - // 静态文件 + // 静态文件(防目录穿越) const urlPath = req.url === '/' ? '/index.html' : req.url.split('?')[0]; - const safePath = path.normalize(urlPath).replace(/^(\.\.(\/|\\|$))+/, ''); - serveStatic(res, path.join(STATIC_DIR, safePath)); + const filePath = path.normalize(path.join(STATIC_DIR, urlPath)); + if (filePath !== STATIC_DIR && !filePath.startsWith(STATIC_DIR + path.sep)) { + res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Forbidden'); + return; + } + serveStatic(res, filePath).catch(() => { + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }); + } + res.end('Internal Server Error'); + }); }); server.listen(PORT, () => { - process.stdout.write(`Frontend proxy running on http://0.0.0.0:${PORT} → API: ${API_TARGET}\n`); + process.stdout.write(`Frontend proxy running on http://0.0.0.0:${PORT} → API: ${API_TARGET.href}\n`); });