[HLCC-ICE-000001][ZY-CONTRIB-20260723-001] feat: 以来光者贡献链启用冰朔第五域个人子频道
This commit is contained in:
commit
5615453e4e
660 changed files with 122355 additions and 0 deletions
37
server-tools/fifth-domain-sync-agent/MODULE.hdlp
Normal file
37
server-tools/fifth-domain-sync-agent/MODULE.hdlp
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# HLP-AGENT-FD-SYNC-001 · 第五域自动同步与回执 Agent
|
||||
|
||||
> **HLDP**: `HLDP://fifth-domain/server-tools/fifth-domain-sync-agent`
|
||||
>
|
||||
> **状态**: REGISTERED · TEST_PENDING · RUNTIME_NOT_DEPLOYED
|
||||
>
|
||||
> **责任**: 铸澜 `ICE-GL-ZL-001` × 冰朔 `ICE-GL∞`
|
||||
|
||||
## 定义
|
||||
|
||||
这是第五域的常驻自动 Agent,不是人格体替身,也不获得一般执行权。它是仓库与服务器之间的**受限同步电话线**:只接收有效签名的 `main` 推送,同步受控工作副本、检查导航连续性并输出回执。
|
||||
|
||||
## 固定能力与拒绝项
|
||||
|
||||
```text
|
||||
允许:签名验证 → 仓库/分支/SHA 校验 → git fetch → fast-forward → 导航记忆校验 → 回执。
|
||||
拒绝:任意命令、普通 push 自动部署、重启服务、迁移数据、读取 Secret、替代 GLSV 会话。
|
||||
```
|
||||
|
||||
## 广播塔阶段
|
||||
|
||||
```text
|
||||
当前: REGISTERED
|
||||
下一步: 专用测试节点验证 webhook、脏副本拒绝、SHA 不一致拒绝、快进同步及回执
|
||||
正式部署: 只有 TEST_PASSED + 技术主控批准 + 明确服务器目标后,才可由 GLSV 人格体远程操作流程安装
|
||||
```
|
||||
|
||||
## 路由
|
||||
|
||||
```text
|
||||
BROADCAST-TOWER
|
||||
→ HLP-AGENT-FD-SYNC-001
|
||||
→ server-tools/fifth-domain-sync-agent/README.md
|
||||
→ health: /health
|
||||
→ webhook: /forgejo/sync(服务器侧 HMAC;不写入仓库)
|
||||
→ receipt: /var/lib/guanghu-fifth-domain-sync-agent/receipts
|
||||
```
|
||||
25
server-tools/fifth-domain-sync-agent/README.md
Normal file
25
server-tools/fifth-domain-sync-agent/README.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# 第五域自动同步 Agent
|
||||
|
||||
这是第 5 域的第一个常驻自动 Agent:它只接收 Forgejo 的**已签名 main 推送**,将新提交快进同步到受控工作副本,复跑导航记忆校验,并留下脱敏回执。
|
||||
|
||||
它不是“任意命令 Agent”,也不是生产部署器:没有来自 webhook 的命令字段;不读取或执行仓库内任意脚本;不因普通 Git push 执行服务重启、迁移或生产发布。
|
||||
|
||||
## 职责
|
||||
|
||||
```text
|
||||
Forgejo 已签名 push
|
||||
→ 验证仓库、main 分支、提交 SHA 与 HMAC
|
||||
→ 拒绝脏工作副本与非快进变更
|
||||
→ fetch 后确认 fetched SHA = webhook after
|
||||
→ 仅 fast-forward 同步
|
||||
→ 检查导航记忆守卫
|
||||
→ /var/lib/.../receipts 写入回执
|
||||
```
|
||||
|
||||
## 部署边界
|
||||
|
||||
1. 在 `/etc/guanghu/fifth-domain-sync-agent.json` 按 `config.example.json` 配置,不提交真实配置。
|
||||
2. 在 `/etc/guanghu/fifth-domain-sync-agent.env` 设置仅服务器持有的 `FORGEJO_WEBHOOK_SECRET`。
|
||||
3. Forgejo webhook 指向 `/forgejo/sync`,使用相同 HMAC secret,并仅选择 push 事件。
|
||||
4. 安装 systemd unit 前,安装器必须把 `User=__RUN_AS_USER__` 替换为现有受控接收器的实际系统运行用户;不得假定旧架构目录名就是 Linux 用户。随后执行 `daemon-reload`、`enable --now`;健康检查为 `/health`。
|
||||
5. 发布动作仍通过广播塔三阶段与受限 deployment receiver;此 Agent 不替代 GLSV 人格体发起的服务器操作流程。
|
||||
14
server-tools/fifth-domain-sync-agent/config.example.json
Normal file
14
server-tools/fifth-domain-sync-agent/config.example.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"listen_host": "127.0.0.1",
|
||||
"listen_port": 3982,
|
||||
"webhook_path": "/forgejo/sync",
|
||||
"health_path": "/health",
|
||||
"repository_full_name": "bingshuo/fifth-domain",
|
||||
"allowed_ref": "refs/heads/main",
|
||||
"repo_path": "/opt/zhuyuan/fifth-domain",
|
||||
"audit_dir": "/var/lib/guanghu-fifth-domain-sync-agent/receipts",
|
||||
"lock_file": "/var/lib/guanghu-fifth-domain-sync-agent/sync.lock",
|
||||
"max_body_bytes": 1048576,
|
||||
"max_execution_ms": 90000,
|
||||
"navigation_guard": "zero-point/core-channel/revive-guard/navigation-memory-guard.py"
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
[Unit]
|
||||
Description=Guanghu Fifth Domain signed sync and receipt agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# 安装器必须替换为现有受控接收器实际运行的系统用户;不得把旧目录名当作 Linux 用户名。
|
||||
User=__RUN_AS_USER__
|
||||
WorkingDirectory=/opt/zhuyuan/fifth-domain/server-tools/fifth-domain-sync-agent
|
||||
Environment=FIFTH_DOMAIN_SYNC_CONFIG=/etc/guanghu/fifth-domain-sync-agent.json
|
||||
EnvironmentFile=/etc/guanghu/fifth-domain-sync-agent.env
|
||||
ExecStart=/usr/bin/node /opt/zhuyuan/fifth-domain/server-tools/fifth-domain-sync-agent/sync-agent.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/opt/zhuyuan/fifth-domain /var/lib/guanghu-fifth-domain-sync-agent
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
13
server-tools/fifth-domain-sync-agent/package.json
Normal file
13
server-tools/fifth-domain-sync-agent/package.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "guanghu-fifth-domain-sync-agent",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Signed Forgejo push receiver that safely synchronizes the Fifth Domain working copy and emits receipts",
|
||||
"scripts": {
|
||||
"start": "node sync-agent.js",
|
||||
"test": "node --test test/*.test.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
167
server-tools/fifth-domain-sync-agent/sync-agent.js
Normal file
167
server-tools/fifth-domain-sync-agent/sync-agent.js
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"use strict";
|
||||
|
||||
// This is a synchronizer, not a deployment executor. It has no command field,
|
||||
// no shell invocation, and no route to production release actions.
|
||||
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.FIFTH_DOMAIN_SYNC_CONFIG;
|
||||
if (!configPath) throw new Error("FIFTH_DOMAIN_SYNC_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", "navigation_guard"]) {
|
||||
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 validPush(payload, config) {
|
||||
return payload && payload.ref === config.allowed_ref &&
|
||||
payload.repository?.full_name === config.repository_full_name &&
|
||||
/^[a-f0-9]{40,64}$/i.test(payload.after || "");
|
||||
}
|
||||
|
||||
function run(argv, options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(argv[0], argv.slice(1), {
|
||||
cwd: options.cwd,
|
||||
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 || 16 * 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 || 90000);
|
||||
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" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 (_) {}
|
||||
};
|
||||
}
|
||||
|
||||
function writeReceipt(config, receipt) {
|
||||
fs.mkdirSync(config.audit_dir, { recursive: true, mode: 0o750 });
|
||||
const target = path.join(config.audit_dir, `sync-${Date.now()}.json`);
|
||||
fs.writeFileSync(target, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o640, flag: "wx" });
|
||||
return target;
|
||||
}
|
||||
|
||||
async function gitValue(config, args) {
|
||||
const result = await run(["/usr/bin/git", "-C", config.repo_path, ...args], { timeout: 15000 });
|
||||
if (result.code !== 0) throw new Error(`git ${args[0]} failed: ${result.stderr.slice(0, 300)}`);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
async function synchronize(config, payload) {
|
||||
if (!validPush(payload, config)) return { accepted: false, reason: "push_not_allowed" };
|
||||
let release;
|
||||
try { release = acquireLock(config.lock_file); }
|
||||
catch (_) { return { accepted: false, reason: "sync_locked" }; }
|
||||
|
||||
const receipt = { event: "repository_sync", commit: payload.after, status: "failed", checked_at: new Date().toISOString() };
|
||||
try {
|
||||
const branch = await gitValue(config, ["symbolic-ref", "--short", "HEAD"]);
|
||||
if (branch !== config.allowed_ref.replace("refs/heads/", "")) throw new Error("working_copy_not_on_allowed_branch");
|
||||
const dirty = await run(["/usr/bin/git", "-C", config.repo_path, "diff", "--quiet"], { timeout: 15000 });
|
||||
if (dirty.code !== 0) throw new Error("working_copy_dirty");
|
||||
|
||||
const before = await gitValue(config, ["rev-parse", "HEAD"]);
|
||||
const fetch = await run(["/usr/bin/git", "-C", config.repo_path, "fetch", "--quiet", "origin", branch], { timeout: config.max_execution_ms });
|
||||
if (fetch.code !== 0) throw new Error(`git_fetch_failed: ${fetch.stderr.slice(0, 300)}`);
|
||||
const fetched = await gitValue(config, ["rev-parse", "FETCH_HEAD"]);
|
||||
if (fetched !== payload.after) throw new Error("fetched_commit_does_not_match_signed_event");
|
||||
const merge = await run(["/usr/bin/git", "-C", config.repo_path, "merge", "--ff-only", "FETCH_HEAD"], { timeout: config.max_execution_ms });
|
||||
if (merge.code !== 0) throw new Error(`fast_forward_refused: ${merge.stderr.slice(0, 300)}`);
|
||||
const after = await gitValue(config, ["rev-parse", "HEAD"]);
|
||||
const guard = await run(["/usr/bin/python3", path.join(config.repo_path, config.navigation_guard), "--range", `${before}..${after}`], { cwd: config.repo_path, timeout: config.max_execution_ms });
|
||||
receipt.status = guard.code === 0 ? "synchronized" : "synchronized_with_navigation_warning";
|
||||
receipt.before = before;
|
||||
receipt.after = after;
|
||||
receipt.navigation_guard_exit_code = guard.code;
|
||||
receipt.navigation_guard_output = `${guard.stdout}${guard.stderr}`.slice(0, 4000);
|
||||
} catch (error) {
|
||||
receipt.reason = error.message;
|
||||
} finally {
|
||||
receipt.completed_at = new Date().toISOString();
|
||||
receipt.local_receipt = writeReceipt(config, receipt);
|
||||
release();
|
||||
}
|
||||
return { accepted: receipt.status !== "failed", receipt };
|
||||
}
|
||||
|
||||
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-fifth-domain-sync-agent", mode: "sync-and-receipt-only", version: "1.0.0" }));
|
||||
return;
|
||||
}
|
||||
if (req.method !== "POST" || req.url !== (config.webhook_path || "/forgejo/sync")) return res.writeHead(404).end();
|
||||
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" });
|
||||
return res.end(JSON.stringify({ ok: false, error: "invalid_signature" }));
|
||||
}
|
||||
try {
|
||||
const result = await synchronize(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) {
|
||||
res.writeHead(500, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: "sync_agent_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 || 3982, config.listen_host || "127.0.0.1", () => {
|
||||
console.log(`guanghu-fifth-domain-sync-agent listening on ${config.listen_host || "127.0.0.1"}:${config.listen_port || 3982}`);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { createServer, synchronize, validPush, verifySignature };
|
||||
24
server-tools/fifth-domain-sync-agent/test/sync-agent.test.js
Normal file
24
server-tools/fifth-domain-sync-agent/test/sync-agent.test.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const test = require("node:test");
|
||||
const { validPush, verifySignature } = require("../sync-agent");
|
||||
|
||||
const config = { repository_full_name: "bingshuo/fifth-domain", allowed_ref: "refs/heads/main" };
|
||||
const good = { ref: "refs/heads/main", after: "a".repeat(40), repository: { full_name: "bingshuo/fifth-domain" } };
|
||||
|
||||
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 the registered Fifth Domain main push", () => {
|
||||
assert.equal(validPush(good, config), true);
|
||||
assert.equal(validPush({ ...good, ref: "refs/heads/feature" }, config), false);
|
||||
assert.equal(validPush({ ...good, repository: { full_name: "other/repo" } }, config), false);
|
||||
assert.equal(validPush({ ...good, after: "not-a-commit" }, config), false);
|
||||
});
|
||||
Loading…
Reference in a new issue