// Docker-Adapter: baut `docker run`-Argumente mit festem Sicherheitsprofil und kapselt die docker-CLI (spawn, nie Shell). import { spawn } from 'node:child_process'; import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; const SUB_RE = /^[a-z0-9-]{1,63}$/; const ENV_NAME_RE = /^[A-Z_][A-Z0-9_]*$/; const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/; const NETWORK = 'preview-sandbox'; // fest (R10), kein Parameter const IMAGES = ['node:20', 'oven/bun:1']; const MEMORIES = ['1g', '512m']; const MAX_LINE = 64 * 1024; // Feste, unkritische Umgebung im Container (R9): Caches landen im gemounteten Cache-Verzeichnis. const FIXED_ENV = ['HOME=/cache/home', 'npm_config_cache=/cache/npm', 'COREPACK_HOME=/cache/corepack']; const FIXED_NAMES = new Set(['HOME', 'COREPACK_HOME']); // Host-Pfad: absolut, keine Sonderzeichen für -v, keine '..'-Segmente, unterhalb der Wurzel (R10). function checkPath(p, root, what) { if (typeof p !== 'string' || typeof root !== 'string' || !p.startsWith('/') || /[:,\0\n\r]/.test(p) || p.split('/').includes('..') || !(p === root || p.startsWith(root.replace(/\/+$/, '') + '/'))) { throw new Error(`Ungültiger Pfad (${what})`); } } function checkEnv(env) { for (const [k, v] of Object.entries(env || {})) { if (!ENV_NAME_RE.test(k) || FIXED_NAMES.has(k)) throw new Error(`Ungültiger Variablenname: ${k}`); if (/[\0\n\r]/.test(String(v))) throw new Error(`Ungültiger Wert für ${k}`); } } function securityArgs({ image, memory, name, workDir, cacheDir, workRoot, cacheRoot, env }) { if (!IMAGES.includes(image)) throw new Error('Image nicht erlaubt'); if (!MEMORIES.includes(memory)) throw new Error('Ungültiges Speicherlimit'); if (typeof name !== 'string' || !NAME_RE.test(name)) throw new Error('Ungültiger Containername'); checkPath(workDir, workRoot, 'workDir'); checkPath(cacheDir, cacheRoot, 'cacheDir'); checkEnv(env); // Werte selbst laufen nie über die Kommandozeile, siehe run() (--env-file) const args = [ '--name', name, '--init', '--memory', memory, '--memory-swap', memory, '--cpus', '1', '--pids-limit', '512', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--user', '1000:1000', '--network', NETWORK, '-v', `${workDir}:/work`, '-v', `${cacheDir}:/cache`, '-w', '/work', ]; for (const e of FIXED_ENV) args.push('-e', e); return args; } // name ist Pflicht (Aufrufer: 'preview-build-'), damit ein Zeitlimit den Container sicher entfernen kann. export function buildRunArgs({ image = 'node:20', name, workDir, cacheDir, workRoot, cacheRoot, env, cmd = [], memory = '1g' }) { return ['run', '--rm', ...securityArgs({ image, memory, name, workDir, cacheDir, workRoot, cacheRoot, env }), image, ...cmd]; } export function devRunArgs({ image = 'node:20', sub, workDir, cacheDir, workRoot, cacheRoot, env, cmd = [], memory = '512m' }) { if (typeof sub !== 'string' || !SUB_RE.test(sub)) throw new Error('Ungültige Subdomain'); return [ 'run', '-d', '--restart', 'no', '--label', `webklar.preview=${sub}`, ...securityArgs({ image, memory, name: `preview-dev-${sub}`, workDir, cacheDir, workRoot, cacheRoot, env }), image, ...cmd, ]; } export const spawnDocker = (args, opts) => spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'], ...opts }); function lineSplitter(onLine) { let buf = ''; return { push(chunk) { buf += chunk; const parts = buf.split(/\r?\n/); buf = parts.pop().slice(0, MAX_LINE); for (const l of parts) onLine(l.length > MAX_LINE ? l.slice(0, MAX_LINE) : l); }, flush() { if (buf !== '') onLine(buf); buf = ''; }, }; } const checkName = (n) => { if (typeof n !== 'string' || !NAME_RE.test(n)) throw new Error('Ungültiger Containername'); }; // Name aus dem Optionsteil (vor dem Image) – nie aus dem Container-Befehl. function optionName(args) { const end = args.findIndex((a) => IMAGES.includes(a)); const opts = end === -1 ? args : args.slice(0, end); const i = opts.indexOf('--name'); return i !== -1 && NAME_RE.test(opts[i + 1] || '') ? opts[i + 1] : null; } export function createDocker({ exec = spawnDocker, baseEnv = process.env, envDir = tmpdir(), rmCapMs = 5000 } = {}) { // CLI-Umgebung NUR aus PATH/DOCKER_HOST des Runners; Job-Variablen gelangen ausschließlich per --env-file (R10). const minimalBase = {}; for (const k of ['PATH', 'DOCKER_HOST']) if (baseEnv[k] !== undefined) minimalBase[k] = baseEnv[k]; function writeEnvFile(env) { checkEnv(env); const dir = mkdtempSync(join(envDir, 'preview-env-')); const file = join(dir, 'env'); writeFileSync(file, Object.entries(env).map(([k, v]) => `${k}=${v}\n`).join(''), { mode: 0o600 }); return { file, cleanup: () => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* egal */ } } }; } // docker rm -f mit kurzer Obergrenze abwarten. function forceRemove(name) { return new Promise((resolve) => { const t = setTimeout(resolve, rmCapMs); try { const c = exec(['rm', '-f', name], { env: minimalBase }); c.on?.('error', () => { clearTimeout(t); resolve(); }); c.on?.('close', () => { clearTimeout(t); resolve(); }); } catch { clearTimeout(t); resolve(); } }); } // onLine(zeile, 'out' | 'err') function run(args, { env = {}, timeoutMs, onLine } = {}) { let envFile = null; let finalArgs = args; if (Object.keys(env).length > 0) { if (args[0] !== 'run') return Promise.reject(new Error('Variablen nur für docker run')); try { envFile = writeEnvFile(env); } catch (e) { return Promise.reject(e); } finalArgs = ['run', '--env-file', envFile.file, ...args.slice(1)]; } return new Promise((resolve, reject) => { let child; try { child = exec(finalArgs, { env: { ...minimalBase } }); } catch (e) { envFile?.cleanup(); reject(e); return; } const split = lineSplitter((l) => { if (onLine) onLine(l, 'out'); }); const split2 = lineSplitter((l) => { if (onLine) onLine(l, 'err'); }); child.stdout?.setEncoding?.('utf8'); child.stderr?.setEncoding?.('utf8'); child.stdout?.on('data', (d) => split.push(String(d))); child.stderr?.on('data', (d) => split2.push(String(d))); let done = false; let timer; const finish = (res) => { if (done) return; done = true; clearTimeout(timer); envFile?.cleanup(); split.flush(); split2.flush(); resolve(res); }; child.on('error', (e) => { if (!done) { done = true; clearTimeout(timer); envFile?.cleanup(); reject(e); } }); child.on('close', (code) => finish({ code: code ?? 1 })); if (timeoutMs > 0) { timer = setTimeout(async () => { try { child.kill('SIGKILL'); } catch { /* schon beendet */ } const name = optionName(args); if (name) await forceRemove(name); finish({ code: 124 }); }, timeoutMs); } }); } async function capture(args) { const out = []; const r = await run(args, { onLine: (l, st) => { if (st === 'out') out.push(l); } }); return { code: r.code, lines: out }; } return { run, async ps(label) { const { code, lines } = await capture(['ps', '--filter', `label=${label}`, '--format', '{{.Names}}']); if (code !== 0) throw new Error(`docker ps fehlgeschlagen (${code})`); return lines.map((l) => l.trim()).filter((l) => NAME_RE.test(l)); }, async rm(name) { checkName(name); return capture(['rm', '-f', name]); }, async stop(name) { checkName(name); return capture(['rm', '-f', name]); }, }; }