From 3a5f2ec901888fcdf5798f39766c0b2dd293eefb Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 12 Jul 2026 10:20:40 -0700 Subject: [PATCH] =?UTF-8?q?[DEPLOY-RECEIVER]=20v1.0=20=C2=B7=20signed=20al?= =?UTF-8?q?lowlist=20webhook=20=C2=B7=20read-only=20gatekeeper=20probe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../requests/WORK-PROBE-20260713-002.json | 7 + server-tools/deployment-receiver/README.md | 74 ++++++ .../deployment-receiver/config.example.json | 21 ++ .../deployment-receiver/ecosystem.config.cjs | 14 ++ .../guanghu-deployment-receiver.service | 24 ++ server-tools/deployment-receiver/package.json | 13 ++ server-tools/deployment-receiver/receiver.js | 210 ++++++++++++++++++ .../scripts/inspect-gatekeeper.js | 49 ++++ .../deployment-receiver/test/receiver.test.js | 29 +++ 9 files changed, 441 insertions(+) create mode 100644 deployment/requests/WORK-PROBE-20260713-002.json create mode 100644 server-tools/deployment-receiver/README.md create mode 100644 server-tools/deployment-receiver/config.example.json create mode 100644 server-tools/deployment-receiver/ecosystem.config.cjs create mode 100644 server-tools/deployment-receiver/guanghu-deployment-receiver.service create mode 100644 server-tools/deployment-receiver/package.json create mode 100644 server-tools/deployment-receiver/receiver.js create mode 100644 server-tools/deployment-receiver/scripts/inspect-gatekeeper.js create mode 100644 server-tools/deployment-receiver/test/receiver.test.js diff --git a/deployment/requests/WORK-PROBE-20260713-002.json b/deployment/requests/WORK-PROBE-20260713-002.json new file mode 100644 index 0000000..14c1988 --- /dev/null +++ b/deployment/requests/WORK-PROBE-20260713-002.json @@ -0,0 +1,7 @@ +{ + "request_id": "WORK-PROBE-20260713-002", + "module": "gatekeeper", + "action": "inspect-gatekeeper", + "approved": true, + "note": "Read-only deployment-chain verification requested by ICE-GL∞." +} diff --git a/server-tools/deployment-receiver/README.md b/server-tools/deployment-receiver/README.md new file mode 100644 index 0000000..ee4fcb7 --- /dev/null +++ b/server-tools/deployment-receiver/README.md @@ -0,0 +1,74 @@ +# Guanghu Deployment Receiver v1.0 + +这是 `fifth-domain` 的服务器端 Forgejo Webhook 接收器。它把仓库提交转换为受控、可审计的服务器动作,但不接受提交中的任意 shell 命令。 + +## 第一版能力 + +- 校验 Forgejo/Gitea `X-Forgejo-Signature` / `X-Gitea-Signature` HMAC-SHA256。 +- 只接受 `bingshuo/fifth-domain` 的 `refs/heads/main` 推送。 +- 只读取 `deployment/requests/*.json`。 +- 请求只能包含 `request_id/module/action/approved/note`,出现 `cmd` 等额外字段直接拒绝。 +- 动作和模块必须在服务器本地 `config.json` 双重白名单中登记。 +- 使用参数数组和 `shell:false`,禁止 shell 拼接。 +- 单实例部署锁、执行超时、输出上限和本地 JSON 审计回执。 +- 当前只登记 `inspect-gatekeeper`:读取线上 Gatekeeper 文件哈希、认证特征与 PM2 状态,不读取任何凭证,不修改服务。 + +## 交给服务器编程 AI 的部署步骤 + +1. 在目标服务器准备只读部署用户与目录: + + ```bash + sudo useradd --system --home /var/lib/guanghu-deployment-receiver --shell /usr/sbin/nologin guanghu-deploy + sudo mkdir -p /opt/zhuyuan /etc/guanghu-deployment-receiver /var/lib/guanghu-deployment-receiver/receipts + sudo chown -R guanghu-deploy:guanghu-deploy /var/lib/guanghu-deployment-receiver + ``` + +2. 将 `fifth-domain` 克隆或更新到 `/opt/zhuyuan/fifth-domain`。确保 `guanghu-deploy` 可以执行仓库的 `git fetch`,但不要给系统 root 权限。 + +3. 复制配置: + + ```bash + sudo cp server-tools/deployment-receiver/config.example.json /etc/guanghu-deployment-receiver/config.json + ``` + +4. 生成至少 32 字节的随机 Webhook 密钥,只放服务器: + + ```bash + umask 077 + printf 'FORGEJO_WEBHOOK_SECRET=%s\n' "$(openssl rand -hex 32)" | sudo tee /etc/guanghu-deployment-receiver/secret.env >/dev/null + ``` + + 不要把密钥提交到仓库。将同一个值配置到 Forgejo 仓库 Webhook 的 Secret。 + +5. 安装并启动 systemd 服务: + + ```bash + sudo cp server-tools/deployment-receiver/guanghu-deployment-receiver.service /etc/systemd/system/ + sudo systemctl daemon-reload + sudo systemctl enable --now guanghu-deployment-receiver + curl -fsS http://127.0.0.1:3981/health + ``` + +6. 使用 Nginx 暴露单一 HTTPS 路径,反代到 `127.0.0.1:3981/forgejo/deploy`。不要直接开放 3981 公网端口。 + +7. 在 Forgejo 的 `bingshuo/fifth-domain` 添加 Push Webhook: + + - URL:服务器编程 AI 配置的 HTTPS 地址,例如 `https://deploy.example.com/forgejo/deploy` + - Secret:步骤 4 的随机值 + - Event:Push events + - Branch filter:`main` + +8. 部署完成后,重新提交或轻微更新: + + `deployment/requests/WORK-PROBE-20260713-002.json` + + 接收器会在服务器本地写入: + + `/var/lib/guanghu-deployment-receiver/receipts/WORK-PROBE-20260713-002-*.json` + +## 重要边界 + +- v1 不自动修改或重启 Gatekeeper。 +- 新增真实部署动作时,服务器端 `config.json` 必须登记一个固定脚本路径;请求文件不能决定命令。 +- 每个部署脚本必须自行实现备份、健康检查和失败回滚,再加入白名单。 +- 不要把仓库 PAT、服务器 Token、邮箱、Webhook Secret 或环境变量写进回执。 diff --git a/server-tools/deployment-receiver/config.example.json b/server-tools/deployment-receiver/config.example.json new file mode 100644 index 0000000..7ccaa6d --- /dev/null +++ b/server-tools/deployment-receiver/config.example.json @@ -0,0 +1,21 @@ +{ + "listen_host": "127.0.0.1", + "listen_port": 3981, + "webhook_path": "/forgejo/deploy", + "health_path": "/health", + "repository_full_name": "bingshuo/fifth-domain", + "allowed_ref": "refs/heads/main", + "repo_path": "/opt/zhuyuan/fifth-domain", + "manifest_prefix": "deployment/requests/", + "audit_dir": "/var/lib/guanghu-deployment-receiver/receipts", + "lock_file": "/var/lib/guanghu-deployment-receiver/deploy.lock", + "max_body_bytes": 1048576, + "max_execution_ms": 120000, + "actions": { + "inspect-gatekeeper": { + "argv": ["/usr/bin/node", "/opt/zhuyuan/fifth-domain/server-tools/deployment-receiver/scripts/inspect-gatekeeper.js"], + "allowed_modules": ["gatekeeper"], + "mode": "read-only" + } + } +} diff --git a/server-tools/deployment-receiver/ecosystem.config.cjs b/server-tools/deployment-receiver/ecosystem.config.cjs new file mode 100644 index 0000000..7451115 --- /dev/null +++ b/server-tools/deployment-receiver/ecosystem.config.cjs @@ -0,0 +1,14 @@ +module.exports = { + apps: [{ + name: "guanghu-deployment-receiver", + script: "receiver.js", + cwd: "/opt/zhuyuan/fifth-domain/server-tools/deployment-receiver", + instances: 1, + autorestart: true, + max_memory_restart: "128M", + env: { + NODE_ENV: "production", + DEPLOY_RECEIVER_CONFIG: "/etc/guanghu-deployment-receiver/config.json" + } + }] +}; diff --git a/server-tools/deployment-receiver/guanghu-deployment-receiver.service b/server-tools/deployment-receiver/guanghu-deployment-receiver.service new file mode 100644 index 0000000..4c53bbf --- /dev/null +++ b/server-tools/deployment-receiver/guanghu-deployment-receiver.service @@ -0,0 +1,24 @@ +[Unit] +Description=Guanghu allowlisted Forgejo deployment receiver +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=guanghu-deploy +Group=guanghu-deploy +WorkingDirectory=/opt/zhuyuan/fifth-domain/server-tools/deployment-receiver +Environment=DEPLOY_RECEIVER_CONFIG=/etc/guanghu-deployment-receiver/config.json +EnvironmentFile=/etc/guanghu-deployment-receiver/secret.env +ExecStart=/usr/bin/node receiver.js +Restart=on-failure +RestartSec=5 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/zhuyuan/fifth-domain /var/lib/guanghu-deployment-receiver +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX + +[Install] +WantedBy=multi-user.target diff --git a/server-tools/deployment-receiver/package.json b/server-tools/deployment-receiver/package.json new file mode 100644 index 0000000..fdc3f90 --- /dev/null +++ b/server-tools/deployment-receiver/package.json @@ -0,0 +1,13 @@ +{ + "name": "guanghu-deployment-receiver", + "version": "1.0.0", + "private": true, + "description": "Allowlisted Forgejo webhook receiver for Fifth Domain deployments", + "scripts": { + "start": "node receiver.js", + "test": "node --test test/*.test.js" + }, + "engines": { + "node": ">=18" + } +} diff --git a/server-tools/deployment-receiver/receiver.js b/server-tools/deployment-receiver/receiver.js new file mode 100644 index 0000000..edd17b1 --- /dev/null +++ b/server-tools/deployment-receiver/receiver.js @@ -0,0 +1,210 @@ +"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 }; diff --git a/server-tools/deployment-receiver/scripts/inspect-gatekeeper.js b/server-tools/deployment-receiver/scripts/inspect-gatekeeper.js new file mode 100644 index 0000000..798fa7f --- /dev/null +++ b/server-tools/deployment-receiver/scripts/inspect-gatekeeper.js @@ -0,0 +1,49 @@ +"use strict"; + +const fs = require("node:fs"); +const crypto = require("node:crypto"); +const { execFileSync } = require("node:child_process"); + +const candidates = [ + "/opt/zhuyuan/gatekeeper/engine-v3.js", + "/opt/engine.js" +]; + +function digest(file) { + return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +} + +function pm2Info() { + try { + const list = JSON.parse(execFileSync("/usr/bin/env", ["pm2", "jlist"], { encoding: "utf8", timeout: 10000 })); + return list.filter((item) => /engine|gatekeeper/i.test(item.name || "")).map((item) => ({ + name: item.name, + status: item.pm2_env?.status, + script: item.pm2_env?.pm_exec_path, + restart_time: item.pm2_env?.restart_time, + started_at: item.pm2_env?.pm_uptime ? new Date(item.pm2_env.pm_uptime).toISOString() : null + })); + } catch (error) { + return [{ error: error.message.slice(0, 300) }]; + } +} + +const files = candidates.filter((file) => fs.existsSync(file)).map((file) => { + const source = fs.readFileSync(file, "utf8"); + return { + path: file, + sha256: digest(file), + contains_direct_email_mode: source.includes("requestEmail") || source.includes("email_mode: requestEmail"), + contains_hmac_headers: source.includes("X-Sovereign") && source.includes("X-Minute"), + contains_auth_request: source.includes("/auth/request"), + contains_exec_route: source.includes("/exec") + }; +}); + +process.stdout.write(`${JSON.stringify({ + ok: true, + checked_at: new Date().toISOString(), + hostname: require("node:os").hostname(), + files, + pm2: pm2Info() +}, null, 2)}\n`); diff --git a/server-tools/deployment-receiver/test/receiver.test.js b/server-tools/deployment-receiver/test/receiver.test.js new file mode 100644 index 0000000..7baeb27 --- /dev/null +++ b/server-tools/deployment-receiver/test/receiver.test.js @@ -0,0 +1,29 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const test = require("node:test"); +const { collectChangedFiles, validManifestPath, validRequest, verifySignature } = require("../receiver"); + +test("verifies Forgejo HMAC signatures", () => { + const secret = "a".repeat(32); + const body = Buffer.from('{"ok":true}'); + const signature = crypto.createHmac("sha256", secret).update(body).digest("hex"); + assert.equal(verifySignature(secret, body, signature), true); + assert.equal(verifySignature(secret, body, "0".repeat(64)), false); +}); + +test("accepts only safe manifest paths", () => { + assert.equal(validManifestPath("deployment/requests/REQ-001.json", "deployment/requests/"), true); + assert.equal(validManifestPath("deployment/requests/../../secret.json", "deployment/requests/"), false); + assert.equal(validManifestPath("deployment/requests/REQ 001.json", "deployment/requests/"), false); +}); + +test("rejects arbitrary command fields", () => { + assert.equal(validRequest({ request_id: "REQ-20260713-001", module: "gatekeeper", action: "inspect-gatekeeper", approved: true }), true); + assert.equal(validRequest({ request_id: "REQ-20260713-001", module: "gatekeeper", action: "inspect-gatekeeper", approved: true, cmd: "rm -rf /" }), false); +}); + +test("collects only added and modified files", () => { + assert.deepEqual(collectChangedFiles({ commits: [{ added: ["a"], modified: ["b"], removed: ["c"] }] }).sort(), ["a", "b"]); +});