Add auto-update system for DashCaddy instances
- self-updater.js: polls for new versions, downloads/verifies tarballs, triggers host-side rebuild via systemd path unit - dashcaddy-update.sh + systemd units: host-side container rebuild with automatic rollback on health check failure - 7 new /api/v1/system/* endpoints for version info, update check/apply, rollback, and update history - Frontend: DashCaddy tab in Updates modal with version display, changelog, update button, rollback, and notification dot - install.sh: updater service installation, volume mounts, env vars - build-release.sh + webhook-handler.js: release server pipeline (Gitea webhook → build tarball → deploy to get.dashcaddy.net) - Dockerfile: DASHCADDY_COMMIT build arg → VERSION file - Version bump to 1.1.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
136
dashcaddy-api/scripts/webhook-handler.js
Normal file
136
dashcaddy-api/scripts/webhook-handler.js
Normal file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* DashCaddy Release Webhook Handler
|
||||
* Receives push webhooks from Gitea, verifies HMAC signature,
|
||||
* and triggers build-release.sh.
|
||||
*
|
||||
* Usage: node webhook-handler.js
|
||||
* Env vars:
|
||||
* WEBHOOK_SECRET — Gitea webhook secret (required)
|
||||
* WEBHOOK_PORT — Listen port (default: 9090)
|
||||
* BUILD_SCRIPT — Path to build script (default: /opt/dashcaddy-release/build-release.sh)
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
const { spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
|
||||
const PORT = parseInt(process.env.WEBHOOK_PORT || '9090', 10);
|
||||
const SECRET = process.env.WEBHOOK_SECRET;
|
||||
const BUILD_SCRIPT = process.env.BUILD_SCRIPT || '/opt/dashcaddy-release/build-release.sh';
|
||||
const LOG_FILE = '/var/log/dashcaddy-release.log';
|
||||
|
||||
if (!SECRET) {
|
||||
console.error('WEBHOOK_SECRET environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let buildRunning = false;
|
||||
|
||||
function log(msg) {
|
||||
const line = `[webhook] ${new Date().toISOString()} ${msg}`;
|
||||
console.log(line);
|
||||
fs.appendFileSync(LOG_FILE, line + '\n');
|
||||
}
|
||||
|
||||
function verifySignature(body, signature) {
|
||||
if (!signature) return false;
|
||||
const hmac = crypto.createHmac('sha256', SECRET).update(body).digest('hex');
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(hmac)
|
||||
);
|
||||
}
|
||||
|
||||
function triggerBuild() {
|
||||
if (buildRunning) {
|
||||
log('Build already in progress, skipping');
|
||||
return;
|
||||
}
|
||||
buildRunning = true;
|
||||
log('Triggering build...');
|
||||
|
||||
const child = spawn('bash', [BUILD_SCRIPT], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, PATH: process.env.PATH },
|
||||
});
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
const lines = data.toString().trim().split('\n');
|
||||
lines.forEach(line => log(`[build] ${line}`));
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
const lines = data.toString().trim().split('\n');
|
||||
lines.forEach(line => log(`[build:err] ${line}`));
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
buildRunning = false;
|
||||
if (code === 0) {
|
||||
log('Build completed successfully');
|
||||
} else {
|
||||
log(`Build FAILED with exit code ${code}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
// Health check
|
||||
if (req.method === 'GET' && req.url === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'ok', buildRunning }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Only accept POST to /webhook
|
||||
if (req.method !== 'POST' || req.url !== '/webhook') {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
let body = '';
|
||||
req.on('data', chunk => { body += chunk; });
|
||||
req.on('end', () => {
|
||||
// Verify Gitea HMAC signature
|
||||
const sig = req.headers['x-gitea-signature'] || '';
|
||||
if (!verifySignature(body, sig)) {
|
||||
log('Signature verification FAILED');
|
||||
res.writeHead(403);
|
||||
res.end('Invalid signature');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(body);
|
||||
const ref = payload.ref || '';
|
||||
const branch = ref.replace('refs/heads/', '');
|
||||
|
||||
if (branch !== 'main') {
|
||||
log(`Ignoring push to ${branch} (not main)`);
|
||||
res.writeHead(200);
|
||||
res.end('Ignored (not main branch)');
|
||||
return;
|
||||
}
|
||||
|
||||
const pusher = payload.pusher?.login || 'unknown';
|
||||
const commits = payload.commits?.length || 0;
|
||||
log(`Push to main by ${pusher}: ${commits} commit(s)`);
|
||||
|
||||
triggerBuild();
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ accepted: true }));
|
||||
} catch (e) {
|
||||
log('Failed to parse webhook payload: ' + e.message);
|
||||
res.writeHead(400);
|
||||
res.end('Invalid payload');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
log(`Webhook handler listening on 127.0.0.1:${PORT}`);
|
||||
});
|
||||
Reference in New Issue
Block a user