'use strict'; const http = require('http'); const https = require('https'); const PORT = Number(process.env.PORT) || 3000; const PROXY_TIMEOUT_MS = Number(process.env.PROXY_TIMEOUT_MS) || 30000; const VM_DOCS_BASE_URL = process.env.VM_DOCS_BASE_URL; if (!VM_DOCS_BASE_URL) { throw new Error('VM_DOCS_BASE_URL environment variable is required'); } const UPSTREAM = new URL(VM_DOCS_BASE_URL); if (UPSTREAM.protocol !== 'http:' && UPSTREAM.protocol !== 'https:') { throw new Error('VM_DOCS_BASE_URL must start with http:// or https://'); } const UPSTREAM_MODULE = UPSTREAM.protocol === 'https:' ? https : http; const UPSTREAM_BASE_PATH = UPSTREAM.pathname.replace(/\/+$/, ''); const UPSTREAM_PORT = UPSTREAM.port || (UPSTREAM.protocol === 'https:' ? 443 : 80); const STAND_RE = /^[A-Za-z0-9._-]+$/; const FORWARD_HEADERS = ['content-type', 'content-length', 'cache-control', 'content-encoding', 'location']; function respond(res, status, body) { if (!res.headersSent) { res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': Buffer.byteLength(body), }); } res.end(body); } function proxyRequest(req, res, targetPath) { const options = { protocol: UPSTREAM.protocol, hostname: UPSTREAM.hostname, port: UPSTREAM_PORT, method: req.method, path: targetPath, timeout: PROXY_TIMEOUT_MS, }; const upstreamReq = UPSTREAM_MODULE.request(options, (upstreamRes) => { const headers = {}; for (const name of FORWARD_HEADERS) { if (upstreamRes.headers[name] !== undefined) { headers[name] = upstreamRes.headers[name]; } } res.writeHead(upstreamRes.statusCode || 502, headers); upstreamRes.pipe(res); }); upstreamReq.on('timeout', () => { upstreamReq.destroy(new Error('Upstream timeout')); }); upstreamReq.on('error', () => { if (res.headersSent) { res.destroy(); } else { respond(res, 502, 'Bad Gateway'); } }); req.on('aborted', () => upstreamReq.destroy()); upstreamReq.end(); } const server = http.createServer((req, res) => { if (req.method !== 'GET' && req.method !== 'HEAD') { respond(res, 405, 'Method Not Allowed'); return; } const rawUrl = req.url || '/'; const qIndex = rawUrl.indexOf('?'); const rawPath = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex); const rawQuery = qIndex === -1 ? '' : rawUrl.slice(qIndex); if (rawPath === '/health') { respond(res, 200, 'ok'); return; } let decoded; try { decoded = decodeURIComponent(rawPath); } catch (_) { respond(res, 400, 'Bad Request'); return; } if (decoded.split('/').some((seg) => seg === '..')) { respond(res, 400, 'Bad Request'); return; } let targetPath; if (decoded === '/') { targetPath = UPSTREAM_BASE_PATH + '/' + rawQuery; } else { const stand = decoded.split('/').filter(Boolean)[0]; if (!stand || !STAND_RE.test(stand)) { respond(res, 400, 'Bad Request'); return; } targetPath = UPSTREAM_BASE_PATH + rawPath + rawQuery; } proxyRequest(req, res, targetPath); }); if (require.main === module) { server.listen(PORT, () => { console.log(`[docs-proxy] listening on port ${PORT}, upstream: ${VM_DOCS_BASE_URL}`); }); } module.exports = { server };