ci: HMAC signature verification for Gitea webhooks
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
// Task Team — Deploy webhook — 2026-03-29
|
// Task Team — Deploy webhook — 2026-03-29
|
||||||
const { exec, execSync } = require("child_process");
|
const { exec, execSync } = require("child_process");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
|
||||||
const DEPLOY_SECRET = process.env.DEPLOY_SECRET || "taskteam-deploy-2026";
|
const DEPLOY_SECRET = process.env.DEPLOY_SECRET || "taskteam-deploy-2026";
|
||||||
const EXEC_ENV = { ...process.env, PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", HOME: "/root" };
|
const EXEC_ENV = { ...process.env, PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", HOME: "/root" };
|
||||||
@@ -11,10 +12,41 @@ function runCmd(cmd, timeout) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deployRoutes(app) {
|
async function deployRoutes(app) {
|
||||||
|
// Override JSON parser in this plugin scope to capture raw body
|
||||||
|
app.removeContentTypeParser("application/json");
|
||||||
|
app.addContentTypeParser("application/json", { parseAs: "string" }, (req, body, done) => {
|
||||||
|
req.rawBody = body;
|
||||||
|
try {
|
||||||
|
done(null, JSON.parse(body));
|
||||||
|
} catch (e) {
|
||||||
|
done(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/deploy/webhook", async (req, reply) => {
|
app.post("/deploy/webhook", async (req, reply) => {
|
||||||
// Verify secret
|
// Verify secret - support multiple methods
|
||||||
const secret = req.headers["x-gitea-secret"] || (req.body && req.body.secret);
|
const signature = req.headers["x-gitea-signature"];
|
||||||
if (secret !== DEPLOY_SECRET) {
|
const headerSecret = req.headers["x-gitea-secret"];
|
||||||
|
const bodySecret = req.body && req.body.secret;
|
||||||
|
|
||||||
|
let authorized = false;
|
||||||
|
|
||||||
|
// Method 1: Gitea HMAC-SHA256 signature
|
||||||
|
if (signature && req.rawBody) {
|
||||||
|
const hmac = crypto.createHmac("sha256", DEPLOY_SECRET).update(req.rawBody).digest("hex");
|
||||||
|
authorized = (hmac === signature);
|
||||||
|
}
|
||||||
|
// Method 2: Plain secret header (manual/curl testing)
|
||||||
|
if (!authorized && headerSecret === DEPLOY_SECRET) {
|
||||||
|
authorized = true;
|
||||||
|
}
|
||||||
|
// Method 3: Secret in body
|
||||||
|
if (!authorized && bodySecret === DEPLOY_SECRET) {
|
||||||
|
authorized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authorized) {
|
||||||
|
app.log.warn("Deploy webhook auth failed");
|
||||||
return reply.code(401).send({ error: "invalid secret" });
|
return reply.code(401).send({ error: "invalid secret" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +57,6 @@ async function deployRoutes(app) {
|
|||||||
|
|
||||||
app.log.info("Deploy triggered by Gitea webhook");
|
app.log.info("Deploy triggered by Gitea webhook");
|
||||||
|
|
||||||
// Run deploy asynchronously via shell script so we can return 200 immediately
|
|
||||||
const deployScript = `#!/bin/bash
|
const deployScript = `#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||||
@@ -33,18 +64,14 @@ export HOME="/root"
|
|||||||
LOG="${DEPLOY_LOG}"
|
LOG="${DEPLOY_LOG}"
|
||||||
echo "=== Deploy started at $(date -Is) ===" >> $LOG
|
echo "=== Deploy started at $(date -Is) ===" >> $LOG
|
||||||
|
|
||||||
# Pull latest code
|
|
||||||
cd /opt/task-team
|
cd /opt/task-team
|
||||||
git pull origin master >> $LOG 2>&1
|
git pull origin master >> $LOG 2>&1
|
||||||
|
|
||||||
# Install deps
|
|
||||||
cd /opt/task-team/api
|
cd /opt/task-team/api
|
||||||
npm install --production >> $LOG 2>&1
|
npm install --production >> $LOG 2>&1
|
||||||
|
|
||||||
# Reload API (with --force to avoid "already in progress")
|
|
||||||
pm2 reload taskteam-api --force >> $LOG 2>&1
|
pm2 reload taskteam-api --force >> $LOG 2>&1
|
||||||
|
|
||||||
# Build frontend (if it exists)
|
|
||||||
if [ -d /opt/task-team/apps/tasks ] && [ -f /opt/task-team/apps/tasks/package.json ]; then
|
if [ -d /opt/task-team/apps/tasks ] && [ -f /opt/task-team/apps/tasks/package.json ]; then
|
||||||
cd /opt/task-team/apps/tasks
|
cd /opt/task-team/apps/tasks
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:3000 npm run build >> $LOG 2>&1
|
NEXT_PUBLIC_API_URL=http://localhost:3000 npm run build >> $LOG 2>&1
|
||||||
@@ -53,14 +80,12 @@ fi
|
|||||||
|
|
||||||
echo "=== Deploy completed at $(date -Is) ===" >> $LOG
|
echo "=== Deploy completed at $(date -Is) ===" >> $LOG
|
||||||
`;
|
`;
|
||||||
// Write and execute deploy script in background
|
|
||||||
fs.writeFileSync("/opt/task-team/deploy.sh", deployScript, { mode: 0o755 });
|
fs.writeFileSync("/opt/task-team/deploy.sh", deployScript, { mode: 0o755 });
|
||||||
exec("nohup /opt/task-team/deploy.sh &", { env: EXEC_ENV, detached: true });
|
exec("nohup /opt/task-team/deploy.sh &", { env: EXEC_ENV, detached: true });
|
||||||
|
|
||||||
return { status: "deploying", message: "Deploy started in background. Check /deploy/status for progress." };
|
return { status: "deploying", message: "Deploy started in background. Check /deploy/status for progress." };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Deploy status
|
|
||||||
app.get("/deploy/status", async () => {
|
app.get("/deploy/status", async () => {
|
||||||
try {
|
try {
|
||||||
const commit = runCmd("cd /opt/task-team && git log --oneline -1").trim();
|
const commit = runCmd("cd /opt/task-team && git log --oneline -1").trim();
|
||||||
@@ -71,7 +96,6 @@ echo "=== Deploy completed at $(date -Is) ===" >> $LOG
|
|||||||
uptime: p.pm2_env.pm_uptime,
|
uptime: p.pm2_env.pm_uptime,
|
||||||
restarts: p.pm2_env.restart_time
|
restarts: p.pm2_env.restart_time
|
||||||
}));
|
}));
|
||||||
// Last deploy log
|
|
||||||
let lastDeploy = "";
|
let lastDeploy = "";
|
||||||
try {
|
try {
|
||||||
const log = fs.readFileSync(DEPLOY_LOG, "utf8");
|
const log = fs.readFileSync(DEPLOY_LOG, "utf8");
|
||||||
@@ -83,19 +107,6 @@ echo "=== Deploy completed at $(date -Is) ===" >> $LOG
|
|||||||
return { status: "error", message: e.message };
|
return { status: "error", message: e.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Manual trigger (GET for convenience)
|
|
||||||
app.post("/deploy/trigger", async (req, reply) => {
|
|
||||||
const secret = req.headers["x-deploy-secret"] || (req.body && req.body.secret);
|
|
||||||
if (secret !== DEPLOY_SECRET) {
|
|
||||||
return reply.code(401).send({ error: "invalid secret" });
|
|
||||||
}
|
|
||||||
// Simulate a push to master
|
|
||||||
req.body = { ref: "refs/heads/master", secret: DEPLOY_SECRET };
|
|
||||||
req.headers["x-gitea-secret"] = DEPLOY_SECRET;
|
|
||||||
return app.inject({ method: "POST", url: "/api/v1/deploy/webhook", payload: req.body, headers: { "x-gitea-secret": DEPLOY_SECRET } }).then(r => JSON.parse(r.body));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = deployRoutes;
|
module.exports = deployRoutes;
|
||||||
// CI/CD pipeline active — 2026-03-29T13:24:58+00:00
|
|
||||||
|
|||||||
Reference in New Issue
Block a user