Files
dukang/deploy/webhook-server.mjs
T

137 lines
4.2 KiB
JavaScript

/**
* CodeUp / GitLab 兼容的部署 Webhook 接收器
* 监听 127.0.0.1:8095,由 Nginx 反代 /hooks/deploy
*/
import http from 'http';
import { spawn } from 'child_process';
import { readFileSync, existsSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
function loadEnv() {
const envPath = join(__dirname, 'auto-release.env');
if (!existsSync(envPath)) return;
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
const trimmed = line.replace(/\r$/, '').trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
let val = trimmed.slice(eq + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
if (!(key in process.env)) process.env[key] = val;
}
}
loadEnv();
const PORT = Number(process.env.DEPLOY_WEBHOOK_PORT || 8095);
const HOST = process.env.DEPLOY_WEBHOOK_HOST || '127.0.0.1';
const SECRET = (process.env.DEPLOY_WEBHOOK_SECRET || '').replace(/\r$/, '').trim();
const ALLOWED_REF = process.env.DEPLOY_GIT_REF || 'refs/heads/dev';
const APP_ROOT = process.env.APP_ROOT || '/opt/dukang-haoke';
const RELEASE_SCRIPT = join(APP_ROOT, 'deploy', 'auto-release.sh');
const DEBOUNCE_MS = Number(process.env.DEPLOY_WEBHOOK_DEBOUNCE_MS || 15000);
let lastTriggerAt = 0;
let lastTriggerRef = '';
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('error', reject);
});
}
function getToken(req) {
return (
req.headers['x-gitlab-token'] ||
req.headers['x-codeup-token'] ||
req.headers['x-deploy-token'] ||
''
);
}
function json(res, status, data) {
const body = JSON.stringify(data);
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(body);
}
function triggerRelease(trigger) {
const now = Date.now();
if (now - lastTriggerAt < DEBOUNCE_MS && lastTriggerRef === trigger) {
console.log(`[webhook] debounced duplicate trigger ref=${trigger}`);
return false;
}
lastTriggerAt = now;
lastTriggerRef = trigger;
console.log(`[webhook] trigger deploy ref=${trigger}`);
const child = spawn('bash', [RELEASE_SCRIPT], {
detached: true,
stdio: 'ignore',
env: { ...process.env, DEPLOY_TRIGGER: trigger },
});
child.unref();
return true;
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
if (req.method === 'GET' && url.pathname === '/health') {
return json(res, 200, { ok: true, service: 'dukang-deploy-webhook' });
}
if (req.method !== 'POST' || url.pathname !== '/deploy') {
return json(res, 404, { ok: false, message: 'not found' });
}
if (!SECRET) {
return json(res, 503, { ok: false, message: 'webhook secret not configured' });
}
const token = getToken(req);
if (token !== SECRET) {
console.log(`[webhook] rejected invalid token from ${req.headers['x-real-ip'] || req.socket.remoteAddress || 'unknown'}`);
return json(res, 403, { ok: false, message: 'invalid token' });
}
let payload = {};
try {
const raw = await readBody(req);
if (raw) payload = JSON.parse(raw);
} catch {
return json(res, 400, { ok: false, message: 'invalid json body' });
}
const ref = payload.ref || payload.object_attributes?.ref || '';
if (ref && ref !== ALLOWED_REF) {
console.log(`[webhook] ignored ref=${ref} (allowed=${ALLOWED_REF})`);
return json(res, 200, {
ok: true,
skipped: true,
message: `ignored ref: ${ref} (allowed: ${ALLOWED_REF})`,
});
}
const started = triggerRelease(ref || 'manual');
return json(res, 202, {
ok: true,
accepted: true,
started,
message: started ? 'deploy started' : 'deploy debounced (duplicate webhook)',
ref: ref || ALLOWED_REF,
});
});
server.listen(PORT, HOST, () => {
console.log(`dukang deploy webhook listening on http://${HOST}:${PORT}`);
});