211 lines
8.9 KiB
JavaScript
211 lines
8.9 KiB
JavaScript
|
|
"use strict";
|
||
|
|
|
||
|
|
const crypto = require("node:crypto");
|
||
|
|
const fs = require("node:fs");
|
||
|
|
const http = require("node:http");
|
||
|
|
const path = require("node:path");
|
||
|
|
const { spawn } = require("node:child_process");
|
||
|
|
|
||
|
|
function loadConfig() {
|
||
|
|
const configPath = process.env.DEPLOY_RECEIVER_CONFIG;
|
||
|
|
if (!configPath) throw new Error("DEPLOY_RECEIVER_CONFIG is required");
|
||
|
|
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||
|
|
for (const key of ["repo_path", "repository_full_name", "allowed_ref", "audit_dir", "lock_file", "actions"]) {
|
||
|
|
if (!config[key]) throw new Error(`missing config field: ${key}`);
|
||
|
|
}
|
||
|
|
return config;
|
||
|
|
}
|
||
|
|
|
||
|
|
function safeEqualHex(expected, supplied) {
|
||
|
|
if (!/^[a-f0-9]{64}$/i.test(supplied || "")) return false;
|
||
|
|
const a = Buffer.from(expected, "hex");
|
||
|
|
const b = Buffer.from(supplied, "hex");
|
||
|
|
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||
|
|
}
|
||
|
|
|
||
|
|
function verifySignature(secret, rawBody, header) {
|
||
|
|
const supplied = String(header || "").replace(/^sha256=/i, "");
|
||
|
|
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
|
||
|
|
return safeEqualHex(expected, supplied);
|
||
|
|
}
|
||
|
|
|
||
|
|
function collectChangedFiles(payload) {
|
||
|
|
const files = new Set();
|
||
|
|
for (const commit of payload.commits || []) {
|
||
|
|
for (const group of ["added", "modified"]) {
|
||
|
|
for (const file of commit[group] || []) files.add(file);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return [...files];
|
||
|
|
}
|
||
|
|
|
||
|
|
function validManifestPath(file, prefix) {
|
||
|
|
return file.startsWith(prefix) && /^[A-Za-z0-9._/-]+\.json$/.test(file) && !file.includes("..") && !path.isAbsolute(file);
|
||
|
|
}
|
||
|
|
|
||
|
|
function validRequest(request) {
|
||
|
|
if (!request || typeof request !== "object" || Array.isArray(request)) return false;
|
||
|
|
return /^[A-Za-z0-9._-]{8,80}$/.test(request.request_id || "") &&
|
||
|
|
/^[a-z0-9-]{2,60}$/.test(request.module || "") &&
|
||
|
|
/^[a-z0-9-]{2,60}$/.test(request.action || "") &&
|
||
|
|
request.approved === true &&
|
||
|
|
Object.keys(request).every((key) => ["request_id", "module", "action", "approved", "note"].includes(key));
|
||
|
|
}
|
||
|
|
|
||
|
|
function run(argv, options = {}) {
|
||
|
|
return new Promise((resolve) => {
|
||
|
|
const child = spawn(argv[0], argv.slice(1), {
|
||
|
|
cwd: options.cwd,
|
||
|
|
env: options.env || { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" },
|
||
|
|
shell: false,
|
||
|
|
stdio: ["ignore", "pipe", "pipe"]
|
||
|
|
});
|
||
|
|
let stdout = "";
|
||
|
|
let stderr = "";
|
||
|
|
const limit = options.outputLimit || 64 * 1024;
|
||
|
|
child.stdout.on("data", (chunk) => { if (stdout.length < limit) stdout += chunk; });
|
||
|
|
child.stderr.on("data", (chunk) => { if (stderr.length < limit) stderr += chunk; });
|
||
|
|
const timer = setTimeout(() => child.kill("SIGKILL"), options.timeout || 120000);
|
||
|
|
child.on("error", (error) => {
|
||
|
|
clearTimeout(timer);
|
||
|
|
resolve({ code: -1, stdout, stderr: `${stderr}${error.message}`, timed_out: false });
|
||
|
|
});
|
||
|
|
child.on("close", (code, signal) => {
|
||
|
|
clearTimeout(timer);
|
||
|
|
resolve({ code: code ?? -1, stdout, stderr, timed_out: signal === "SIGKILL" });
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function readManifest(config, sha, file) {
|
||
|
|
if (!/^[a-f0-9]{40,64}$/i.test(sha)) throw new Error("invalid commit sha");
|
||
|
|
const result = await run(["/usr/bin/git", "-C", config.repo_path, "show", `${sha}:${file}`], { timeout: 15000 });
|
||
|
|
if (result.code !== 0) throw new Error(`cannot read manifest: ${result.stderr.slice(0, 300)}`);
|
||
|
|
return JSON.parse(result.stdout);
|
||
|
|
}
|
||
|
|
|
||
|
|
function writeReceipt(config, receipt) {
|
||
|
|
fs.mkdirSync(config.audit_dir, { recursive: true, mode: 0o750 });
|
||
|
|
const filename = `${receipt.request_id || "event"}-${Date.now()}.json`;
|
||
|
|
const target = path.join(config.audit_dir, filename);
|
||
|
|
fs.writeFileSync(target, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o640, flag: "wx" });
|
||
|
|
return target;
|
||
|
|
}
|
||
|
|
|
||
|
|
function acquireLock(lockFile) {
|
||
|
|
fs.mkdirSync(path.dirname(lockFile), { recursive: true, mode: 0o750 });
|
||
|
|
const fd = fs.openSync(lockFile, "wx", 0o640);
|
||
|
|
fs.writeFileSync(fd, `${process.pid}\n`);
|
||
|
|
return () => {
|
||
|
|
fs.closeSync(fd);
|
||
|
|
try { fs.unlinkSync(lockFile); } catch (_) {}
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handlePayload(config, payload) {
|
||
|
|
if (payload.ref !== config.allowed_ref) return { accepted: false, reason: "ref_not_allowed" };
|
||
|
|
if (payload.repository?.full_name !== config.repository_full_name) return { accepted: false, reason: "repository_not_allowed" };
|
||
|
|
const sha = payload.after;
|
||
|
|
const manifests = collectChangedFiles(payload).filter((file) => validManifestPath(file, config.manifest_prefix));
|
||
|
|
if (manifests.length === 0) return { accepted: true, executed: 0, reason: "no_deployment_manifest" };
|
||
|
|
if (manifests.length > 5) return { accepted: false, reason: "too_many_manifests" };
|
||
|
|
|
||
|
|
let release;
|
||
|
|
try { release = acquireLock(config.lock_file); }
|
||
|
|
catch (_) { return { accepted: false, reason: "deployment_locked" }; }
|
||
|
|
|
||
|
|
const receipts = [];
|
||
|
|
try {
|
||
|
|
const fetchResult = await run(["/usr/bin/git", "-C", config.repo_path, "fetch", "--quiet", "origin", config.allowed_ref.replace("refs/heads/", "")], { timeout: 30000 });
|
||
|
|
if (fetchResult.code !== 0) throw new Error(`git fetch failed: ${fetchResult.stderr.slice(0, 300)}`);
|
||
|
|
|
||
|
|
for (const file of manifests) {
|
||
|
|
let request;
|
||
|
|
try { request = await readManifest(config, sha, file); }
|
||
|
|
catch (error) {
|
||
|
|
receipts.push({ request_id: "invalid", status: "rejected", reason: error.message, file, checked_at: new Date().toISOString() });
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (!validRequest(request)) {
|
||
|
|
receipts.push({ request_id: request?.request_id || "invalid", status: "rejected", reason: "invalid_request_schema", file, checked_at: new Date().toISOString() });
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const spec = config.actions[request.action];
|
||
|
|
if (!spec || !Array.isArray(spec.argv) || !spec.allowed_modules?.includes(request.module)) {
|
||
|
|
receipts.push({ request_id: request.request_id, status: "rejected", reason: "action_or_module_not_allowed", file, checked_at: new Date().toISOString() });
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const started = new Date().toISOString();
|
||
|
|
const result = await run(spec.argv, { cwd: config.repo_path, timeout: config.max_execution_ms });
|
||
|
|
const receipt = {
|
||
|
|
request_id: request.request_id,
|
||
|
|
module: request.module,
|
||
|
|
action: request.action,
|
||
|
|
mode: spec.mode || "controlled",
|
||
|
|
status: result.code === 0 ? "succeeded" : "failed",
|
||
|
|
exit_code: result.code,
|
||
|
|
timed_out: result.timed_out,
|
||
|
|
stdout: result.stdout.slice(0, 16000),
|
||
|
|
stderr: result.stderr.slice(0, 16000),
|
||
|
|
commit: sha,
|
||
|
|
started_at: started,
|
||
|
|
completed_at: new Date().toISOString()
|
||
|
|
};
|
||
|
|
receipt.local_receipt = writeReceipt(config, receipt);
|
||
|
|
receipts.push(receipt);
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
release();
|
||
|
|
}
|
||
|
|
return { accepted: true, executed: receipts.length, receipts };
|
||
|
|
}
|
||
|
|
|
||
|
|
function createServer(config, secret) {
|
||
|
|
return http.createServer((req, res) => {
|
||
|
|
if (req.method === "GET" && req.url === (config.health_path || "/health")) {
|
||
|
|
res.writeHead(200, { "content-type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ ok: true, service: "guanghu-deployment-receiver", version: "1.0.0" }));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (req.method !== "POST" || req.url !== (config.webhook_path || "/forgejo/deploy")) {
|
||
|
|
res.writeHead(404).end();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const chunks = [];
|
||
|
|
let size = 0;
|
||
|
|
req.on("data", (chunk) => {
|
||
|
|
size += chunk.length;
|
||
|
|
if (size > (config.max_body_bytes || 1048576)) req.destroy();
|
||
|
|
else chunks.push(chunk);
|
||
|
|
});
|
||
|
|
req.on("end", async () => {
|
||
|
|
const raw = Buffer.concat(chunks);
|
||
|
|
if (!verifySignature(secret, raw, req.headers["x-forgejo-signature"] || req.headers["x-gitea-signature"])) {
|
||
|
|
res.writeHead(401, { "content-type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ ok: false, error: "invalid_signature" }));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const result = await handlePayload(config, JSON.parse(raw.toString("utf8")));
|
||
|
|
res.writeHead(result.accepted ? 202 : 409, { "content-type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ ok: result.accepted, ...result }));
|
||
|
|
} catch (error) {
|
||
|
|
writeReceipt(config, { request_id: "event", status: "failed", reason: error.message, checked_at: new Date().toISOString() });
|
||
|
|
res.writeHead(500, { "content-type": "application/json" });
|
||
|
|
res.end(JSON.stringify({ ok: false, error: "receiver_error" }));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (require.main === module) {
|
||
|
|
const config = loadConfig();
|
||
|
|
const secret = process.env.FORGEJO_WEBHOOK_SECRET;
|
||
|
|
if (!secret || secret.length < 32) throw new Error("FORGEJO_WEBHOOK_SECRET must be at least 32 characters");
|
||
|
|
createServer(config, secret).listen(config.listen_port || 3981, config.listen_host || "127.0.0.1", () => {
|
||
|
|
console.log(`guanghu-deployment-receiver listening on ${config.listen_host || "127.0.0.1"}:${config.listen_port || 3981}`);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = { collectChangedFiles, createServer, handlePayload, validManifestPath, validRequest, verifySignature };
|