Files
AirCardIOS/deploy/webhook-server.js
T
2026-10-04 08:22:46 +00:00

234 lines
8.3 KiB
JavaScript

'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/<x>` -> `<x>`, 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<void>} 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 <script>` mit DEPLOY_TRIGGER; erfolgreich bei Exit-Code 0, sonst Ablehnung.
* Das Webhook-Secret bekommt das Skript nicht (npm und die Build-Skripte laufen darunter).
*/
function spawnDeploy(script, trigger) {
return new Promise((resolve, reject) => {
const env = { ...process.env, DEPLOY_TRIGGER: trigger };
delete env.AIRCARD_WEBHOOK_TOKEN;
const child = spawn('sh', [script], { stdio: 'inherit', env });
child.once('error', reject);
child.once('exit', (code, signal) => {
if (code === 0) resolve();
else reject(new Error(`deploy exited with ${code === null ? `signal ${signal}` : `code ${code}`}`));
});
});
}
function integerFromEnv(name, fallback, { min }) {
const raw = process.env[name];
if (raw === undefined || raw.trim() === '') return fallback;
const value = Number.parseInt(raw, 10);
return Number.isInteger(value) && value >= min ? value : fallback;
}
function main() {
const secret = process.env.AIRCARD_WEBHOOK_TOKEN || '';
if (secret.trim() === '') {
console.error('[webhook] AIRCARD_WEBHOOK_TOKEN ist nicht gesetzt - Abbruch (ohne Secret wird kein Webhook angeboten)');
process.exit(1);
}
const port = integerFromEnv('AIRCARD_WEBHOOK_PORT', 8087, { min: 1 });
const branch = process.env.AIRCARD_DEPLOY_BRANCH || 'main';
const script = process.env.AIRCARD_DEPLOY_SCRIPT || '/deploy.sh';
const pollMinutes = integerFromEnv('POLL_INTERVAL_MINUTES', 15, { min: 0 });
const server = createServer({
secret,
branch,
runDeploy: (trigger) => spawnDeploy(script, trigger),
});
server.listen(port, () => {
console.log(`[webhook] ${new Date().toISOString()} listening on :${port} (branch ${branch})`);
server.enqueueDeploy('initial');
if (pollMinutes > 0) {
console.log(`[webhook] ${new Date().toISOString()} periodic check enabled: every ${pollMinutes} minutes`);
setInterval(() => server.enqueueDeploy('poll'), pollMinutes * 60 * 1000);
}
});
}
module.exports = { verifySignature, branchFromPayload, createServer };
if (require.main === module) {
main();
}