feat(deploy): Webhook, Build-Skript, nginx, Traefik
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
30f4ddc0f4
commit
906bdf1033
7 files changed
+1084
No files matched your search
@@ -0,0 +1,233 @@
|
||||
'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();
|
||||
}
|
||||
Reference in new issue
Block a user