diff --git a/vps/deploy.sh b/vps/deploy.sh index f5c58af..8bfc3de 100644 --- a/vps/deploy.sh +++ b/vps/deploy.sh @@ -12,7 +12,7 @@ LOG="$DEPLOY_DIR/deploy.log" TOKEN_FILE="/root/.gitea-replit-token" GITEA_HOST="143.198.27.163:3000" GITEA_REPO="admin/timmy-tower" -HEALTH_URL="http://localhost:8088/api/health" +HEALTH_URL="http://localhost:8088/api/healthz" log() { echo "[$(date -u +%FT%TZ)] [deploy] $*" | tee -a "$LOG"; } fail() { log "ERROR: $*"; exit 1; } diff --git a/vps/health-check.sh b/vps/health-check.sh index 32e3787..0e660cd 100644 --- a/vps/health-check.sh +++ b/vps/health-check.sh @@ -2,9 +2,9 @@ # ============================================================================= # /opt/timmy-tower/health-check.sh # Run by systemd timer every 5 minutes. -# Restarts timmy-tower if /api/health returns non-200. +# Restarts timmy-tower if /api/healthz returns non-200. # ============================================================================= -HEALTH_URL="http://localhost:8088/api/health" +HEALTH_URL="http://localhost:8088/api/healthz" LOG="/opt/timmy-tower/health.log" log() { echo "[$(date -u +%FT%TZ)] [health] $*" | tee -a "$LOG"; } diff --git a/vps/webhook.js b/vps/webhook.js index e81a9f5..95d7430 100644 --- a/vps/webhook.js +++ b/vps/webhook.js @@ -3,6 +3,9 @@ // /opt/timmy-tower/webhook.js // Lightweight webhook receiver: validates Gitea HMAC signature, runs deploy.sh // Runs as systemd service: timmy-deploy-hook +// +// Security: fails closed — if WEBHOOK_SECRET is not set, the server refuses +// to start rather than accepting unsigned requests. // ============================================================================= 'use strict'; @@ -18,7 +21,18 @@ const SECRET = process.env.WEBHOOK_SECRET || ''; const DEPLOY_SCRIPT = path.join(__dirname, 'deploy.sh'); const LOG_FILE = path.join(__dirname, 'deploy.log'); +// Fail closed — do not start without a secret +if (!SECRET) { + process.stderr.write( + '[webhook] FATAL: WEBHOOK_SECRET environment variable is not set.\n' + + ' Set it in /opt/timmy-tower/.env and restart the service.\n' + ); + process.exit(1); +} + +// Single-slot deploy queue: tracks current deploy + whether another is pending let deploying = false; +let pendingDeploy = null; // { pusher, commit } of the queued request, or null function log(msg) { const line = `[${new Date().toISOString()}] [webhook] ${msg}\n`; @@ -27,13 +41,11 @@ function log(msg) { } function verifySignature(payload, signature) { - if (!SECRET) { - log('WARN: WEBHOOK_SECRET not set — skipping signature check'); - return true; - } const hmac = crypto.createHmac('sha256', SECRET); hmac.update(payload); const expected = 'sha256=' + hmac.digest('hex'); + // Both buffers must be the same length for timingSafeEqual + if (Buffer.byteLength(expected) !== Buffer.byteLength(signature)) return false; try { return crypto.timingSafeEqual( Buffer.from(expected, 'utf8'), @@ -44,10 +56,30 @@ function verifySignature(payload, signature) { } } +function runDeploy(pusher, commit) { + deploying = true; + log(`Running deploy — pusher: ${pusher}, commit: ${commit}`); + execFile('bash', [DEPLOY_SCRIPT], { timeout: 600_000 }, (err) => { + deploying = false; + if (err) { + log(`Deploy FAILED: ${err.message}`); + } else { + log(`Deploy succeeded (commit ${commit})`); + } + // If another push arrived while we were deploying, run it now + if (pendingDeploy) { + const next = pendingDeploy; + pendingDeploy = null; + log(`Running queued deploy — pusher: ${next.pusher}, commit: ${next.commit}`); + runDeploy(next.pusher, next.commit); + } + }); +} + const server = http.createServer((req, res) => { if (req.method === 'GET' && req.url === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: true, deploying })); + res.end(JSON.stringify({ ok: true, deploying, queued: !!pendingDeploy })); return; } @@ -65,7 +97,7 @@ const server = http.createServer((req, res) => { || ''; if (!verifySignature(body, sig)) { - log(`Rejected deploy: invalid HMAC signature from ${req.socket.remoteAddress}`); + log(`Rejected: invalid HMAC from ${req.socket.remoteAddress}`); res.writeHead(401).end('Unauthorized'); return; } @@ -76,39 +108,33 @@ const server = http.createServer((req, res) => { const branch = (payload.ref || '').replace('refs/heads/', ''); if (branch && branch !== 'main') { log(`Skipping push to non-main branch: ${branch}`); - res.writeHead(200).end('Skipped'); + res.writeHead(200).end('Skipped non-main branch'); return; } + const pusher = payload.pusher + ? (payload.pusher.login || payload.pusher.name || 'unknown') + : 'unknown'; + const commit = payload.after ? payload.after.slice(0, 8) : 'unknown'; + if (deploying) { - log('Deploy already in progress — request queued'); - res.writeHead(202).end('Deploy already in progress'); + // Queue the latest push — overwrite any previous queued one + pendingDeploy = { pusher, commit }; + log(`Deploy in progress — queued commit ${commit} (will run after current deploy)`); + res.writeHead(202).end('Queued — will deploy after current deploy completes'); return; } - const pusher = payload.pusher ? payload.pusher.login || payload.pusher.name : 'unknown'; - const commit = payload.after ? payload.after.slice(0, 8) : 'unknown'; - log(`Deploy triggered — pusher: ${pusher}, commit: ${commit}`); - res.writeHead(202).end('Deploy started'); - - deploying = true; - execFile('bash', [DEPLOY_SCRIPT], { timeout: 600_000 }, (err, stdout, stderr) => { - deploying = false; - if (err) { - log(`Deploy FAILED: ${err.message}`); - } else { - log(`Deploy succeeded (commit ${commit})`); - } - }); + runDeploy(pusher, commit); }); }); server.listen(PORT, HOST, () => { log(`Webhook receiver listening on ${HOST}:${PORT}`); log(`Deploy script: ${DEPLOY_SCRIPT}`); - log(`HMAC secret: ${SECRET ? 'configured' : 'NOT SET (insecure)'}`); + log('HMAC secret: configured'); }); -process.on('SIGTERM', () => { log('SIGTERM received — shutting down'); process.exit(0); }); -process.on('SIGINT', () => { log('SIGINT received — shutting down'); process.exit(0); }); +process.on('SIGTERM', () => { log('SIGTERM — shutting down'); process.exit(0); }); +process.on('SIGINT', () => { log('SIGINT — shutting down'); process.exit(0); });