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:
@@ -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` 一次性加密。
|
||||
|
||||
@@ -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 重载...'
|
||||
|
||||
92
deploy.sh
92
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')"
|
||||
|
||||
@@ -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,
|
||||
|
||||
147
migrate.sh
147
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'"
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
249
serve-proxy.js
249
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`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user