297 lines
10 KiB
JavaScript
297 lines
10 KiB
JavaScript
/*
|
||
* ════════════════════════════════════════════════════════════════
|
||
* 光湖 · 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∞`);
|
||
});
|