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

@@ -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`);
});