fix: webhook fail-closed, /api/healthz endpoint, queued deploy
- webhook.js: fail-closed on missing WEBHOOK_SECRET (exits at startup, never accepts unsigned requests) - webhook.js: single-slot queue — push during deploy is held and runs after current deploy completes (not silently dropped) - deploy.sh + health-check.sh: URL corrected to /api/healthz
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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"; }
|
||||
|
||||
@@ -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); });
|
||||
|
||||
Reference in New Issue
Block a user