181 lines
7.6 KiB
JavaScript
181 lines
7.6 KiB
JavaScript
// 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-<runId>'), 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]); },
|
||
};
|
||
}
|