'use strict'; /** * Deploy-Webhook für aircard-ios.org (Node 22, nur Bordmittel). * * POST /webhook/deploy Gitea-Push-Webhook; Signatur im Header x-gitea-signature * (HMAC-SHA256 über den Rohbody, Hex ohne Präfix). * 401 Signatur fehlt oder stimmt nicht * 413 Body größer als 1 MB * 200 {"ignored":true} Push auf einen anderen Branch (oder ohne Branch) * 202 {"queued":true} Push auf den Deploy-Branch; der Build läuft im Hintergrund * alles andere 404 * * Builds laufen nacheinander: höchstens ein laufender und ein wartender Lauf. Weitere * Anfragen während ein Lauf wartet, werden mit 202 beantwortet, fügen aber keinen * Lauf hinzu (der wartende Lauf holt ohnehin den neuesten Stand). * * Umgebung (nur beim Start als Programm): AIRCARD_WEBHOOK_TOKEN (Pflicht, sonst Exit 1), * AIRCARD_WEBHOOK_PORT (8087), AIRCARD_DEPLOY_BRANCH (main), AIRCARD_DEPLOY_SCRIPT * (/deploy.sh), POLL_INTERVAL_MINUTES (15; 0 schaltet das Polling ab). */ const http = require('node:http'); const crypto = require('node:crypto'); const { spawn } = require('node:child_process'); const MAX_BODY_BYTES = 1024 * 1024; const WEBHOOK_PATH = '/webhook/deploy'; const BRANCH_PREFIX = 'refs/heads/'; /** * Prüft die Gitea-Signatur: kleingeschriebenes Hex von HMAC-SHA256 über den Rohbody. * false bei leerem Secret, fehlender oder unpassend langer Signatur. */ function verifySignature(raw, signatureHex, secret) { if (typeof secret !== 'string' || secret === '') return false; if (typeof signatureHex !== 'string' || signatureHex === '') return false; if (!Buffer.isBuffer(raw)) return false; const expected = Buffer.from(crypto.createHmac('sha256', secret).update(raw).digest('hex')); const given = Buffer.from(signatureHex); // timingSafeEqual wirft bei ungleicher Länge, deshalb vorher prüfen. if (given.length !== expected.length) return false; return crypto.timingSafeEqual(given, expected); } /** `refs/heads/` -> ``, alles andere -> null. */ function branchFromPayload(body) { if (body === null || typeof body !== 'object') return null; const ref = body.ref; if (typeof ref !== 'string' || !ref.startsWith(BRANCH_PREFIX)) return null; const branch = ref.slice(BRANCH_PREFIX.length); return branch === '' ? null : branch; } /** * Baut den HTTP-Server (ohne ihn zu starten). * * @param {object} options * @param {string} options.secret Webhook-Secret; leer -> jede Anfrage bekommt 401 * @param {string} [options.branch] Deploy-Branch (Standard: main) * @param {(trigger: string) => Promise} options.runDeploy führt einen Lauf aus * @param {(line: string) => void} [options.log] bekommt fertige Zeilen, die mit [webhook] beginnen * @returns {http.Server} mit zusätzlicher Methode `enqueueDeploy(trigger)` für initial/poll-Läufe * über dieselbe Warteschlange */ function createServer({ secret, branch = 'main', runDeploy, log = console.log }) { const say = (message) => log(`[webhook] ${new Date().toISOString()} ${message}`); // Warteschlange: ein laufender Lauf + ein Platz für einen wartenden Lauf. let running = false; let pending = null; // Trigger des wartenden Laufs oder null async function drain() { if (running) return; running = true; try { while (pending !== null) { const trigger = pending; pending = null; say(`starting deployment (trigger=${trigger})`); try { await runDeploy(trigger); say(`deployment finished (trigger=${trigger})`); } catch (err) { say(`deployment failed (trigger=${trigger}): ${err && err.message ? err.message : err}`); } } } finally { running = false; } } /** @returns {boolean} true, wenn ein neuer Lauf vorgemerkt wurde; false, wenn schon einer wartet */ function enqueueDeploy(trigger) { if (pending !== null) { // Der Webhook baut immer, ein Poll darf überspringen: der wartende Lauf behält den stärkeren Trigger. if (trigger === 'webhook') pending = 'webhook'; return false; } pending = trigger; drain(); return true; } function sendJson(res, status, payload) { const body = JSON.stringify(payload); res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }); res.end(body); } function handleWebhook(req, res) { const chunks = []; let size = 0; let tooLarge = false; req.on('data', (chunk) => { if (tooLarge) return; // Rest verwerfen, die Verbindung bleibt sauber size += chunk.length; if (size > MAX_BODY_BYTES) { tooLarge = true; chunks.length = 0; say('rejected request: body too large'); sendJson(res, 413, { error: 'payload too large' }); return; } chunks.push(chunk); }); req.on('error', (err) => say(`request error: ${err.message}`)); req.on('end', () => { if (tooLarge) return; const raw = Buffer.concat(chunks); if (!verifySignature(raw, req.headers['x-gitea-signature'], secret)) { say('rejected request: invalid signature'); sendJson(res, 401, { error: 'invalid signature' }); return; } let pushedBranch = null; try { pushedBranch = branchFromPayload(JSON.parse(raw.toString('utf8'))); } catch { // kein JSON: wie ein Push ohne Branch behandeln } if (pushedBranch !== branch) { say(`ignoring push to ${pushedBranch === null ? 'unknown ref' : pushedBranch} (only ${branch} deploys)`); sendJson(res, 200, { ignored: true }); return; } // Sofort antworten: Gitea wartet nur wenige Sekunden, der Build dauert Minuten. const queued = enqueueDeploy('webhook'); say(queued ? `push to ${branch} queued` : `push to ${branch} accepted, a deployment is already waiting`); sendJson(res, 202, { queued: true }); }); } const server = http.createServer((req, res) => { const pathname = (req.url || '').split('?')[0]; if (req.method === 'POST' && pathname === WEBHOOK_PATH) { handleWebhook(req, res); return; } sendJson(res, 404, { error: 'not found' }); }); server.enqueueDeploy = enqueueDeploy; return server; } // ---- Start als Programm ----------------------------------------------------- /** * Startet `sh