import http from 'node:http'; import crypto from 'node:crypto'; import { readFileSync } from 'node:fs'; import { validateJob, isValidSub, isValidRunId } from './validate.js'; const MAX_BODY = 256 * 1024; export function readMemAvailable(path = '/proc/meminfo') { const m = /^MemAvailable:\s+(\d+)\s+kB/m.exec(readFileSync(path, 'utf8')); return m ? Number(m[1]) * 1024 : 0; } function tokenOk(header, token) { if (!token) return false; const m = /^Bearer (.+)$/.exec(header || ''); if (!m) return false; const a = crypto.createHash('sha256').update(m[1]).digest(); const b = crypto.createHash('sha256').update(token).digest(); return crypto.timingSafeEqual(a, b); } function send(res, status, obj) { const body = JSON.stringify(obj); res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }); res.end(body); } function readJson(req) { return new Promise((resolve, reject) => { const chunks = []; let size = 0; let tooBig = false; req.on('data', (c) => { if (tooBig) return; // Rest verwerfen, damit die Antwort noch ausgeliefert wird size += c.length; if (size > MAX_BODY) { tooBig = true; chunks.length = 0; return; } chunks.push(c); }); req.on('end', () => { if (tooBig) return reject(Object.assign(new Error('Anfrage zu groß'), { status: 413 })); const raw = Buffer.concat(chunks).toString('utf8'); if (!raw) return resolve({}); try { resolve(JSON.parse(raw)); } catch { reject(Object.assign(new Error('Ungültiges JSON'), { status: 400 })); } }); req.on('error', reject); }); } export function createApiServer({ config, jobs, dev, mem = readMemAvailable }) { return http.createServer(async (req, res) => { try { if (!tokenOk(req.headers.authorization, config.token)) return send(res, 401, { error: 'Nicht autorisiert' }); const url = new URL(req.url, 'http://x'); const parts = url.pathname.split('/').filter(Boolean); const method = req.method; if (method === 'GET' && url.pathname === '/status') { return send(res, 200, { dev: dev.list(), queue: jobs.queueLength(), memAvailable: mem() }); } if (method === 'POST' && url.pathname === '/jobs/build') { const v = validateJob(await readJson(req)); if (!v.ok) return send(res, 400, { error: v.error }); return send(res, 200, jobs.submit(v.job)); } if (method === 'GET' && parts[0] === 'jobs' && parts.length === 2) { if (!isValidRunId(parts[1])) return send(res, 400, { error: 'Ungültige Lauf-ID' }); const run = jobs.get(parts[1]); return run ? send(res, 200, run) : send(res, 404, { error: 'Lauf nicht gefunden' }); } if (method === 'PUT' && parts[0] === 'dev' && parts.length === 2) { const sub = parts[1]; if (!isValidSub(sub)) return send(res, 400, { error: 'Ungültige Subdomain' }); const v = validateJob(await readJson(req)); if (!v.ok) return send(res, 400, { error: v.error }); if (v.job.sub !== sub) return send(res, 400, { error: 'Subdomain stimmt nicht überein' }); return send(res, 200, { state: (await dev.register(sub, v.job)).state }); } if (method === 'POST' && parts[0] === 'dev' && parts.length === 3 && ['start', 'stop'].includes(parts[2])) { const sub = parts[1]; if (!isValidSub(sub)) return send(res, 400, { error: 'Ungültige Subdomain' }); if (parts[2] === 'stop') { await dev.stop(sub); return send(res, 200, { ok: true }); } const v = validateJob(await readJson(req)); if (!v.ok) return send(res, 400, { error: v.error }); if (v.job.sub !== sub) return send(res, 400, { error: 'Subdomain stimmt nicht überein' }); return send(res, 200, await dev.ensure(sub, v.job)); } return send(res, 404, { error: 'Nicht gefunden' }); } catch (e) { const status = e.status || 500; if (status >= 500) console.error('Runner-Fehler:', e); if (!res.headersSent) send(res, status, { error: status === 500 ? 'Interner Fehler' : e.message }); } }); }