fix(ops): 加固部署/代理/迁移脚本,生产环境安全默认

由 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)
This commit is contained in:
2026-08-09 21:29:22 +08:00
parent 9565a0f23c
commit 3bcad138a1
7 changed files with 476 additions and 57 deletions

View File

@@ -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` 一次性加密。

View File

@@ -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-pathsdevDependencies不能 --omit=dev
npm ci
echo '执行数据库迁移...'
npm run migration:run -w @gongxue/server
echo 'PM2 重载...'

View File

@@ -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-pathsdevDependencies不能 --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 jlistJSON按 name 查找 gongxue-backend
# 判断 status === 'online' 且 unstable_restarts === 0。
# 说明:
# - restart_time 是累计重启次数(正常滚动/长期运行也会累计),不适合做健康阈值,改用 unstable_restarts。
# - online ≠ healthyonline 只代表 PM2 认为进程存活;若后续后端提供健康端点,应优先 curl 探测。
# 带重试以覆盖 PM2 reload 的 listen_timeout8s窗口应用不在线则 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')"

View File

@@ -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,

View File

@@ -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 触发 SIGPIPEstderr 先收集,超时时输出便于诊断(如密码错误/容器未启动)
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 等 DDLMySQL 会隐式提交,该回滚保证不再成立,需拆分 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'"

View File

@@ -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',
},
},
};

View File

@@ -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/协议供后端审计与限流使用
// 注意:不能赋 undefinedNode 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`);
});