import test from 'node:test'; import assert from 'node:assert/strict'; import http from 'node:http'; import net from 'node:net'; import crypto from 'node:crypto'; import { createProxy, stripHop } from '../src/proxy.js'; const config = { baseHost: 'project.webklar.com' }; // Test-Ziel: HTTP-Echo + WebSocket-Echo per rohem Upgrade function startTarget() { const seen = []; const srv = http.createServer((req, res) => { const chunks = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => { seen.push({ method: req.method, url: req.url, headers: req.headers }); res.writeHead(201, { 'X-Test': 'ja' }); res.end(`${req.method} ${req.url} ${Buffer.concat(chunks).toString()}`); }); }); srv.on('upgrade', (req, socket) => { const acc = crypto.createHash('sha1').update(`${req.headers['sec-websocket-key']}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest('base64'); socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${acc}\r\n\r\n`); socket.on('data', (d) => socket.write(d)); // Echo socket.on('end', () => socket.end()); socket.on('error', () => {}); }); return new Promise((r) => srv.listen(0, '127.0.0.1', () => r({ srv, seen, port: srv.address().port }))); } async function withProxy(devImpl, fn, opts = {}) { const touched = []; const dev = { touch: (s) => touched.push(s), ...devImpl }; const proxy = createProxy({ config, dev, ...opts }); await new Promise((r) => proxy.listen(0, '127.0.0.1', r)); try { await fn({ port: proxy.address().port, touched }); } finally { proxy.closeAllConnections?.(); await new Promise((r) => proxy.close(r)); } } const get = (port, host, { method = 'GET', path = '/', body } = {}) => new Promise((resolve, reject) => { const req = http.request({ port, host: '127.0.0.1', method, path, headers: { Host: host } }, (res) => { const c = []; res.on('data', (d) => c.push(d)); res.on('end', () => resolve({ res, text: Buffer.concat(c).toString() })); }); req.on('error', reject); req.end(body); }); test('online: Durchreichen inkl. POST-Body, X-Forwarded-*, Host, touch', async () => { const t = await startTarget(); try { await withProxy({ wake: async (s) => ({ state: 'online', target: `http://127.0.0.1:${t.port}` }) }, async ({ port, touched }) => { const { res, text } = await get(port, 'demo.project.webklar.com:443', { method: 'POST', path: '/a?b=1', body: 'hallo' }); assert.equal(res.statusCode, 201); assert.equal(res.headers['x-test'], 'ja'); assert.equal(text, 'POST /a?b=1 hallo'); assert.deepEqual(touched, ['demo']); const h = t.seen[0].headers; assert.equal(h.host, 'demo.project.webklar.com:443'); assert.equal(h['x-forwarded-host'], 'demo.project.webklar.com:443'); assert.equal(h['x-forwarded-proto'], 'https'); assert.ok(h['x-forwarded-for']); }); } finally { t.srv.closeAllConnections?.(); await new Promise((r) => t.srv.close(r)); } }); test('WebSocket-Upgrade wird durchgereicht (Echo)', async () => { const t = await startTarget(); try { await withProxy({ wake: async () => ({ state: 'online', target: `http://127.0.0.1:${t.port}` }) }, async ({ port, touched }) => { const sock = net.connect(port, '127.0.0.1'); sock.on('error', () => {}); let buf = ''; const got = new Promise((resolve) => sock.on('data', (d) => { buf += d.toString('latin1'); if (buf.includes('\r\n\r\n') && buf.endsWith('ping-pong')) resolve(); })); sock.write('GET /ws HTTP/1.1\r\nHost: demo.project.webklar.com\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n'); await new Promise((r) => { const i = setInterval(() => { if (buf.includes('\r\n\r\n')) { clearInterval(i); r(); } }, 5); }); assert.match(buf, /^HTTP\/1\.1 101/); try { sock.write('ping-pong'); await got; assert.ok(buf.endsWith('ping-pong')); assert.deepEqual(touched, ['demo']); } finally { sock.destroy(); } }); } finally { t.srv.closeAllConnections?.(); await new Promise((r) => t.srv.close(r)); } }); test('starting ⇒ 503 mit Text, Retry-After und Meta-Refresh', async () => { await withProxy({ wake: async () => ({ state: 'starting' }) }, async ({ port, touched }) => { const { res, text } = await get(port, 'demo.project.webklar.com'); assert.equal(res.statusCode, 503); assert.equal(res.headers['retry-after'], '3'); assert.match(text, /Preview startet …/); assert.match(text, //); assert.deepEqual(touched, []); }); }); test('failed ⇒ 503 mit Fehlerseite', async () => { await withProxy({ wake: async () => ({ state: 'failed' }) }, async ({ port }) => { const { res, text } = await get(port, 'demo.project.webklar.com'); assert.equal(res.statusCode, 503); assert.match(text, /Preview konnte nicht starten/); assert.match(text, /Protokoll im WOMS/); assert.ok(!text.includes('http-equiv="refresh"')); }); }); test('wake unknown ⇒ 503 „nicht verfügbar“', async () => { await withProxy({ wake: async () => ({ state: 'unknown' }) }, async ({ port }) => { const { res, text } = await get(port, 'demo.project.webklar.com'); assert.equal(res.statusCode, 503); assert.match(text, /Diese Preview ist gerade nicht verfügbar\./); }); }); test('Speicherschutz (wake wirft 503) ⇒ 503-Seite, kein Absturz', async () => { await withProxy({ wake: async () => { throw Object.assign(new Error('Server ausgelastet'), { status: 503 }); } }, async ({ port }) => { const { res, text } = await get(port, 'demo.project.webklar.com'); assert.equal(res.statusCode, 503); assert.match(text, /ausgelastet/); }); }); test('fremder Host, Großschreibung, ungültiger sub, mehrstufig ⇒ 404 ohne wake', async () => { let woken = 0; await withProxy({ wake: async () => { woken++; return { state: 'starting' }; } }, async ({ port }) => { for (const h of ['evil.example.com', 'project.webklar.com', 'Demo.project.webklar.com', 'a_b.project.webklar.com', 'a.b.project.webklar.com', '.project.webklar.com', 'demo.project.webklar.com.evil.com', '']) { const { res } = await get(port, h); assert.equal(res.statusCode, 404, h); } assert.equal(woken, 0); }); }); test('Upstream-Fehler ⇒ 502 deutscher Text', async () => { const dead = net.createServer(); await new Promise((r) => dead.listen(0, '127.0.0.1', r)); const port0 = dead.address().port; await new Promise((r) => dead.close(r)); await withProxy({ wake: async () => ({ state: 'online', target: `http://127.0.0.1:${port0}` }) }, async ({ port }) => { const { res, text } = await get(port, 'demo.project.webklar.com'); assert.equal(res.statusCode, 502); assert.match(text, /nicht erreichbar/); }); }); test('stripHop: Hop-by-hop und in Connection genannte Header fallen weg; Upgrade-Pfad behält Upgrade', () => { const h = { connection: 'keep-alive, X-Geheim', 'keep-alive': 'timeout=5', 'x-geheim': '1', te: 'trailers', upgrade: 'websocket', 'proxy-authorization': 'x', 'x-ok': '2' }; assert.deepEqual(stripHop(h), { 'x-ok': '2' }); assert.deepEqual(stripHop(h, { upgrade: true }), { connection: 'Upgrade', upgrade: 'websocket', 'x-ok': '2' }); }); test('Hop-by-hop-Header werden in beide Richtungen nicht durchgereicht', async () => { const srv = http.createServer((req, res) => { res.writeHead(200, { Connection: 'close, X-Weg', 'X-Weg': 'a', 'Keep-Alive': 'timeout=1', 'X-Bleibt': 'b' }); res.end(JSON.stringify(req.headers)); }); await new Promise((r) => srv.listen(0, '127.0.0.1', r)); try { await withProxy({ wake: async () => ({ state: 'online', target: `http://127.0.0.1:${srv.address().port}` }) }, async ({ port }) => { const { res, text } = await new Promise((resolve, reject) => { const rq = http.request({ port, host: '127.0.0.1', headers: { Host: 'demo.project.webklar.com', Connection: 'keep-alive, X-Hop', 'X-Hop': '1', 'Proxy-Authorization': 'Basic x', TE: 'trailers', 'X-Normal': 'ja' } }, (rs) => { const c = []; rs.on('data', (d) => c.push(d)); rs.on('end', () => resolve({ res: rs, text: Buffer.concat(c).toString() })); }); rq.on('error', reject); rq.end(); }); const seen = JSON.parse(text); assert.equal(seen['x-hop'], undefined); assert.equal(seen['proxy-authorization'], undefined); assert.equal(seen.te, undefined); assert.equal(seen['x-normal'], 'ja'); assert.equal(res.headers['x-weg'], undefined); assert.notEqual(res.headers['keep-alive'], 'timeout=1'); assert.equal(res.headers['x-bleibt'], 'b'); }); } finally { srv.closeAllConnections?.(); await new Promise((r) => srv.close(r)); } }); test('Upstream antwortet nicht ⇒ 504', async () => { const srv = http.createServer(() => {}); // antwortet nie await new Promise((r) => srv.listen(0, '127.0.0.1', r)); try { await withProxy({ wake: async () => ({ state: 'online', target: `http://127.0.0.1:${srv.address().port}` }) }, async ({ port }) => { const { res, text } = await get(port, 'demo.project.webklar.com'); assert.equal(res.statusCode, 504); assert.match(text, /nicht rechtzeitig/); }, { upstreamTimeoutMs: 100 }); } finally { srv.closeAllConnections?.(); await new Promise((r) => srv.close(r)); } }); test('Upgrade-Race: Client weg während wake ⇒ kein Upstream-Request', async () => { let release; const gate = new Promise((r) => { release = r; }); let upstreamCalls = 0; const request = () => { upstreamCalls++; throw new Error('darf nicht aufgerufen werden'); }; await withProxy({ wake: async () => { await gate; return { state: 'online', target: 'http://127.0.0.1:1' }; } }, async ({ port, touched }) => { const sock = net.connect(port, '127.0.0.1'); sock.on('error', () => {}); sock.write('GET /ws HTTP/1.1\r\nHost: demo.project.webklar.com\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n'); await new Promise((r) => setTimeout(r, 50)); sock.destroy(); await new Promise((r) => setTimeout(r, 50)); release(); await new Promise((r) => setTimeout(r, 50)); assert.equal(upstreamCalls, 0); assert.deepEqual(touched, []); }, { request }); }); test('WebSocket-Daten touchen höchstens einmal pro Minute', async () => { const t = await startTarget(); const clock = { t: 1000 }; try { await withProxy({ wake: async () => ({ state: 'online', target: `http://127.0.0.1:${t.port}` }) }, async ({ port, touched }) => { const sock = net.connect(port, '127.0.0.1'); sock.on('error', () => {}); let buf = ''; sock.on('data', (d) => { buf += d.toString('latin1'); }); const waitFor = async (f) => { for (let i = 0; i < 200 && !f(); i++) await new Promise((r) => setTimeout(r, 5)); assert.ok(f()); }; try { sock.write('GET /ws HTTP/1.1\r\nHost: demo.project.webklar.com\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n'); await waitFor(() => buf.includes('\r\n\r\n')); assert.deepEqual(touched, ['demo']); sock.write('a'); await waitFor(() => buf.endsWith('a')); assert.deepEqual(touched, ['demo']); // innerhalb der Minute: kein weiteres touch clock.t += 61e3; sock.write('b'); await waitFor(() => buf.endsWith('b')); assert.deepEqual(touched, ['demo', 'demo']); } finally { sock.destroy(); } }, { now: () => clock.t }); } finally { t.srv.closeAllConnections?.(); await new Promise((r) => t.srv.close(r)); } });