Runner: Geruest, Eingabepruefung, Auth
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c402837df6
commit
aeb08580bb
7 files changed
+296
No files matched your search
@@ -0,0 +1,6 @@
|
||||
FROM node:20-alpine
|
||||
RUN apk add --no-cache docker-cli git
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
COPY src ./src
|
||||
CMD ["node","src/index.js"]
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "webklar-preview-runner",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"test": "node --test test/*.test.js"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { writeFileSync, mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createApiServer, readMemAvailable } from '../src/server.js';
|
||||
import { loadConfig } from '../src/config.js';
|
||||
import { validateJob } from '../src/validate.js';
|
||||
|
||||
|
||||
const config = { ...loadConfig({ PREVIEW_RUNNER_TOKEN: 'geheim' }) };
|
||||
const submitted = [];
|
||||
const jobs = {
|
||||
submit: (j) => { submitted.push(j); return { runId: 'r1' }; },
|
||||
get: (id) => (id === 'r1' ? { runId: 'r1', state: 'building' } : null),
|
||||
queueLength: () => 0,
|
||||
};
|
||||
const stopped = [];
|
||||
const dev = {
|
||||
ensure: async (sub) => ({ sub, state: 'starting' }),
|
||||
stop: async (sub) => { stopped.push(sub); },
|
||||
list: () => [],
|
||||
};
|
||||
|
||||
async function withServer(cfg, fn) {
|
||||
const srv = createApiServer({ config: cfg, jobs, dev, mem: () => 123 });
|
||||
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
|
||||
const base = `http://127.0.0.1:${srv.address().port}`;
|
||||
try { await fn(base); } finally { await new Promise((r) => srv.close(r)); }
|
||||
}
|
||||
const auth = { Authorization: 'Bearer geheim', 'Content-Type': 'application/json' };
|
||||
const good = { sub: 'demo', repo: 'JUSN/demo', sha: 'abcdef1', branch: 'main' };
|
||||
|
||||
test('loadConfig: Vorgaben', () => {
|
||||
const c = loadConfig({});
|
||||
assert.equal(c.baseHost, 'project.webklar.com');
|
||||
assert.equal(c.workRoot, '/srv/webklar-preview-runner/work');
|
||||
assert.equal(c.cacheRoot, '/srv/webklar-preview-runner/cache');
|
||||
assert.equal(c.previewsRoot, '/var/www/previews');
|
||||
assert.equal(c.apiPort, 8080);
|
||||
assert.equal(c.proxyPort, 8081);
|
||||
assert.equal(c.minFreeBytes, 1.5 * 1024 ** 3);
|
||||
assert.equal(c.buildTimeoutMs, 900000);
|
||||
assert.equal(c.devIdleMs, 1200000);
|
||||
assert.equal(c.devMax, 3);
|
||||
assert.equal(c.devReadyMs, 180000);
|
||||
assert.equal(c.token, '');
|
||||
});
|
||||
|
||||
test('401 ohne/mit falschem Token und bei leerem konfiguriertem Token', async () => {
|
||||
await withServer(config, async (base) => {
|
||||
assert.equal((await fetch(`${base}/status`)).status, 401);
|
||||
assert.equal((await fetch(`${base}/status`, { headers: { Authorization: 'Bearer falsch' } })).status, 401);
|
||||
});
|
||||
await withServer({ ...config, token: '' }, async (base) => {
|
||||
assert.equal((await fetch(`${base}/status`, { headers: { Authorization: 'Bearer ' } })).status, 401);
|
||||
});
|
||||
});
|
||||
|
||||
test('GET /status', async () => {
|
||||
await withServer(config, async (base) => {
|
||||
const r = await fetch(`${base}/status`, { headers: auth });
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(await r.json(), { dev: [], queue: 0, memAvailable: 123 });
|
||||
});
|
||||
});
|
||||
|
||||
test('POST /jobs/build: 400 bei ungültiger Eingabe, 200 bei gültiger', async () => {
|
||||
await withServer(config, async (base) => {
|
||||
for (const bad of [{ sub: '../x' }, { repo: 'a/b;rm' }, { sha: 'xyz' }]) {
|
||||
const r = await fetch(`${base}/jobs/build`, { method: 'POST', headers: auth, body: JSON.stringify({ ...good, ...bad }) });
|
||||
assert.equal(r.status, 400);
|
||||
}
|
||||
const ok = await fetch(`${base}/jobs/build`, { method: 'POST', headers: auth, body: JSON.stringify(good) });
|
||||
assert.equal(ok.status, 200);
|
||||
assert.deepEqual(await ok.json(), { runId: 'r1' });
|
||||
assert.equal(submitted[0].sub, 'demo');
|
||||
const nojson = await fetch(`${base}/jobs/build`, { method: 'POST', headers: auth, body: '{kaputt' });
|
||||
assert.equal(nojson.status, 400);
|
||||
});
|
||||
});
|
||||
|
||||
test('GET /jobs/:runId, Dev-Start/Stop', async () => {
|
||||
await withServer(config, async (base) => {
|
||||
assert.equal((await fetch(`${base}/jobs/r1`, { headers: auth })).status, 200);
|
||||
assert.equal((await fetch(`${base}/jobs/nix`, { headers: auth })).status, 404);
|
||||
const s = await fetch(`${base}/dev/demo/start`, { method: 'POST', headers: auth, body: JSON.stringify(good) });
|
||||
assert.equal(s.status, 200);
|
||||
assert.equal((await fetch(`${base}/dev/..x/start`, { method: 'POST', headers: auth, body: JSON.stringify(good) })).status, 400);
|
||||
const t = await fetch(`${base}/dev/demo/stop`, { method: 'POST', headers: auth });
|
||||
assert.equal(t.status, 200);
|
||||
assert.deepEqual(stopped, ['demo']);
|
||||
});
|
||||
});
|
||||
|
||||
test('Body über 256 KB ⇒ 413', async () => {
|
||||
await withServer(config, async (base) => {
|
||||
const r = await fetch(`${base}/jobs/build`, { method: 'POST', headers: auth, body: JSON.stringify({ ...good, x: 'a'.repeat(300 * 1024) }) });
|
||||
assert.equal(r.status, 413);
|
||||
});
|
||||
});
|
||||
|
||||
test('validateJob: env, index, mode', () => {
|
||||
assert.equal(validateJob({ ...good, mode: 'dev', index: 'src/a.html', env: { A_B: 'x' } }).ok, true);
|
||||
assert.equal(validateJob({ ...good, mode: 'off' }).ok, false);
|
||||
assert.equal(validateJob({ ...good, index: '../etc' }).ok, false);
|
||||
assert.equal(validateJob({ ...good, index: '/abs' }).ok, false);
|
||||
assert.equal(validateJob({ ...good, env: { 'a-b': 'x' } }).ok, false);
|
||||
assert.equal(validateJob({ ...good, env: { A: 'x'.repeat(4097) } }).ok, false);
|
||||
const many = Object.fromEntries(Array.from({ length: 31 }, (_, i) => [`V${i}`, '1']));
|
||||
assert.equal(validateJob({ ...good, env: many }).ok, false);
|
||||
assert.equal(validateJob({ ...good, branch: 'a b' }).ok, false);
|
||||
assert.equal(validateJob(null).ok, false);
|
||||
});
|
||||
|
||||
test('readMemAvailable liest MemAvailable in Bytes', () => {
|
||||
const p = join(mkdtempSync(join(tmpdir(), 'mem-')), 'meminfo');
|
||||
writeFileSync(p, 'MemTotal: 8000000 kB\nMemFree: 100000 kB\nMemAvailable: 2048000 kB\n');
|
||||
assert.equal(readMemAvailable(p), 2048000 * 1024);
|
||||
});
|
||||
Reference in new issue
Block a user