diff --git a/package.json b/package.json index 31a5c07..ec31902 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tf-docs", - "version": "0.0.3", - "description": "Terraform docs redirect router (302 to VM static server)", + "version": "0.0.4", + "description": "Terraform docs reverse-proxy (stream from VM static server)", "main": "server.js", "scripts": { "start": "node server.js", diff --git a/server.js b/server.js index dc2a259..a00a7f6 100644 --- a/server.js +++ b/server.js @@ -1,79 +1,124 @@ 'use strict'; const http = require('http'); +const https = require('https'); const PORT = Number(process.env.PORT) || 3000; -const VM_DOCS_BASE_URL = (process.env.VM_DOCS_BASE_URL || '').replace(/\/+$/, ''); +const PROXY_TIMEOUT_MS = Number(process.env.PROXY_TIMEOUT_MS) || 30000; +const VM_DOCS_BASE_URL = process.env.VM_DOCS_BASE_URL; -const STAND_RE = /^[A-Za-z0-9._-]+$/; - -function send(res, status, body, headers = {}) { - const data = Buffer.from(String(body)); - res.writeHead(status, { - 'Content-Type': 'text/plain; charset=utf-8', - 'Content-Length': data.length, - ...headers, - }); - res.end(data); +if (!VM_DOCS_BASE_URL) { + throw new Error('VM_DOCS_BASE_URL environment variable is required'); } -function redirect(res, location) { - send(res, 302, '', { Location: location }); +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']; + +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') { - send(res, 405, 'Method Not Allowed'); + respond(res, 405, 'Method Not Allowed'); return; } - let pathname; + 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 { - pathname = decodeURIComponent(req.url.split('?')[0]); - } catch (err) { - send(res, 400, 'Bad Request'); + decoded = decodeURIComponent(rawPath); + } catch (_) { + respond(res, 400, 'Bad Request'); return; } - if (pathname === '/health') { - send(res, 200, 'ok'); + if (decoded.split('/').some((seg) => seg === '..')) { + respond(res, 400, 'Bad Request'); return; } - if (pathname === '/') { - if (!VM_DOCS_BASE_URL) { - send(res, 500, 'VM_DOCS_BASE_URL is not configured'); + 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; } - redirect(res, VM_DOCS_BASE_URL + '/'); - return; + targetPath = UPSTREAM_BASE_PATH + rawPath + rawQuery; } - if (!VM_DOCS_BASE_URL) { - send(res, 500, 'VM_DOCS_BASE_URL is not configured'); - return; - } - - const segments = pathname.split('/').filter((s) => s !== ''); - if (segments.some((s) => s === '..')) { - send(res, 400, 'Bad Request'); - return; - } - - const stand = segments[0]; - if (!stand || !STAND_RE.test(stand)) { - send(res, 404, 'Not Found'); - return; - } - - redirect(res, VM_DOCS_BASE_URL + pathname); + proxyRequest(req, res, targetPath); }); if (require.main === module) { server.listen(PORT, () => { - console.log(`redirect router listening on :${PORT}`); - console.log(`VM_DOCS_BASE_URL=${VM_DOCS_BASE_URL || '(not set)'}`); + console.log(`[docs-proxy] listening on port ${PORT}, upstream: ${VM_DOCS_BASE_URL}`); }); } -module.exports = { server, send, redirect }; +module.exports = { server }; diff --git a/test/server.test.js b/test/server.test.js index 302657c..ed07ed7 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -1,19 +1,36 @@ 'use strict'; -process.env.VM_DOCS_BASE_URL = process.env.VM_DOCS_BASE_URL || 'http://vm.example'; - +const http = require('node:http'); const test = require('node:test'); const assert = require('node:assert'); -const { server } = require('../server.js'); +let server; + +const upstream = http.createServer((req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + 'Content-Length': Buffer.byteLength('ok'), + }); + res.end('ok'); +}); + +test.before(async () => { + await new Promise((r) => upstream.listen(0, '127.0.0.1', r)); + process.env.VM_DOCS_BASE_URL = `http://127.0.0.1:${upstream.address().port}`; + ({ server } = require('../server.js')); +}); + +test.after(async () => { + await new Promise((r) => upstream.close(r)); +}); async function withServer(fn) { - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); const base = `http://127.0.0.1:${server.address().port}`; try { await fn(base); } finally { - await new Promise((resolve) => server.close(resolve)); + await new Promise((r) => server.close(r)); } } @@ -25,19 +42,19 @@ test('GET /health -> 200 ok', async () => { }); }); -test('GET /nubes-test/akhq/ -> 302 to VM', async () => { +test('GET /nubes-test/ -> proxies to upstream', async () => { await withServer(async (base) => { - const res = await fetch(`${base}/nubes-test/akhq/`, { redirect: 'manual' }); - assert.strictEqual(res.status, 302); - assert.strictEqual(res.headers.get('location'), 'http://vm.example/nubes-test/akhq/'); + const res = await fetch(`${base}/nubes-test/`); + assert.strictEqual(res.status, 200); + assert.strictEqual(await res.text(), 'ok'); }); }); -test('GET / -> 302 to VM root', async () => { +test('GET / -> proxies to upstream root', async () => { await withServer(async (base) => { - const res = await fetch(`${base}/`, { redirect: 'manual' }); - assert.strictEqual(res.status, 302); - assert.strictEqual(res.headers.get('location'), 'http://vm.example/'); + const res = await fetch(`${base}/`); + assert.strictEqual(res.status, 200); + assert.strictEqual(await res.text(), 'ok'); }); });