/* * ════════════════════════════════════════════════════════════════ * 光湖 · Git 同步服务 (Git Sync) * 监听 Gitea Webhook,自动拉取最新代码到服务器 * ════════════════════════════════════════════════════════════════ * * 工作原理: * 1. Gitea 配置 Webhook → push 事件 → 通知此服务 * 2. 本服务收到通知后执行 git pull 同步代码 * 3. 同步成功后重启相关 PM2 服务 * * 安全设计: * - 验证 Webhook Secret(防止伪造请求) * - 仅响应 push 到 main 分支的事件 * - 同步操作以 root 权限运行(确保 git 权限无问题) * - 同步锁防止并发执行 * - 同步失败自动重试(最多3次) * * 部署位置: /data/guanghulab/server/git-sync/ * PM2 进程名: guanghulab-git-sync * ════════════════════════════════════════════════════════════════ */ "use strict"; const express = require("express"); const crypto = require("crypto"); const { execSync } = require("child_process"); const fs = require("fs"); const path = require("path"); const app = express(); // 捕获 raw body 用于 Webhook 签名验证 app.use(express.json({ limit: "1mb", verify: (req, _res, buf) => { req.rawBody = buf.toString("utf8"); } })); // ─── 配置 ───────────────────────────────────────────────────── const CONFIG = { port: process.env.GIT_SYNC_PORT || 8082, host: process.env.GIT_SYNC_HOST || "127.0.0.1", // 仓库路径 repoDir: process.env.REPO_DIR || "/data/guanghulab/repo", // Webhook Secret(和 Gitea 里配置的一致) webhookSecret: process.env.GIT_SYNC_SECRET || "", // Git 认证信息(内嵌在 remote URL 中) gitUser: process.env.GIT_USER || "bingshuo", gitToken: process.env.ZY_REPO_TOKEN || "", gitHost: process.env.GIT_HOST || "127.0.0.1:3001", gitRepo: process.env.GIT_REPO || "bingshuo/guanghulab.git", // 同步后要重启的 PM2 进程(逗号分隔) restartServices: (process.env.RESTART_SERVICES || "").split(",").filter(Boolean), // 重试配置 maxRetries: 3, retryDelay: 5000, // 5秒 // 同步日志 syncLogPath: process.env.SYNC_LOG_PATH || "/data/guanghulab/.runtime/git-sync-log.json" }; // ─── 同步锁 ───────────────────────────────────────────────────── let isSyncing = false; // ─── 同步日志 ────────────────────────────────────────────────── let syncLog = []; function loadSyncLog() { try { if (fs.existsSync(CONFIG.syncLogPath)) { syncLog = JSON.parse(fs.readFileSync(CONFIG.syncLogPath, "utf8")); } } catch { syncLog = []; } } function saveSyncLog() { try { const dir = path.dirname(CONFIG.syncLogPath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); // 只保留最近50条 syncLog = syncLog.slice(-50); fs.writeFileSync(CONFIG.syncLogPath, JSON.stringify(syncLog, null, 2)); } catch (e) { console.error("[git-sync] 日志写入失败:", e.message); } } function addLog(entry) { syncLog.push({ ...entry, timestamp: new Date().toISOString() }); saveSyncLog(); } // ─── 验证 Webhook Secret ────────────────────────────────────── function verifyWebhook(payload, signature) { if (!CONFIG.webhookSecret) { // 未配置 Secret 时跳过验证(仅限内网使用) console.warn("[git-sync] ⚠️ Webhook Secret 未配置,跳过验证"); return true; } if (!signature) return false; const expected = crypto .createHmac("sha256", CONFIG.webhookSecret) .update(payload) .digest("hex"); return crypto.timingSafeEqual( Buffer.from(`sha256=${expected}`), Buffer.from(signature) ); } // ─── 执行 Git 同步 ──────────────────────────────────────────── function doSync(commitSha, committer, message) { if (isSyncing) { console.log("[git-sync] 同步进行中,跳过"); return { skipped: true }; } isSyncing = true; try { // 确保 remote URL 包含认证信息 const authUrl = `http://${CONFIG.gitUser}:${CONFIG.gitToken}@${CONFIG.gitHost}/${CONFIG.gitRepo}`; execSync(`cd ${CONFIG.repoDir} && git remote set-url origin ${authUrl}`, { encoding: "utf8", timeout: 10000 }); // 执行 git pull const output = execSync( `cd ${CONFIG.repoDir} && git fetch origin && git reset --hard origin/main && git log --oneline -1`, { encoding: "utf8", timeout: 30000 } ); console.log(`[git-sync] ✅ 同步成功: ${output.trim()}`); // 同步后重启相关服务 if (CONFIG.restartServices.length > 0) { for (const svc of CONFIG.restartServices) { try { execSync(`pm2 restart ${svc}`, { encoding: "utf8", timeout: 15000 }); console.log(`[git-sync] 🔄 已重启: ${svc}`); } catch (e) { console.error(`[git-sync] ⚠️ 重启失败 ${svc}: ${e.message}`); } } } addLog({ status: "success", commit: commitSha || output.trim().split(" ")[0], committer: committer || "unknown", message: message || output.trim(), output: output.trim() }); return { success: true, output: output.trim() }; } catch (e) { console.error(`[git-sync] ❌ 同步失败: ${e.message}`); addLog({ status: "failed", commit: commitSha, committer: committer || "unknown", message: message || "", error: e.message }); return { success: false, error: e.message }; } finally { isSyncing = false; } } // ─── 带重试的同步 ────────────────────────────────────────────── async function syncWithRetry(commitSha, committer, message) { for (let attempt = 1; attempt <= CONFIG.maxRetries; attempt++) { const result = doSync(commitSha, committer, message); if (result.success || result.skipped) return result; if (attempt < CONFIG.maxRetries) { console.log(`[git-sync] 重试 ${attempt}/${CONFIG.maxRetries}...`); await new Promise(r => setTimeout(r, CONFIG.retryDelay)); } } return { success: false, error: "重试次数已用完" }; } // ─── API 路由 ────────────────────────────────────────────────── // Webhook 接收端点 app.post("/webhook", async (req, res) => { const signature = req.headers["x-gitea-signature"] || req.headers["x-hub-signature-256"] || ""; // 使用 raw body 验证签名(Express JSON 解析后 stringify 会和原始 body 不一致) const payload = req.rawBody || JSON.stringify(req.body); // 验证签名 if (!verifyWebhook(payload, signature)) { console.warn("[git-sync] ❌ Webhook 签名验证失败"); return res.status(403).json({ error: "签名验证失败" }); } const body = req.body; const ref = body.ref || ""; const commitSha = body.after || body.head_commit?.id || ""; const committer = body.pusher?.login || body.sender?.login || "unknown"; const message = body.head_commit?.message || body.commits?.[0]?.message || ""; // 仅响应 push 到 main 分支 if (!ref.endsWith("/main")) { console.log(`[git-sync] 忽略非 main 分支推送: ${ref}`); return res.json({ ignored: true, ref }); } console.log(`[git-sync] 📥 收到 push 事件: ${commitSha.substring(0, 8)} by ${committer}`); // 异步执行同步(不阻塞 Webhook 响应) syncWithRetry(commitSha, committer, message) .then(result => { if (result.success) { console.log(`[git-sync] ✅ 同步完成: ${commitSha.substring(0, 8)}`); } else { console.error(`[git-sync] ❌ 同步最终失败: ${result.error}`); } }); // 立即返回 200,Gitea 不需要等待同步完成 res.json({ received: true, syncing: true, commit: commitSha.substring(0, 8) }); }); // 手动触发同步 app.post("/sync", async (req, res) => { if (isSyncing) { return res.json({ syncing: true, message: "同步进行中" }); } const result = await syncWithRetry("", "manual", "手动触发"); res.json(result); }); // 同步状态查询 app.get("/status", (req, res) => { const lastSync = syncLog.length > 0 ? syncLog[syncLog.length - 1] : null; // 读取当前仓库状态 let repoStatus = null; try { const log = execSync(`cd ${CONFIG.repoDir} && git log --oneline -1 && git status --short`, { encoding: "utf8", timeout: 10000 }); repoStatus = log.trim(); } catch { repoStatus = "无法读取"; } res.json({ service: "git-sync", status: isSyncing ? "syncing" : "idle", lastSync, repoStatus, totalSyncs: syncLog.length, failedSyncs: syncLog.filter(l => l.status === "failed").length }); }); // 健康检查 app.get("/health", (req, res) => { res.json({ status: "ok", uptime: process.uptime() }); }); // ─── 启动 ───────────────────────────────────────────────────── loadSyncLog(); app.listen(CONFIG.port, CONFIG.host, () => { console.log(`[git-sync] Git 同步服务启动: http://${CONFIG.host}:${CONFIG.port}`); console.log(`[git-sync] 仓库路径: ${CONFIG.repoDir}`); console.log(`[git-sync] Webhook 端点: POST /webhook`); console.log(`[git-sync] 铸渊 · ICE-GL-ZY001 · TCS-0002∞`); });