Runner: Proxy härten (R13 Listen-Adressen, Hop-by-hop, Upgrade-Race, Touch, Timeout)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4afe7eb5cd
commit
e358514240
6 files changed
+173
-15
No files matched your search
@@ -8,6 +8,7 @@ export function loadConfig(env = process.env) {
|
|||||||
previewsRoot: env.PREVIEWS_ROOT || '/var/www/previews',
|
previewsRoot: env.PREVIEWS_ROOT || '/var/www/previews',
|
||||||
workRoot: env.RUNNER_WORK_ROOT || '/srv/webklar-preview-runner/work',
|
workRoot: env.RUNNER_WORK_ROOT || '/srv/webklar-preview-runner/work',
|
||||||
cacheRoot: env.RUNNER_CACHE_ROOT || '/srv/webklar-preview-runner/cache',
|
cacheRoot: env.RUNNER_CACHE_ROOT || '/srv/webklar-preview-runner/cache',
|
||||||
|
sandboxSubnet: env.PREVIEW_SANDBOX_SUBNET || '172.30.0.0/24',
|
||||||
apiPort: 8080,
|
apiPort: 8080,
|
||||||
proxyPort: 8081,
|
proxyPort: 8081,
|
||||||
minFreeBytes: 1.5 * 1024 ** 3,
|
minFreeBytes: 1.5 * 1024 ** 3,
|
||||||
|
|||||||
+8
-6
@@ -4,6 +4,8 @@ import { createDocker } from './docker.js';
|
|||||||
import { createJobs } from './jobs.js';
|
import { createJobs } from './jobs.js';
|
||||||
import { createDev } from './dev.js';
|
import { createDev } from './dev.js';
|
||||||
import { createProxy } from './proxy.js';
|
import { createProxy } from './proxy.js';
|
||||||
|
import os from 'node:os';
|
||||||
|
import { listenAddresses } from './listen.js';
|
||||||
|
|
||||||
const config = loadConfig(process.env);
|
const config = loadConfig(process.env);
|
||||||
const docker = createDocker();
|
const docker = createDocker();
|
||||||
@@ -16,9 +18,9 @@ const dev = createDev({ config, docker, jobs: realJobs });
|
|||||||
dev.cleanupOrphans().catch((e) => console.error('cleanupOrphans', e));
|
dev.cleanupOrphans().catch((e) => console.error('cleanupOrphans', e));
|
||||||
setInterval(() => dev.sweep().catch((e) => console.error('sweep', e)), 60e3).unref();
|
setInterval(() => dev.sweep().catch((e) => console.error('sweep', e)), 60e3).unref();
|
||||||
|
|
||||||
createApiServer({ config, jobs, dev }).listen(config.apiPort, () => {
|
// R13: nur auf Adressen außerhalb des Sandbox-Netzes lauschen (kein Zugriff aus Dev-Containern am Traefik-Auth vorbei).
|
||||||
console.log(`Preview-Runner API auf Port ${config.apiPort}`);
|
const addrs = listenAddresses(os.networkInterfaces(), config.sandboxSubnet);
|
||||||
});
|
for (const addr of addrs) {
|
||||||
createProxy({ config, dev }).listen(config.proxyPort, () => {
|
createApiServer({ config, jobs, dev }).listen(config.apiPort, addr, () => console.log(`Preview-Runner API auf ${addr}:${config.apiPort}`));
|
||||||
console.log(`Preview-Runner Proxy auf Port ${config.proxyPort}`);
|
createProxy({ config, dev }).listen(config.proxyPort, addr, () => console.log(`Preview-Runner Proxy auf ${addr}:${config.proxyPort}`));
|
||||||
});
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// R13: API und Proxy nur auf Adressen binden, die NICHT im Sandbox-Netz liegen (Dev-Container laufen fremden Code).
|
||||||
|
import os from 'node:os';
|
||||||
|
|
||||||
|
const ipToInt = (ip) => ip.split('.').reduce((a, o) => ((a << 8) | Number(o)) >>> 0, 0);
|
||||||
|
|
||||||
|
export function inSubnet(ip, subnet) {
|
||||||
|
const [base, bits] = subnet.split('/');
|
||||||
|
const n = Number(bits);
|
||||||
|
if (!/^\d+\.\d+\.\d+\.\d+$/.test(base) || !Number.isInteger(n) || n < 0 || n > 32) throw new Error(`Ungültiges Sandbox-Netz: ${subnet}`);
|
||||||
|
const mask = n === 0 ? 0 : (~0 << (32 - n)) >>> 0;
|
||||||
|
return ((ipToInt(ip) & mask) >>> 0) === ((ipToInt(base) & mask) >>> 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listenAddresses(interfaces = os.networkInterfaces(), subnet, { allowLoopback = false } = {}) {
|
||||||
|
const out = [];
|
||||||
|
for (const list of Object.values(interfaces)) {
|
||||||
|
for (const i of list || []) {
|
||||||
|
if (i.family !== 'IPv4' && i.family !== 4) continue;
|
||||||
|
if (i.internal && !allowLoopback) continue;
|
||||||
|
if (inSubnet(i.address, subnet)) continue;
|
||||||
|
if (!out.includes(i.address)) out.push(i.address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!out.length) throw new Error(`Keine Listen-Adresse außerhalb des Sandbox-Netzes ${subnet} gefunden – Runner startet nicht.`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
+30
-8
@@ -22,7 +22,21 @@ function html(res, status, body, extra = {}) {
|
|||||||
res.end(body);
|
res.end(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createProxy({ config, dev, request = http.request }) {
|
const HOP = ['connection', 'keep-alive', 'proxy-authorization', 'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade'];
|
||||||
|
// allowHalfOpen: ein vom Client geschlossener Socket ist zwar nicht 'destroyed', aber nicht mehr lesbar
|
||||||
|
const gone = (s) => s.destroyed || !s.readable;
|
||||||
|
const TOUCH_MS = 60e3;
|
||||||
|
|
||||||
|
// Hop-by-hop-Header entfernen (samt der in Connection genannten Tokens). Auf dem Upgrade-Pfad bleiben Upgrade/Connection: Upgrade.
|
||||||
|
export function stripHop(headers, { upgrade = false } = {}) {
|
||||||
|
const out = { ...headers };
|
||||||
|
const listed = String(headers.connection || '').split(',').map((t) => t.trim().toLowerCase()).filter(Boolean);
|
||||||
|
for (const k of Object.keys(out)) if (HOP.includes(k.toLowerCase()) || listed.includes(k.toLowerCase())) delete out[k];
|
||||||
|
if (upgrade) { out.connection = 'Upgrade'; out.upgrade = headers.upgrade; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProxy({ config, dev, request = http.request, upstreamTimeoutMs = 120e3, now = Date.now }) {
|
||||||
// Sub aus dem Host-Header: exakt <sub>.<baseHost>, klein geschrieben, Port entfernt.
|
// Sub aus dem Host-Header: exakt <sub>.<baseHost>, klein geschrieben, Port entfernt.
|
||||||
function subOf(hostHeader) {
|
function subOf(hostHeader) {
|
||||||
const host = String(hostHeader || '').replace(/:\d+$/, '');
|
const host = String(hostHeader || '').replace(/:\d+$/, '');
|
||||||
@@ -32,8 +46,8 @@ export function createProxy({ config, dev, request = http.request }) {
|
|||||||
return isValidSub(sub) ? sub : null;
|
return isValidSub(sub) ? sub : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fwdHeaders(req) {
|
function fwdHeaders(req, upgrade = false) {
|
||||||
const h = { ...req.headers };
|
const h = stripHop(req.headers, { upgrade });
|
||||||
const prior = req.headers['x-forwarded-for'];
|
const prior = req.headers['x-forwarded-for'];
|
||||||
const ip = req.socket.remoteAddress || '';
|
const ip = req.socket.remoteAddress || '';
|
||||||
h['x-forwarded-for'] = prior ? `${prior}, ${ip}` : ip;
|
h['x-forwarded-for'] = prior ? `${prior}, ${ip}` : ip;
|
||||||
@@ -45,7 +59,7 @@ export function createProxy({ config, dev, request = http.request }) {
|
|||||||
// Ergebnis: { target } wenn online, sonst { state }.
|
// Ergebnis: { target } wenn online, sonst { state }.
|
||||||
async function resolve(sub) {
|
async function resolve(sub) {
|
||||||
let r;
|
let r;
|
||||||
try { r = await dev.wake(sub); } catch (e) { return { state: e?.status === 503 ? 'busy' : 'unknown' }; }
|
try { r = await dev.wake(sub); } catch (e) { if (e?.status !== 503) console.error('Proxy wake', sub, e); return { state: e?.status === 503 ? 'busy' : 'unknown' }; }
|
||||||
if (r.state === 'online' && r.target) return { target: new URL(r.target) };
|
if (r.state === 'online' && r.target) return { target: new URL(r.target) };
|
||||||
return { state: r.state };
|
return { state: r.state };
|
||||||
}
|
}
|
||||||
@@ -63,14 +77,16 @@ export function createProxy({ config, dev, request = http.request }) {
|
|||||||
const up = request({
|
const up = request({
|
||||||
hostname: r.target.hostname, port: r.target.port, method: req.method, path: req.url, headers: fwdHeaders(req),
|
hostname: r.target.hostname, port: r.target.port, method: req.method, path: req.url, headers: fwdHeaders(req),
|
||||||
}, (ures) => {
|
}, (ures) => {
|
||||||
res.writeHead(ures.statusCode, ures.headers);
|
res.writeHead(ures.statusCode, stripHop(ures.headers));
|
||||||
ures.pipe(res);
|
ures.pipe(res);
|
||||||
ures.on('error', () => res.destroy());
|
ures.on('error', () => res.destroy());
|
||||||
});
|
});
|
||||||
|
let timedOut = false;
|
||||||
|
up.setTimeout(upstreamTimeoutMs, () => { timedOut = true; up.destroy(); });
|
||||||
up.on('error', () => {
|
up.on('error', () => {
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
const body = 'Die Preview ist gerade nicht erreichbar (502).';
|
const body = timedOut ? 'Die Preview antwortet nicht rechtzeitig (504).' : 'Die Preview ist gerade nicht erreichbar (502).';
|
||||||
res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': Buffer.byteLength(body) });
|
res.writeHead(timedOut ? 504 : 502, { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': Buffer.byteLength(body) });
|
||||||
res.end(body);
|
res.end(body);
|
||||||
} else res.destroy();
|
} else res.destroy();
|
||||||
});
|
});
|
||||||
@@ -89,10 +105,11 @@ export function createProxy({ config, dev, request = http.request }) {
|
|||||||
const sub = subOf(req.headers.host);
|
const sub = subOf(req.headers.host);
|
||||||
if (!sub) return socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
|
if (!sub) return socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
|
||||||
const r = await resolve(sub);
|
const r = await resolve(sub);
|
||||||
|
if (gone(socket)) return socket.destroy(); // Client hat sich während des Wartens verabschiedet
|
||||||
if (!r.target) return socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
|
if (!r.target) return socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
|
||||||
dev.touch(sub);
|
dev.touch(sub);
|
||||||
const up = request({
|
const up = request({
|
||||||
hostname: r.target.hostname, port: r.target.port, method: req.method, path: req.url, headers: fwdHeaders(req),
|
hostname: r.target.hostname, port: r.target.port, method: req.method, path: req.url, headers: fwdHeaders(req, true),
|
||||||
});
|
});
|
||||||
up.on('response', (ures) => { // Upstream lehnt das Upgrade ab: Antwort durchreichen und schließen
|
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`;
|
let raw = `HTTP/1.1 ${ures.statusCode} ${ures.statusMessage}\r\n`;
|
||||||
@@ -101,6 +118,7 @@ export function createProxy({ config, dev, request = http.request }) {
|
|||||||
ures.pipe(socket);
|
ures.pipe(socket);
|
||||||
});
|
});
|
||||||
up.on('upgrade', (ures, usock, uhead) => {
|
up.on('upgrade', (ures, usock, uhead) => {
|
||||||
|
if (gone(socket)) { usock.destroy(); up.destroy(); return; }
|
||||||
usock.on('error', () => { usock.destroy(); socket.destroy(); });
|
usock.on('error', () => { usock.destroy(); socket.destroy(); });
|
||||||
// allowHalfOpen-Sockets bleiben nach 'end' offen: bei Ende oder Schließen einer Seite beide zerstören
|
// allowHalfOpen-Sockets bleiben nach 'end' offen: bei Ende oder Schließen einer Seite beide zerstören
|
||||||
const both = () => { usock.destroy(); socket.destroy(); };
|
const both = () => { usock.destroy(); socket.destroy(); };
|
||||||
@@ -110,6 +128,10 @@ export function createProxy({ config, dev, request = http.request }) {
|
|||||||
socket.write(`${raw}\r\n`);
|
socket.write(`${raw}\r\n`);
|
||||||
if (uhead?.length) socket.write(uhead);
|
if (uhead?.length) socket.write(uhead);
|
||||||
if (head?.length) usock.write(head);
|
if (head?.length) usock.write(head);
|
||||||
|
// Offene Verbindung (z. B. HMR) hält den Dev-Server wach: höchstens einmal pro Minute touch
|
||||||
|
let last = now();
|
||||||
|
const keep = () => { const t = now(); if (t - last >= TOUCH_MS) { last = t; dev.touch(sub); } };
|
||||||
|
usock.on('data', keep); socket.on('data', keep);
|
||||||
usock.pipe(socket);
|
usock.pipe(socket);
|
||||||
socket.pipe(usock);
|
socket.pipe(usock);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { listenAddresses, inSubnet } from '../src/listen.js';
|
||||||
|
|
||||||
|
const ifs = {
|
||||||
|
lo: [{ family: 'IPv4', address: '127.0.0.1', internal: true }],
|
||||||
|
eth0: [{ family: 'IPv4', address: '172.18.0.5', internal: false }, { family: 'IPv6', address: 'fe80::1', internal: false }],
|
||||||
|
eth1: [{ family: 'IPv4', address: '172.30.0.9', internal: false }],
|
||||||
|
};
|
||||||
|
|
||||||
|
test('inSubnet', () => {
|
||||||
|
assert.equal(inSubnet('172.30.0.9', '172.30.0.0/24'), true);
|
||||||
|
assert.equal(inSubnet('172.30.1.9', '172.30.0.0/24'), false);
|
||||||
|
assert.throws(() => inSubnet('1.2.3.4', 'müll'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listenAddresses: Sandbox-Adresse, IPv6 und Loopback ausgeschlossen', () => {
|
||||||
|
assert.deepEqual(listenAddresses(ifs, '172.30.0.0/24'), ['172.18.0.5']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listenAddresses: Loopback nur mit Option', () => {
|
||||||
|
assert.deepEqual(listenAddresses(ifs, '172.30.0.0/24', { allowLoopback: true }), ['127.0.0.1', '172.18.0.5']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listenAddresses: nichts gefunden ⇒ deutscher Fehler', () => {
|
||||||
|
assert.throws(() => listenAddresses({ eth1: ifs.eth1 }, '172.30.0.0/24'), /Keine Listen-Adresse/);
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { createProxy } from '../src/proxy.js';
|
import { createProxy, stripHop } from '../src/proxy.js';
|
||||||
|
|
||||||
const config = { baseHost: 'project.webklar.com' };
|
const config = { baseHost: 'project.webklar.com' };
|
||||||
|
|
||||||
@@ -141,3 +141,83 @@ test('Upstream-Fehler ⇒ 502 deutscher Text', async () => {
|
|||||||
assert.match(text, /nicht erreichbar/);
|
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)); }
|
||||||
|
});
|
||||||
Reference in new issue
Block a user