diff --git a/runner/src/docker.js b/runner/src/docker.js new file mode 100644 index 0000000..e347756 --- /dev/null +++ b/runner/src/docker.js @@ -0,0 +1,126 @@ +// Docker-Adapter: baut `docker run`-Argumente mit festem Sicherheitsprofil und kapselt die docker-CLI (spawn, nie Shell). +import { spawn } from 'node:child_process'; + +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'; +// 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(FIXED_ENV.map((e) => e.split('=')[0])); + +function securityArgs({ memory, network, name, workDir, cacheDir, env }) { + if (name !== undefined && !NAME_RE.test(name)) throw new Error('Ungültiger Containername'); + const args = []; + if (name !== undefined) args.push('--name', name); + args.push( + '--init', + '--memory', 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); + 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 (String(v).includes('\0')) throw new Error(`Ungültiger Wert für ${k}`); + args.push('-e', k); // nur der Name; der Wert reist über die Umgebung des docker-Prozesses + } + return args; +} + +export function buildRunArgs({ image = 'node:20', name, workDir, cacheDir, env, cmd = [], memory = '1g', network = NETWORK }) { + return ['run', '--rm', ...securityArgs({ memory, network, name, workDir, cacheDir, env }), image, ...cmd]; +} + +export function devRunArgs({ image = 'node:20', sub, workDir, cacheDir, env, cmd = [], memory = '512m', network = NETWORK }) { + if (typeof sub !== 'string' || !SUB_RE.test(sub)) throw new Error('Ungültige Subdomain'); + return [ + 'run', '-d', '--restart', 'no', '--label', `webklar.preview=${sub}`, + ...securityArgs({ memory, network, name: `preview-dev-${sub}`, workDir, cacheDir, 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(); + for (const l of parts) onLine(l); + }, + flush() { if (buf !== '') onLine(buf); buf = ''; }, + }; +} + +const checkName = (n) => { if (typeof n !== 'string' || !NAME_RE.test(n)) throw new Error('Ungültiger Containername'); }; + +export function createDocker({ exec = spawnDocker, baseEnv = process.env } = {}) { + // Minimale Basis-Umgebung: die Runner-Geheimnisse (Tokens) dürfen nie in docker/Container gelangen. + const minimalBase = {}; + for (const k of ['PATH', 'DOCKER_HOST']) if (baseEnv[k] !== undefined) minimalBase[k] = baseEnv[k]; + + function run(args, { env = {}, timeoutMs, onLine } = {}) { + for (const [k, v] of Object.entries(env)) { + if (String(v).includes('\0')) return Promise.reject(new Error(`Ungültiger Wert für ${k}`)); + } + return new Promise((resolve, reject) => { + const child = exec(args, { env: { ...minimalBase, ...env } }); + const split = lineSplitter((l) => { if (onLine) onLine(l); }); + const split2 = lineSplitter((l) => { if (onLine) onLine(l); }); + 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); + split.flush(); split2.flush(); + resolve(res); + }; + child.on('error', (e) => { if (!done) { done = true; clearTimeout(timer); reject(e); } }); + child.on('close', (code) => finish({ code: code ?? 1 })); + if (timeoutMs > 0) { + timer = setTimeout(() => { + try { child.kill('SIGKILL'); } catch { /* schon beendet */ } + const i = args.indexOf('--name'); + if (i !== -1 && args[i + 1]) { + try { exec(['rm', '-f', args[i + 1]], { env: minimalBase })?.on?.('error', () => {}); } catch { /* egal */ } + } + finish({ code: 124 }); + }, timeoutMs); + } + }); + } + + // Kurze docker-Aufrufe: stdout einsammeln. + async function capture(args) { + const out = []; + const r = await run(args, { onLine: (l) => 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(Boolean); + }, + async rm(name) { checkName(name); return capture(['rm', '-f', name]); }, + async stop(name) { checkName(name); return capture(['rm', '-f', name]); }, + }; +} diff --git a/runner/test/docker.test.js b/runner/test/docker.test.js new file mode 100644 index 0000000..5f4951b --- /dev/null +++ b/runner/test/docker.test.js @@ -0,0 +1,120 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { buildRunArgs, devRunArgs, createDocker, spawnDocker } from '../src/docker.js'; + +const base = { image: 'node:20', workDir: '/srv/w/build/1', cacheDir: '/srv/c/repo', cmd: ['npm', 'ci'] }; +const pair = (args, flag, val) => args.some((a, i) => a === flag && args[i + 1] === val); + +test('buildRunArgs: komplettes Sicherheitsprofil', () => { + const a = buildRunArgs(base); + assert.deepEqual(a.slice(0, 2), ['run', '--rm']); + assert.ok(a.includes('--init')); + for (const [f, v] of [['--memory', '1g'], ['--cpus', '1'], ['--pids-limit', '512'], ['--cap-drop', 'ALL'], ['--security-opt', 'no-new-privileges'], ['--user', '1000:1000'], ['--network', 'preview-sandbox'], ['-w', '/work'], ['-v', '/srv/w/build/1:/work'], ['-v', '/srv/c/repo:/cache'], ['-e', 'HOME=/cache/home'], ['-e', 'npm_config_cache=/cache/npm'], ['-e', 'COREPACK_HOME=/cache/corepack']]) { + assert.ok(pair(a, f, v), `${f} ${v}`); + } + assert.equal(a.filter((x) => x === '-v').length, 2); + assert.deepEqual(a.slice(-3), ['node:20', 'npm', 'ci']); +}); + +test('buildRunArgs: Variablen nur als Name, Werte nie in args', () => { + const a = buildRunArgs({ ...base, env: { API_KEY: 'geheim-wert', X: 'abc' }, memory: '1g' }); + assert.ok(pair(a, '-e', 'API_KEY') && pair(a, '-e', 'X')); + assert.ok(!a.join(' ').includes('geheim-wert')); +}); + +test('buildRunArgs: ungültiger Name/NUL/Name-Validierung', () => { + assert.throws(() => buildRunArgs({ ...base, env: { 'a b': '1' } })); + assert.throws(() => buildRunArgs({ ...base, env: { A: 'x\0y' } })); + assert.throws(() => buildRunArgs({ ...base, env: { HOME: 'x' } })); + assert.throws(() => buildRunArgs({ ...base, name: '-x' })); +}); + +test('buildRunArgs: memory/name optional', () => { + const a = buildRunArgs({ ...base, name: 'job-1', memory: '2g' }); + assert.ok(pair(a, '--memory', '2g') && pair(a, '--name', 'job-1')); +}); + +test('devRunArgs: detached, Name, Label, ohne --rm, 512m', () => { + const a = devRunArgs({ ...base, sub: 'demo-1', cmd: ['npm', 'run', 'dev'] }); + assert.ok(!a.includes('--rm')); + assert.ok(a.includes('-d')); + assert.ok(pair(a, '--name', 'preview-dev-demo-1')); + assert.ok(pair(a, '--label', 'webklar.preview=demo-1')); + assert.ok(pair(a, '--restart', 'no')); + assert.ok(pair(a, '--memory', '512m')); + assert.ok(pair(a, '--cap-drop', 'ALL') && pair(a, '--user', '1000:1000')); + assert.deepEqual(a.slice(-4), ['node:20', 'npm', 'run', 'dev']); +}); + +test('devRunArgs: Subdomain wird validiert', () => { + for (const sub of ['', 'A', 'a/b', '-x;', 'x'.repeat(64), undefined]) assert.throws(() => devRunArgs({ ...base, sub }), sub); +}); + +// Fake-Kindprozess +function fakeChild() { + const c = new EventEmitter(); + c.stdout = new PassThrough(); c.stderr = new PassThrough(); + c.killed = []; c.kill = (s) => { c.killed.push(s); }; + return c; +} + +test('run: streamt Zeilen, liefert Exit-Code, Umgebung minimal + Job-Env', async () => { + const calls = []; + const child = fakeChild(); + const docker = createDocker({ exec: (args, opts) => { calls.push({ args, opts }); return child; }, baseEnv: { PATH: '/bin', DOCKER_HOST: 'unix:///x', PREVIEW_RUNNER_TOKEN: 'leak' } }); + const lines = []; + const p = docker.run(['run', '-e', 'K'], { env: { K: 'v' }, onLine: (l) => lines.push(l) }); + child.stdout.write('eins\nzw'); + child.stderr.write('fehler\n'); + child.stdout.write('ei\n'); + child.stdout.end(); child.stderr.end(); + await new Promise((r) => setImmediate(r)); + child.emit('close', 3); + assert.deepEqual(await p, { code: 3 }); + assert.deepEqual(lines.sort(), ['eins', 'fehler', 'zwei']); + assert.deepEqual(calls[0].opts.env, { PATH: '/bin', DOCKER_HOST: 'unix:///x', K: 'v' }); +}); + +test('run: NUL im Wert ⇒ Fehler vor dem Start', async () => { + let n = 0; + const docker = createDocker({ exec: () => { n++; return fakeChild(); } }); + await assert.rejects(docker.run(['run'], { env: { A: 'x\0' } })); + assert.equal(n, 0); +}); + +test('run: Zeitlimit ⇒ Kill, docker rm -f, code 124', async () => { + const calls = []; + const child = fakeChild(); + const docker = createDocker({ exec: (args) => { calls.push(args); return calls.length === 1 ? child : Object.assign(fakeChild(), { __rm: true }); } }); + const p = docker.run(['run', '--name', 'job-9', 'img'], { timeoutMs: 20 }); + const r = await p; + assert.deepEqual(r, { code: 124 }); + assert.deepEqual(child.killed, ['SIGKILL']); + assert.deepEqual(calls[1], ['rm', '-f', 'job-9']); +}); + +test('ps/stop/rm', async () => { + const calls = []; + const exec = (args) => { + calls.push(args); + const c = fakeChild(); + setImmediate(() => { c.stdout.write('preview-dev-a\npreview-dev-b\n\n'); c.stdout.end(); c.stderr.end(); c.emit('close', 0); }); + return c; + }; + const docker = createDocker({ exec }); + assert.deepEqual(await docker.ps('webklar.preview'), ['preview-dev-a', 'preview-dev-b']); + assert.deepEqual(calls[0], ['ps', '--filter', 'label=webklar.preview', '--format', '{{.Names}}']); + await docker.stop('preview-dev-a'); + await docker.rm('preview-dev-a'); + assert.deepEqual(calls[1], ['rm', '-f', 'preview-dev-a']); + assert.deepEqual(calls[2], ['rm', '-f', 'preview-dev-a']); + assert.equal(typeof spawnDocker, 'function'); +}); + +test('rm/stop: ungültiger Name wird abgelehnt', async () => { + const docker = createDocker({ exec: () => { throw new Error('nicht aufrufen'); } }); + await assert.rejects(docker.rm('--all')); + await assert.rejects(docker.stop('a b')); +});