diff --git a/runner/Dockerfile b/runner/Dockerfile new file mode 100644 index 0000000..3e08ba8 --- /dev/null +++ b/runner/Dockerfile @@ -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"] diff --git a/runner/package.json b/runner/package.json new file mode 100644 index 0000000..e089c26 --- /dev/null +++ b/runner/package.json @@ -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" + } +} diff --git a/runner/src/config.js b/runner/src/config.js new file mode 100644 index 0000000..24f0321 --- /dev/null +++ b/runner/src/config.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, + }; +} diff --git a/runner/src/index.js b/runner/src/index.js new file mode 100644 index 0000000..e7e6cf1 --- /dev/null +++ b/runner/src/index.js @@ -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}`); +}); diff --git a/runner/src/server.js b/runner/src/server.js new file mode 100644 index 0000000..992dd75 --- /dev/null +++ b/runner/src/server.js @@ -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 }); + } + }); +} diff --git a/runner/src/validate.js b/runner/src/validate.js new file mode 100644 index 0000000..7846731 --- /dev/null +++ b/runner/src/validate.js @@ -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 }; +} diff --git a/runner/test/server.test.js b/runner/test/server.test.js new file mode 100644 index 0000000..a404ddd --- /dev/null +++ b/runner/test/server.test.js @@ -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); +});