feat: reverse-proxy to VM instead of 302 (keep clean domain) + bump 0.0.4
This commit is contained in:
+2
-2
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+30
-13
@@ -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('<html>ok</html>'),
|
||||
});
|
||||
res.end('<html>ok</html>');
|
||||
});
|
||||
|
||||
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(), '<html>ok</html>');
|
||||
});
|
||||
});
|
||||
|
||||
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(), '<html>ok</html>');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user