Runner: Geruest, Eingabepruefung, Auth

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-09-30 21:53:41 +00:00
1 parent c402837df6
commit aeb08580bb
7 files changed
+296

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
// Konfiguration aus Umgebungsvariablen. Pfade sind HOST-Pfade (R6): der Runner mountet sie identisch.
export function loadConfig(env = process.env) {
return {
token: env.PREVIEW_RUNNER_TOKEN || '',
giteaUrl: env.GITEA_URL || '',
giteaToken: env.GITEA_TOKEN || '',
baseHost: env.PREVIEW_BASE_HOST || 'project.webklar.com',
previewsRoot: env.PREVIEWS_ROOT || '/var/www/previews',
workRoot: env.RUNNER_WORK_ROOT || '/srv/webklar-preview-runner/work',
cacheRoot: env.RUNNER_CACHE_ROOT || '/srv/webklar-preview-runner/cache',
apiPort: 8080,
proxyPort: 8081,
minFreeBytes: 1.5 * 1024 ** 3,
buildTimeoutMs: 15 * 60e3,
devIdleMs: 20 * 60e3,
devMax: 3,
devReadyMs: 180e3,
};
}
+11
View File
@@ -0,0 +1,11 @@
import { loadConfig } from './config.js';
import { createApiServer } from './server.js';
// Jobs/Dev/Proxy kommen in späteren Tasks; bis dahin nur Stubs, damit /status antwortet.
const config = loadConfig(process.env);
const jobs = { submit: () => { throw Object.assign(new Error('Noch nicht verfügbar'), { status: 503 }); }, get: () => null, queueLength: () => 0 };
const dev = { ensure: async () => { throw Object.assign(new Error('Noch nicht verfügbar'), { status: 503 }); }, stop: async () => {}, list: () => [] };
createApiServer({ config, jobs, dev }).listen(config.apiPort, () => {
console.log(`Preview-Runner API auf Port ${config.apiPort}`);
});
+87
View File
@@ -0,0 +1,87 @@
import http from 'node:http';
import crypto from 'node:crypto';
import { readFileSync } from 'node:fs';
import { validateJob, isValidSub } 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) {
const run = jobs.get(parts[1]);
return run ? send(res, 200, run) : send(res, 404, { error: 'Lauf nicht gefunden' });
}
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 (!res.headersSent) send(res, status, { error: status === 500 ? 'Interner Fehler' : e.message });
}
});
}
+43
View File
@@ -0,0 +1,43 @@
// Strikte Eingabeprüfung für Runner-Jobs.
const RE_SUB = /^[a-z0-9-]{1,63}$/;
const RE_REPO = /^[\w.-]+\/[\w.-]+$/;
const RE_SHA = /^[0-9a-f]{7,40}$/;
const RE_BRANCH = /^[\w./-]{1,100}$/;
const RE_ENV = /^[A-Z_][A-Z0-9_]*$/;
const MODES = ['auto', 'build', 'dev'];
export const isValidSub = (s) => typeof s === 'string' && RE_SUB.test(s);
export function validateJob(body) {
if (!body || typeof body !== 'object' || Array.isArray(body)) return { ok: false, error: 'Ungültige Anfrage' };
const { sub, repo, sha, branch } = body;
if (!isValidSub(sub)) return { ok: false, error: 'Ungültige Subdomain' };
if (typeof repo !== 'string' || !RE_REPO.test(repo)) return { ok: false, error: 'Ungültiges Repository' };
if (typeof sha !== 'string' || !RE_SHA.test(sha)) return { ok: false, error: 'Ungültiger Commit' };
if (typeof branch !== 'string' || !RE_BRANCH.test(branch) || branch.includes('..')) return { ok: false, error: 'Ungültiger Branch' };
const mode = body.mode === undefined || body.mode === '' ? 'auto' : body.mode;
if (!MODES.includes(mode)) return { ok: false, error: 'Ungültiger Modus' };
let index;
if (body.index !== undefined && body.index !== '') {
const i = body.index;
if (typeof i !== 'string' || i.length > 200 || i.startsWith('/') || i.split(/[\\/]/).includes('..') || i.includes('\0')) {
return { ok: false, error: 'Ungültiger Startseiten-Pfad' };
}
index = i;
}
const env = {};
if (body.env !== undefined) {
if (!body.env || typeof body.env !== 'object' || Array.isArray(body.env)) return { ok: false, error: 'Ungültige Variablen' };
const names = Object.keys(body.env);
if (names.length > 30) return { ok: false, error: 'Zu viele Variablen (max. 30)' };
for (const n of names) {
const v = body.env[n];
if (!RE_ENV.test(n)) return { ok: false, error: `Ungültiger Variablenname: ${n}` };
if (typeof v !== 'string' || Buffer.byteLength(v) > 4096) return { ok: false, error: `Ungültiger Wert für ${n}` };
env[n] = v;
}
}
const job = { ...body, sub, repo, sha, branch, mode, env };
if (index !== undefined) job.index = index; else delete job.index;
return { ok: true, job };
}