// Reverse-Proxy für Dev-Previews: Host . ⇒ laufender Dev-Container (R5: Docker-DNS im Netz preview-sandbox). // Keine Authentifizierung hier: die Hosts liegen hinter Traefiks preview-chain (ForwardAuth). Weitergeleitet wird // ausschließlich an das Ziel, das dev.wake/dev.ensure liefert (preview-dev-). import http from 'node:http'; import { isValidSub } from './validate.js'; const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); function page(title, text, { refresh = false, hint = '' } = {}) { return `${refresh ? '' : ''}${esc(title)}

${esc(title)}

${esc(text)}

${hint ? `

${esc(hint)}

` : ''}
`; } const PAGES = { starting: page('Preview startet …', 'Der Dev-Server wird gestartet. Die Seite lädt automatisch neu.', { refresh: true }), failed: page('Preview konnte nicht starten', 'Der Dev-Server ist nicht hochgekommen.', { hint: 'Details stehen im Protokoll im WOMS.' }), unknown: page('Preview nicht verfügbar', 'Diese Preview ist gerade nicht verfügbar.'), busy: page('Preview nicht verfügbar', 'Der Server ist gerade ausgelastet. Bitte später erneut versuchen.'), }; function html(res, status, body, extra = {}) { res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'no-store', ...extra }); res.end(body); } export function createProxy({ config, dev, request = http.request }) { // Sub aus dem Host-Header: exakt ., klein geschrieben, Port entfernt. function subOf(hostHeader) { const host = String(hostHeader || '').replace(/:\d+$/, ''); const suffix = `.${config.baseHost}`; if (host !== host.toLowerCase() || !host.endsWith(suffix)) return null; const sub = host.slice(0, -suffix.length); return isValidSub(sub) ? sub : null; } function fwdHeaders(req) { const h = { ...req.headers }; const prior = req.headers['x-forwarded-for']; const ip = req.socket.remoteAddress || ''; h['x-forwarded-for'] = prior ? `${prior}, ${ip}` : ip; h['x-forwarded-host'] = req.headers.host || ''; h['x-forwarded-proto'] = req.headers['x-forwarded-proto'] || 'https'; return h; } // Ergebnis: { target } wenn online, sonst { state }. async function resolve(sub) { let r; try { r = await dev.wake(sub); } catch (e) { return { state: e?.status === 503 ? 'busy' : 'unknown' }; } if (r.state === 'online' && r.target) return { target: new URL(r.target) }; return { state: r.state }; } const server = http.createServer(async (req, res) => { try { const sub = subOf(req.headers.host); if (!sub) return html(res, 404, page('Nicht gefunden', 'Diese Adresse gibt es nicht.')); const r = await resolve(sub); if (!r.target) { const key = PAGES[r.state] ? r.state : 'unknown'; return html(res, 503, PAGES[key], key === 'starting' ? { 'Retry-After': '3' } : {}); } dev.touch(sub); const up = request({ hostname: r.target.hostname, port: r.target.port, method: req.method, path: req.url, headers: fwdHeaders(req), }, (ures) => { res.writeHead(ures.statusCode, ures.headers); ures.pipe(res); ures.on('error', () => res.destroy()); }); up.on('error', () => { if (!res.headersSent) { const body = 'Die Preview ist gerade nicht erreichbar (502).'; res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': Buffer.byteLength(body) }); res.end(body); } else res.destroy(); }); res.on('close', () => { if (!res.writableFinished) up.destroy(); }); req.pipe(up); } catch (e) { console.error('Proxy-Fehler:', e); if (!res.headersSent) html(res, 500, page('Fehler', 'Interner Fehler.')); else res.destroy(); } }); server.on('upgrade', async (req, socket, head) => { socket.on('error', () => socket.destroy()); try { const sub = subOf(req.headers.host); if (!sub) return socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n'); const r = await resolve(sub); if (!r.target) return socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n'); dev.touch(sub); const up = request({ hostname: r.target.hostname, port: r.target.port, method: req.method, path: req.url, headers: fwdHeaders(req), }); up.on('response', (ures) => { // Upstream lehnt das Upgrade ab: Antwort durchreichen und schließen let raw = `HTTP/1.1 ${ures.statusCode} ${ures.statusMessage}\r\n`; for (let i = 0; i < ures.rawHeaders.length; i += 2) raw += `${ures.rawHeaders[i]}: ${ures.rawHeaders[i + 1]}\r\n`; socket.write(`${raw}\r\n`); ures.pipe(socket); }); up.on('upgrade', (ures, usock, uhead) => { usock.on('error', () => { usock.destroy(); socket.destroy(); }); // allowHalfOpen-Sockets bleiben nach 'end' offen: bei Ende oder Schließen einer Seite beide zerstören const both = () => { usock.destroy(); socket.destroy(); }; for (const ev of ['end', 'close']) { socket.on(ev, both); usock.on(ev, both); } let raw = `HTTP/1.1 ${ures.statusCode} ${ures.statusMessage}\r\n`; for (let i = 0; i < ures.rawHeaders.length; i += 2) raw += `${ures.rawHeaders[i]}: ${ures.rawHeaders[i + 1]}\r\n`; socket.write(`${raw}\r\n`); if (uhead?.length) socket.write(uhead); if (head?.length) usock.write(head); usock.pipe(socket); socket.pipe(usock); }); up.on('error', () => socket.destroy()); socket.on('close', () => up.destroy()); up.end(); } catch (e) { console.error('Proxy-Upgrade-Fehler:', e); socket.destroy(); } }); return server; }