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:
2026-03-07 03:11:35 -08:00
parent 9a0abc02d1
commit ffa6966fd3
14 changed files with 1395 additions and 4 deletions

View 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}`);
});