Runner: Docker-Adapter mit Sicherheitsprofil
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b8174e296a
commit
10ef2c8085
2 files changed
+246
No files matched your search
@@ -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]); },
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user