Files
gongxue-base/serve-proxy.js
wangziqi 3bcad138a1 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)
2026-08-09 21:29:22 +08:00

265 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 轻量静态文件 + API 代理服务器
// PM2 启动: node serve-proxy.js
const http = require('http');
const https = require('https');
const fs = require('fs/promises');
const fsStream = require('fs');
const path = require('path');
// 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',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
};
// 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 {
stat = await fs.stat(filePath);
} catch {
// 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: 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: {
...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',
},
};
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', () => {
// 超时回调已自行回 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 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.href}\n`);
});