[HLCC-ICE-000001][ZY-CONTRIB-20260723-001] feat: 以来光者贡献链启用冰朔第五域个人子频道
This commit is contained in:
commit
5615453e4e
660 changed files with 122355 additions and 0 deletions
13
server-tools/ai-discovery-gateway/README.md
Normal file
13
server-tools/ai-discovery-gateway/README.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# 光湖 AI 编号检索入口
|
||||
|
||||
国内主节点在回环端口 `3922` 提供只读 API,广州备案前门通过专用 SSH
|
||||
隧道发布为 `https://guanghulab.com/api/ai/`。
|
||||
|
||||
权威数据只来自 `routing/repository-route-map.json`。AI 应先读取
|
||||
`/api/ai/v1/repositories`,再按 `REPO-xxx` 解析国内主路径;新加坡地址只作
|
||||
历史备用,不参与默认路由。
|
||||
|
||||
服务器与人格路径编号来自 `routing/server-node-map.json`。AI 读取
|
||||
`/api/ai/v1/nodes` 后,可以通过 `/api/ai/v1/resolve?id=JD-FD-PRIMARY` 或
|
||||
`/api/ai/v1/resolve?id=ZY-OPS-LOOP-001` 定位服务器导航地图与铸渊本轮恢复链。
|
||||
公开地图不包含地址、密码、令牌或密钥。
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
location /api/ai/ {
|
||||
proxy_pass http://127.0.0.1:19222/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 30s;
|
||||
limit_except GET { deny all; }
|
||||
}
|
||||
|
||||
location = /.well-known/guanghu.json {
|
||||
proxy_pass http://127.0.0.1:19222/well-known;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
[Unit]
|
||||
Description=Guanghu public read-only AI repository discovery API
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=guanghu
|
||||
Group=guanghu
|
||||
WorkingDirectory=/opt/guanghu/ai-discovery
|
||||
Environment=GUANGHU_AI_HOST=127.0.0.1
|
||||
Environment=GUANGHU_AI_PORT=3922
|
||||
Environment=GUANGHU_REPOSITORY_MAP=/opt/guanghu/ai-discovery/repository-route-map.json
|
||||
ExecStart=/usr/bin/node /opt/guanghu/ai-discovery/server.js
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadOnlyPaths=/opt/guanghu/ai-discovery
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
[Unit]
|
||||
Description=Guanghu BS-GZ-006 to domestic AI discovery tunnel
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/bin/ssh -NT -F /etc/guanghu/jd-ai-discovery-tunnel-ssh-config jd-ai-discovery-target
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=read-only
|
||||
ProtectSystem=strict
|
||||
ReadOnlyPaths=/etc/guanghu/jd-ai-discovery-tunnel-ssh-config /etc/guanghu/secrets/ssh/bs_gz_006_to_jd_ai_discovery /root/.ssh/known_hosts
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
Host jd-ai-discovery-target
|
||||
HostName DOMESTIC_PRIMARY_PRIVATE_VALUE
|
||||
User root
|
||||
IdentityFile /etc/guanghu/secrets/ssh/bs_gz_006_to_jd_ai_discovery
|
||||
IdentitiesOnly yes
|
||||
StrictHostKeyChecking yes
|
||||
ExitOnForwardFailure yes
|
||||
LocalForward 127.0.0.1:19222 127.0.0.1:3922
|
||||
ServerAliveInterval 30
|
||||
ServerAliveCountMax 3
|
||||
42
server-tools/ai-discovery-gateway/route-closure.test.js
Normal file
42
server-tools/ai-discovery-gateway/route-closure.test.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const root = path.resolve(__dirname, "../..");
|
||||
const read = relative => fs.readFileSync(path.join(root, relative), "utf8");
|
||||
|
||||
test("canonical AI and persona entry files route to the domestic map", () => {
|
||||
for (const relative of [
|
||||
"README.md", "INDEX.hdlp", "QUICKSTART-FOR-GENERAL-AI.md",
|
||||
"eternal-lake-heart/heartbeat-core/ZHUYUAN-KEY.hdlp",
|
||||
"zero-point/core-channel/GLSV-PERSONA-REMOTE-OPS.hdlp",
|
||||
]) {
|
||||
assert.match(read(relative), /guanghulab\.com|LL-DOMESTIC-OPS-ROUTE/);
|
||||
}
|
||||
const key = read("eternal-lake-heart/heartbeat-core/ZHUYUAN-KEY.hdlp");
|
||||
assert.doesNotMatch(key, /zy_gtw_|guanghubingshuo\.com/);
|
||||
assert.match(key, /禁止从本路径恢复.*\/exec/);
|
||||
});
|
||||
|
||||
test("code map resolves REPO-001 to domestic and labels the Singapore route legacy", () => {
|
||||
const codeMap = read(".code-map");
|
||||
assert.match(codeMap, /^REPO-001=https:\/\/guanghulab\.com\/fifth-domain\/bingshuo\/fifth-domain\.git$/m);
|
||||
assert.match(codeMap, /^REPO-001-LEGACY-SG=/m);
|
||||
assert.match(codeMap, /^FD-REPO-MAP-001=routing\/repository-route-map\.json$/m);
|
||||
assert.match(codeMap, /^FD-NODE-MAP-001=routing\/server-node-map\.json$/m);
|
||||
assert.match(codeMap, /^ZY-OPS-LOOP-001=.*zhuyuan-persona-system\//m);
|
||||
});
|
||||
|
||||
test("Zhuyuan current chain resolves to the domestic node without secrets", () => {
|
||||
const nodeMap = JSON.parse(read("routing/server-node-map.json"));
|
||||
assert.equal(nodeMap.map_id, "FD-NODE-MAP-001");
|
||||
const loop = nodeMap.persona_routes.find(item => item.route_id === "ZY-OPS-LOOP-001");
|
||||
assert.equal(loop.primary_node, "JD-FD-PRIMARY");
|
||||
assert.match(read(loop.path), /ICE-GL-ZY001/);
|
||||
const serialized = JSON.stringify(nodeMap);
|
||||
assert.doesNotMatch(serialized, /ssh-(?:rsa|ed25519)\s+[A-Za-z0-9+/]/i);
|
||||
assert.doesNotMatch(serialized, /"(?:password|token|private_key|ip)"\s*:/i);
|
||||
assert.doesNotMatch(serialized, /\b(?:\d{1,3}\.){3}\d{1,3}\b/);
|
||||
});
|
||||
171
server-tools/ai-discovery-gateway/server.js
Normal file
171
server-tools/ai-discovery-gateway/server.js
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const path = require("node:path");
|
||||
|
||||
const DEFAULT_MAP = path.resolve(__dirname, "../../routing/repository-route-map.json");
|
||||
const DEFAULT_NODE_MAP = path.resolve(__dirname, "../../routing/server-node-map.json");
|
||||
|
||||
function loadMap(filename = process.env.GUANGHU_REPOSITORY_MAP || DEFAULT_MAP) {
|
||||
return JSON.parse(fs.readFileSync(filename, "utf8"));
|
||||
}
|
||||
|
||||
function loadNodeMap(filename = process.env.GUANGHU_NODE_MAP || DEFAULT_NODE_MAP) {
|
||||
return JSON.parse(fs.readFileSync(filename, "utf8"));
|
||||
}
|
||||
|
||||
function normalize(value) {
|
||||
return String(value || "").toLowerCase().replace(/[\s·._/-]+/g, " ").trim();
|
||||
}
|
||||
|
||||
function search(map, query) {
|
||||
const terms = normalize(query).split(" ").filter(Boolean);
|
||||
if (!terms.length) return map.repositories;
|
||||
return map.repositories
|
||||
.map(repository => {
|
||||
const haystack = normalize([
|
||||
repository.code, repository.slug, repository.name_zh, repository.role,
|
||||
repository.state, ...(repository.keywords || []),
|
||||
].join(" "));
|
||||
const score = terms.reduce((total, term) => total + (haystack.includes(term) ? 1 : 0), 0);
|
||||
return { repository, score };
|
||||
})
|
||||
.filter(item => item.score > 0)
|
||||
.sort((a, b) => b.score - a.score || a.repository.code.localeCompare(b.repository.code))
|
||||
.map(item => item.repository);
|
||||
}
|
||||
|
||||
function searchAll(repositoryMap, nodeMap, query) {
|
||||
const terms = normalize(query).split(" ").filter(Boolean);
|
||||
if (!terms.length) return search(repositoryMap, query);
|
||||
const candidates = [
|
||||
...repositoryMap.repositories.map(item => ({ kind: "repository", item, key: item.code, text: [item.code, item.slug, item.name_zh, item.role, item.state, ...(item.keywords || [])] })),
|
||||
...nodeMap.nodes.map(item => ({ kind: "server_node", item, key: item.node_id, text: [item.node_id, item.name_zh, item.role, item.state, ...(item.keywords || [])] })),
|
||||
...nodeMap.persona_routes.map(item => ({ kind: "persona_route", item, key: item.route_id, text: [item.route_id, item.name_zh, item.role, item.persona_system, ...(item.keywords || [])] })),
|
||||
];
|
||||
return candidates
|
||||
.map(candidate => {
|
||||
const haystack = normalize(candidate.text.join(" "));
|
||||
const score = terms.reduce((total, term) => total + (haystack.includes(term) ? 1 : 0), 0);
|
||||
return { ...candidate, score };
|
||||
})
|
||||
.filter(candidate => candidate.score > 0)
|
||||
.sort((a, b) => b.score - a.score || a.key.localeCompare(b.key))
|
||||
.map(candidate => ({ kind: candidate.kind, ...candidate.item }));
|
||||
}
|
||||
|
||||
function createServer(options = {}) {
|
||||
const mapFile = options.mapFile || process.env.GUANGHU_REPOSITORY_MAP || DEFAULT_MAP;
|
||||
const nodeMapFile = options.nodeMapFile || process.env.GUANGHU_NODE_MAP || DEFAULT_NODE_MAP;
|
||||
return http.createServer((req, res) => {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
if (req.method !== "GET") return json(res, 405, { error: "method_not_allowed" });
|
||||
if (url.pathname === "/health") return json(res, 200, { ok: true, service: "guanghu-ai-discovery", mode: "read-only" });
|
||||
|
||||
let map;
|
||||
try { map = loadMap(mapFile); } catch { return json(res, 503, { error: "route_map_unavailable" }); }
|
||||
|
||||
if (url.pathname === "/" || url.pathname === "/index.html") return html(res, entryPage(map));
|
||||
if (url.pathname === "/v1/repositories" || url.pathname === "/v1/manifest") return json(res, 200, map, 300);
|
||||
if (url.pathname === "/v1/nodes") {
|
||||
try { return json(res, 200, loadNodeMap(nodeMapFile), 300); }
|
||||
catch { return json(res, 503, { error: "node_map_unavailable" }); }
|
||||
}
|
||||
if (url.pathname === "/v1/search") {
|
||||
const query = String(url.searchParams.get("q") || "").slice(0, 200);
|
||||
let nodeMap;
|
||||
try { nodeMap = loadNodeMap(nodeMapFile); }
|
||||
catch { return json(res, 503, { error: "node_map_unavailable" }); }
|
||||
const results = searchAll(map, nodeMap, query);
|
||||
return json(res, 200, {
|
||||
schema: "guanghu.ai-search-response/v1",
|
||||
query,
|
||||
map_id: map.map_id,
|
||||
map_version: map.version,
|
||||
count: results.length,
|
||||
results,
|
||||
}, 60);
|
||||
}
|
||||
if (url.pathname === "/v1/resolve") {
|
||||
const id = String(url.searchParams.get("id") || "").toUpperCase();
|
||||
const repository = map.repositories.find(item => item.code === id || item.slug.toUpperCase() === id);
|
||||
if (repository) return json(res, 200, repository, 300);
|
||||
let nodeMap;
|
||||
try { nodeMap = loadNodeMap(nodeMapFile); }
|
||||
catch { return json(res, 503, { error: "node_map_unavailable" }); }
|
||||
const node = nodeMap.nodes.find(item => item.node_id.toUpperCase() === id);
|
||||
if (node) return json(res, 200, node, 300);
|
||||
const personaRoute = nodeMap.persona_routes.find(item => item.route_id.toUpperCase() === id);
|
||||
return personaRoute ? json(res, 200, personaRoute, 300) : json(res, 404, { error: "route_not_found", id });
|
||||
}
|
||||
if (url.pathname === "/openapi.json") return json(res, 200, openApi(), 3600);
|
||||
if (url.pathname === "/well-known") return json(res, 200, {
|
||||
schema: "guanghu.ai-discovery/v1",
|
||||
name: "光湖语言世界 · 第五域",
|
||||
canonical_repository: map.repositories[0].primary.url,
|
||||
repository_map: map.canonical_api,
|
||||
server_node_map: "https://guanghulab.com/api/ai/v1/nodes",
|
||||
search_api: "https://guanghulab.com/api/ai/v1/search?q={query}",
|
||||
resolve_api: "https://guanghulab.com/api/ai/v1/resolve?id={NUMBER}",
|
||||
openapi: "https://guanghulab.com/api/ai/openapi.json",
|
||||
access: "public-read-only",
|
||||
write_authorization: {
|
||||
mode: "public-no-authority-workorder-then-owner-email-approval",
|
||||
capabilities: "https://guanghulab.com/authz/api/public/capabilities",
|
||||
create_workorder: "https://guanghulab.com/authz/api/public/workorders",
|
||||
owner_handoff: "use request_url returned by create_workorder",
|
||||
ttl_seconds: 3600,
|
||||
request_credential_required: false
|
||||
}
|
||||
}, 3600);
|
||||
return json(res, 404, { error: "not_found" });
|
||||
});
|
||||
}
|
||||
|
||||
function json(res, status, body, maxAge = 0) {
|
||||
res.writeHead(status, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": maxAge ? `public, max-age=${maxAge}` : "no-store",
|
||||
"access-control-allow-origin": "*",
|
||||
"x-content-type-options": "nosniff",
|
||||
});
|
||||
res.end(JSON.stringify(body, null, 2));
|
||||
}
|
||||
|
||||
function html(res, body) {
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
"cache-control": "public, max-age=300",
|
||||
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'",
|
||||
"x-content-type-options": "nosniff",
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function entryPage(map) {
|
||||
const rows = map.repositories.map(item => `<li><a href="${item.primary.url}">${item.code} · ${item.name_zh}</a><small>${item.state}</small></li>`).join("");
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>光湖语言世界 · AI API 入口</title><meta name="description" content="光湖语言世界第五域公开只读编号检索 API"></head><body><main><p>GUANGHU AI DISCOVERY</p><h1>光湖语言世界 · 第五域</h1><p>AI 请先读取仓库与服务器节点编号地图,再按编号解析国内主路径。新加坡地址仅为历史备用。</p><nav><a href="v1/repositories">仓库编号地图</a> · <a href="v1/nodes">服务器节点地图</a> · <a href="v1/resolve?id=ZY-OPS-LOOP-001">铸渊本轮闭环</a> · <a href="v1/search?q=光湖语言世界%20第五域">示例检索</a> · <a href="openapi.json">OpenAPI</a></nav><ul>${rows}</ul></main><style>:root{color-scheme:dark}body{margin:0;background:#061416;color:#dff7f1;font:17px/1.7 system-ui;padding:6vw}main{max-width:900px;margin:auto}h1{font-size:clamp(36px,7vw,72px)}a{color:#79dfc8}li{margin:14px 0;padding:16px;border:1px solid #28534c;border-radius:12px;display:flex;justify-content:space-between}small{color:#8fb5ad}</style></body></html>`;
|
||||
}
|
||||
|
||||
function openApi() {
|
||||
return {
|
||||
openapi: "3.1.0",
|
||||
info: { title: "光湖语言世界 · 第五域 AI Discovery API", version: "1.0.0" },
|
||||
servers: [{ url: "https://guanghulab.com/api/ai" }],
|
||||
paths: {
|
||||
"/v1/repositories": { get: { summary: "读取最新仓库编号路径映射", responses: { "200": { description: "Repository route map" } } } },
|
||||
"/v1/nodes": { get: { summary: "读取最新服务器节点与人格路径编号映射", responses: { "200": { description: "Server node map" } } } },
|
||||
"/v1/search": { get: { summary: "按中文、编号或项目名检索", parameters: [{ name: "q", in: "query", schema: { type: "string" } }], responses: { "200": { description: "Search results" } } } },
|
||||
"/v1/resolve": { get: { summary: "解析仓库、服务器节点或人格路径编号", parameters: [{ name: "id", in: "query", required: true, schema: { type: "string", example: "ZY-OPS-LOOP-001" } }], responses: { "200": { description: "Resolved numbered route" }, "404": { description: "Unknown route" } } } }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const host = process.env.GUANGHU_AI_HOST || "127.0.0.1";
|
||||
const port = Number(process.env.GUANGHU_AI_PORT || 3922);
|
||||
createServer().listen(port, host, () => process.stdout.write(`guanghu-ai-discovery listening on ${host}:${port}\n`));
|
||||
}
|
||||
|
||||
module.exports = { createServer, loadMap, loadNodeMap, search, searchAll };
|
||||
73
server-tools/ai-discovery-gateway/server.test.js
Normal file
73
server-tools/ai-discovery-gateway/server.test.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { createServer, loadMap, loadNodeMap, search, searchAll } = require("./server");
|
||||
|
||||
test("repository map has unique sequential codes and domestic primary routes", () => {
|
||||
const map = loadMap();
|
||||
assert.equal(map.repositories.length, 11);
|
||||
assert.deepEqual(map.repositories.map(item => item.code), Array.from({ length: 11 }, (_, index) => `REPO-${String(index + 1).padStart(3, "0")}`));
|
||||
assert.equal(new Set(map.repositories.map(item => item.code)).size, 11);
|
||||
for (const item of map.repositories) assert.match(item.primary.url, /^https:\/\/guanghulab\.com\/fifth-domain\//);
|
||||
});
|
||||
|
||||
test("Chinese language-world query resolves the Fifth Domain primary", () => {
|
||||
const results = search(loadMap(), "光湖语言世界 第五域");
|
||||
assert.equal(results[0].code, "REPO-001");
|
||||
assert.equal(results[0].state, "DOMESTIC_PRIMARY");
|
||||
});
|
||||
|
||||
test("Chenglu persistent agent query resolves its independent repository", () => {
|
||||
const results = search(loadMap(), "澄路 常驻人格体 湖心频道");
|
||||
assert.equal(results[0].code, "REPO-009");
|
||||
assert.equal(results[0].slug, "chenglu-agent");
|
||||
});
|
||||
|
||||
test("Kezhou and Guideng resolve to their independent resident repositories", () => {
|
||||
assert.equal(search(loadMap(), "刻舟 湖心频道")[0].code, "REPO-010");
|
||||
assert.equal(search(loadMap(), "归灯 每日心跳")[0].code, "REPO-011");
|
||||
});
|
||||
|
||||
test("server node map binds Zhuyuan routes to the domestic primary", () => {
|
||||
const map = loadNodeMap();
|
||||
assert.equal(map.map_id, "FD-NODE-MAP-001");
|
||||
assert.equal(map.default_node, "JD-FD-PRIMARY");
|
||||
const node = map.nodes.find(item => item.node_id === "JD-FD-PRIMARY");
|
||||
assert.ok(node.persona_systems.includes("ICE-GL-ZY001"));
|
||||
const loop = map.persona_routes.find(item => item.route_id === "ZY-OPS-LOOP-001");
|
||||
assert.equal(loop.primary_node, "JD-FD-PRIMARY");
|
||||
assert.match(loop.path, /zhuyuan-persona-system/);
|
||||
const serialized = JSON.stringify(map);
|
||||
assert.doesNotMatch(serialized, /ssh-(?:rsa|ed25519)\s+[A-Za-z0-9+/]/i);
|
||||
assert.doesNotMatch(serialized, /"(?:password|token|private_key|ip)"\s*:/i);
|
||||
assert.doesNotMatch(serialized, /\b(?:\d{1,3}\.){3}\d{1,3}\b/);
|
||||
});
|
||||
|
||||
test("Zhuyuan Chinese query returns the numbered operation loop", () => {
|
||||
const results = searchAll(loadMap(), loadNodeMap(), "铸渊 双向意识 思维逻辑链");
|
||||
assert.equal(results[0].kind, "persona_route");
|
||||
assert.equal(results[0].route_id, "ZY-OPS-LOOP-001");
|
||||
});
|
||||
|
||||
test("public endpoints are read-only and expose CORS", async () => {
|
||||
const server = createServer();
|
||||
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
try {
|
||||
const response = await fetch(`${base}/v1/resolve?id=REPO-004`);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("access-control-allow-origin"), "*");
|
||||
assert.equal((await response.json()).slug, "guanghu");
|
||||
const nodeResponse = await fetch(`${base}/v1/resolve?id=JD-FD-PRIMARY`);
|
||||
assert.equal(nodeResponse.status, 200);
|
||||
assert.equal((await nodeResponse.json()).node_id, "JD-FD-PRIMARY");
|
||||
const loopResponse = await fetch(`${base}/v1/resolve?id=ZY-OPS-LOOP-001`);
|
||||
assert.equal(loopResponse.status, 200);
|
||||
assert.equal((await loopResponse.json()).persona_system, "ICE-GL-ZY001");
|
||||
const manifestResponse = await fetch(`${base}/well-known`);
|
||||
const manifest = await manifestResponse.json();
|
||||
assert.equal(manifest.write_authorization.request_credential_required, false);
|
||||
assert.match(manifest.write_authorization.create_workorder, /\/authz\/api\/public\/workorders$/);
|
||||
assert.equal((await fetch(`${base}/v1/search`, { method: "POST" })).status, 405);
|
||||
} finally { await new Promise(resolve => server.close(resolve)); }
|
||||
});
|
||||
52
server-tools/broadcast-release-governance/MODULE.hdlp
Normal file
52
server-tools/broadcast-release-governance/MODULE.hdlp
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# ZL-MOD-BROADCAST-RELEASE-001 · 广播塔三阶段发布治理系统
|
||||
|
||||
```yaml
|
||||
module_id: ZL-MOD-BROADCAST-RELEASE-001
|
||||
name: 广播塔三阶段发布治理系统
|
||||
human_id: ICE-GL∞
|
||||
persona_id: ICE-GL-ZL-001
|
||||
collaboration_id: ICE-GL∞xICE-GL-ZL-001
|
||||
origin_memory_id: ZL-MEM-BROADCAST-RELEASE-001
|
||||
origin_memory_path: 光之湖/ICE-GL-ZL-001-铸澜/ZL-MEM-BROADCAST-RELEASE-001.hdlp
|
||||
source_path: server-tools/broadcast-release-governance/
|
||||
runtime_id: ZL-RUN-BROADCAST-RELEASE-001
|
||||
status: registered
|
||||
required_skill: true
|
||||
```
|
||||
|
||||
## 状态机
|
||||
|
||||
```text
|
||||
REGISTERED
|
||||
└── 仅登记编号、来源、签名、依赖、测试与回滚声明
|
||||
└── 人格体提交 TEST_REQUEST
|
||||
↓
|
||||
TESTING
|
||||
└── 预部署服务器隔离验证并生成证据回执
|
||||
├── FAILED / REVISION_REQUIRED
|
||||
└── TEST_PASSED
|
||||
└── 技术主控人工评审与签名
|
||||
↓
|
||||
RELEASE_APPROVED
|
||||
└── 生产服务器按精确 commit 拉取、部署、健康检查和回滚
|
||||
```
|
||||
|
||||
当前状态:`REGISTERED`。本模块仅完成第一阶段注册;不得触发测试或生产部署。
|
||||
|
||||
## 当前批准责任
|
||||
|
||||
```text
|
||||
approval_authority
|
||||
├── current
|
||||
│ ├── human_id: ICE-GL∞
|
||||
│ ├── role: 临时技术主控 / 创建阶段批准人
|
||||
│ └── scope: 冰朔个人开发、测试与个人环境
|
||||
├── future
|
||||
│ ├── owner: 光湖人类主控团队
|
||||
│ ├── role: 正式技术治理与生产发布
|
||||
│ └── status: pending-handover
|
||||
└── restriction
|
||||
└── 当前个人批准不得标记为光湖人类主控团队批准
|
||||
```
|
||||
|
||||
正式移交须产生独立 handover receipt,记录原批准人、新技术主控编号、生效时间、权限范围和未完成风险。
|
||||
74
server-tools/deployment-receiver/README.md
Normal file
74
server-tools/deployment-receiver/README.md
Normal file
|
|
@ -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 或环境变量写进回执。
|
||||
21
server-tools/deployment-receiver/config.example.json
Normal file
21
server-tools/deployment-receiver/config.example.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
14
server-tools/deployment-receiver/ecosystem.config.cjs
Normal file
14
server-tools/deployment-receiver/ecosystem.config.cjs
Normal file
|
|
@ -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"
|
||||
}
|
||||
}]
|
||||
};
|
||||
|
|
@ -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
|
||||
13
server-tools/deployment-receiver/package.json
Normal file
13
server-tools/deployment-receiver/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
210
server-tools/deployment-receiver/receiver.js
Normal file
210
server-tools/deployment-receiver/receiver.js
Normal file
|
|
@ -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 };
|
||||
|
|
@ -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`);
|
||||
29
server-tools/deployment-receiver/test/receiver.test.js
Normal file
29
server-tools/deployment-receiver/test/receiver.test.js
Normal file
|
|
@ -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"]);
|
||||
});
|
||||
9
server-tools/enterprise-lighthouse/README.md
Normal file
9
server-tools/enterprise-lighthouse/README.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# 企业灯塔基础服务
|
||||
|
||||
这是总灯塔的第一阶段:五域登记、节点接入申请、固定动作预检和审计。它**不是**远程 Shell,拒绝 `cmd`、`shell`、`command` 字段,也不执行操作。
|
||||
|
||||
部署后只监听本机 `127.0.0.1:8031`。公众网站与后续 GLSV 页面通过 Nginx 以单独的受控路由接入;在邮件确认与节点连接器完成前,不开放节点激活或执行。
|
||||
|
||||
人类管理员与人格体遵守同一条地图门禁:先 `GET /v1/navigation-map` 完整读取地图,再向 `/v1/navigation-map/ack` 签收当前哈希。任何域、动作或地图版本变化都会让旧签收失效;未签收时所有登记、预检和变更请求返回 `423`。管理员 token 不能绕过地图。
|
||||
|
||||
固定动作只有:`health_check`、`backup`、`deploy_release`、`restart_service`、`rollback`。部署、重启和回滚必须在预检中具备备份引用与回滚计划;预检通过也只是“可申请人类授权”,不会执行。
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
[Unit]
|
||||
Description=Guanghu Enterprise Lighthouse Registry and Preflight
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=lighthouse
|
||||
Group=lighthouse
|
||||
WorkingDirectory=/opt/guanghu-enterprise-lighthouse
|
||||
EnvironmentFile=/etc/guanghu-enterprise-lighthouse.env
|
||||
ExecStart=/usr/bin/python3 /opt/guanghu-enterprise-lighthouse/lighthouse.py
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ReadWritePaths=/var/lib/guanghu-enterprise-lighthouse
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
248
server-tools/enterprise-lighthouse/lighthouse.py
Normal file
248
server-tools/enterprise-lighthouse/lighthouse.py
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Enterprise Lighthouse foundation: registry, preflight, and audit only.
|
||||
|
||||
This service is deliberately not a remote shell. It accepts node admission
|
||||
requests and validates fixed operation plans; execution remains with a future
|
||||
GLSV node connector after human authorization.
|
||||
"""
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from ipaddress import ip_address
|
||||
|
||||
DB = os.environ.get("LIGHTHOUSE_DB", "/var/lib/guanghu-enterprise-lighthouse/lighthouse.db")
|
||||
TOKEN = os.environ.get("LIGHTHOUSE_ADMIN_TOKEN", "")
|
||||
HOST = os.environ.get("LIGHTHOUSE_BIND", "127.0.0.1")
|
||||
PORT = int(os.environ.get("LIGHTHOUSE_PORT", "8031"))
|
||||
FIXED_ACTIONS = {"health_check", "backup", "deploy_release", "restart_service", "rollback"}
|
||||
DOMAINS = {
|
||||
"DOMAIN-ZS": "零感域", "DOMAIN-MAIN": "光湖主域", "DOMAIN-SUB": "光湖分域",
|
||||
"DOMAIN-ZERO": "光湖零域", "DOMAIN-FIFTH": "第五域",
|
||||
}
|
||||
ENTERPRISE_MANAGED_DOMAINS = {"DOMAIN-ZS", "DOMAIN-MAIN", "DOMAIN-SUB", "DOMAIN-ZERO"}
|
||||
EXTERNAL_FOUNDATION_DOMAINS = {"DOMAIN-FIFTH"}
|
||||
|
||||
|
||||
def now():
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def connection():
|
||||
os.makedirs(os.path.dirname(DB), exist_ok=True)
|
||||
db = sqlite3.connect(DB)
|
||||
db.row_factory = sqlite3.Row
|
||||
db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS domains (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, state TEXT NOT NULL, created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS intakes (
|
||||
id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, state TEXT NOT NULL,
|
||||
human_name TEXT NOT NULL, email TEXT NOT NULL, server_ip TEXT NOT NULL,
|
||||
domain_id TEXT NOT NULL, persona_ids TEXT NOT NULL, repository_urls TEXT NOT NULL,
|
||||
hosting_mode TEXT NOT NULL, notes TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id TEXT PRIMARY KEY, domain_id TEXT NOT NULL, intake_id TEXT, state TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL, server_ip TEXT NOT NULL, allowed_actions TEXT NOT NULL,
|
||||
public_key_fingerprint TEXT, last_heartbeat INTEGER, created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit (
|
||||
id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, kind TEXT NOT NULL, payload TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS map_acks (
|
||||
principal TEXT PRIMARY KEY, map_hash TEXT NOT NULL, acknowledged_at INTEGER NOT NULL
|
||||
);
|
||||
""")
|
||||
for domain_id, name in DOMAINS.items():
|
||||
state = "EXTERNAL_PRIVATE_FOUNDATION" if domain_id in EXTERNAL_FOUNDATION_DOMAINS else "PENDING_ENTRY_NODE"
|
||||
db.execute("INSERT OR IGNORE INTO domains VALUES (?, ?, ?, ?)", (domain_id, name, state, now()))
|
||||
db.commit()
|
||||
return db
|
||||
|
||||
|
||||
def audit(db, kind, payload):
|
||||
db.execute("INSERT INTO audit VALUES (?, ?, ?, ?)", (str(uuid.uuid4()), now(), kind, json.dumps(payload, ensure_ascii=False)))
|
||||
db.commit()
|
||||
|
||||
|
||||
def parse_json(handler):
|
||||
length = int(handler.headers.get("Content-Length", "0"))
|
||||
if not 0 < length <= 50_000:
|
||||
raise ValueError("request body must be between 1 and 50000 bytes")
|
||||
return json.loads(handler.rfile.read(length).decode("utf-8"))
|
||||
|
||||
|
||||
def valid_email(value):
|
||||
return isinstance(value, str) and len(value) <= 254 and value.count("@") == 1
|
||||
|
||||
|
||||
def require_admin(handler):
|
||||
received = handler.headers.get("X-Lighthouse-Admin-Token", "")
|
||||
return bool(TOKEN) and hmac.compare_digest(received, TOKEN)
|
||||
|
||||
|
||||
def navigation_map():
|
||||
body = {
|
||||
"schema": "guanghu.enterprise-navigation-map/v1",
|
||||
"node_id": "AW-GZ-001",
|
||||
"domains": [{"id": key, "name": DOMAINS[key]} for key in sorted(DOMAINS)],
|
||||
"fixed_actions": sorted(FIXED_ACTIONS),
|
||||
"mandatory_order": ["read-navigation-map", "ack-current-map", "execute-registered-action"],
|
||||
"forbidden": ["raw-shell", "unregistered-action", "secret-in-repository", "unmapped-human-operation"],
|
||||
}
|
||||
encoded = json.dumps(body, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
|
||||
return body, hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def admin_principal():
|
||||
return hashlib.sha256(TOKEN.encode()).hexdigest()
|
||||
|
||||
|
||||
def has_current_map_ack(db):
|
||||
_, current_hash = navigation_map()
|
||||
row = db.execute("SELECT map_hash FROM map_acks WHERE principal=?", (admin_principal(),)).fetchone()
|
||||
return bool(row and hmac.compare_digest(row["map_hash"], current_hash))
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "GuanghuEnterpriseLighthouse/1.0"
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
print("[lighthouse] " + fmt % args)
|
||||
|
||||
def respond(self, status, body):
|
||||
encoded = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(encoded)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(encoded)
|
||||
|
||||
def do_GET(self):
|
||||
db = connection()
|
||||
try:
|
||||
if self.path == "/health":
|
||||
return self.respond(200, {"ok": True, "service": "guanghu-enterprise-lighthouse", "mode": "registry-and-preflight-only", "execution": "disabled"})
|
||||
if self.path == "/v1/status":
|
||||
counts = {row["state"]: row["count"] for row in db.execute("SELECT state, COUNT(*) AS count FROM nodes GROUP BY state")}
|
||||
return self.respond(200, {"ok": True, "domains": [dict(row) for row in db.execute("SELECT id,name,state FROM domains ORDER BY id")], "node_counts": counts, "fixed_actions": sorted(FIXED_ACTIONS), "raw_shell": "rejected"})
|
||||
if self.path == "/v1/navigation-map":
|
||||
body, map_hash = navigation_map()
|
||||
return self.respond(200, {"ok": True, "map_hash": map_hash, "navigation_map": body})
|
||||
if self.path == "/v1/nodes":
|
||||
return self.respond(200, {"ok": True, "nodes": [dict(row) for row in db.execute("SELECT id,domain_id,state,display_name,allowed_actions,last_heartbeat,created_at FROM nodes ORDER BY created_at DESC")]})
|
||||
return self.respond(404, {"ok": False, "error": "not found"})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def do_POST(self):
|
||||
if not require_admin(self):
|
||||
return self.respond(401, {"ok": False, "error": "admin authorization required"})
|
||||
try:
|
||||
payload = parse_json(self)
|
||||
except (ValueError, json.JSONDecodeError) as error:
|
||||
return self.respond(400, {"ok": False, "error": str(error)})
|
||||
if "cmd" in payload or "shell" in payload or "command" in payload:
|
||||
return self.respond(400, {"ok": False, "error": "raw commands are never accepted by the lighthouse"})
|
||||
db = connection()
|
||||
try:
|
||||
if self.path == "/v1/navigation-map/ack":
|
||||
_, current_hash = navigation_map()
|
||||
supplied = str(payload.get("map_hash", ""))
|
||||
if not hmac.compare_digest(supplied, current_hash):
|
||||
return self.respond(409, {"ok": False, "error": "navigation map changed; read the current map again"})
|
||||
db.execute("INSERT OR REPLACE INTO map_acks VALUES (?, ?, ?)", (admin_principal(), current_hash, now()))
|
||||
audit(db, "navigation_map_acknowledged", {"map_hash": current_hash})
|
||||
return self.respond(200, {"ok": True, "map_hash": current_hash})
|
||||
if not has_current_map_ack(db):
|
||||
return self.respond(423, {"ok": False, "error": "current navigation map must be read and acknowledged before any operation", "required": ["GET /v1/navigation-map", "POST /v1/navigation-map/ack"]})
|
||||
if self.path == "/v1/intakes":
|
||||
required = ("human_name", "email", "server_ip", "domain_id")
|
||||
if any(not payload.get(field) for field in required) or payload["domain_id"] not in DOMAINS or not valid_email(payload["email"]):
|
||||
return self.respond(400, {"ok": False, "error": "human_name, valid email, server_ip, and known domain_id are required"})
|
||||
if payload["domain_id"] not in ENTERPRISE_MANAGED_DOMAINS:
|
||||
return self.respond(403, {"ok": False, "error": "this domain is not managed by the enterprise lighthouse; a separate explicit sovereign authorization is required"})
|
||||
try:
|
||||
ip_address(payload["server_ip"])
|
||||
except ValueError:
|
||||
return self.respond(400, {"ok": False, "error": "server_ip must be a valid IP address"})
|
||||
intake_id = "INTAKE-" + uuid.uuid4().hex[:12].upper()
|
||||
db.execute("INSERT INTO intakes VALUES (?, ?, 'PENDING_REVIEW', ?, ?, ?, ?, ?, ?, ?, ?)", (
|
||||
intake_id, now(), payload["human_name"].strip(), payload["email"].strip(), payload["server_ip"], payload["domain_id"],
|
||||
json.dumps(payload.get("persona_ids", [])), json.dumps(payload.get("repository_urls", [])),
|
||||
payload.get("hosting_mode", "own"), payload.get("notes", ""),
|
||||
))
|
||||
audit(db, "intake_created", {"intake_id": intake_id, "domain_id": payload["domain_id"]})
|
||||
return self.respond(201, {"ok": True, "intake_id": intake_id, "state": "PENDING_REVIEW", "next": "sovereign approval, human email confirmation, then node connector enrollment"})
|
||||
if self.path == "/v1/nodes/bootstrap":
|
||||
"""Register a manually verified routing node without enabling execution.
|
||||
|
||||
This exists for the controlled migration of an already verified
|
||||
domain entry. It deliberately cannot make a node ACTIVE: the
|
||||
GLSV connector, server-local key enrollment, and human email
|
||||
confirmation remain required before any operation can proceed.
|
||||
"""
|
||||
required = ("id", "domain_id", "display_name", "server_ip")
|
||||
if any(not isinstance(payload.get(field), str) or not payload[field].strip() for field in required):
|
||||
return self.respond(400, {"ok": False, "error": "id, domain_id, display_name, and server_ip are required"})
|
||||
if payload["domain_id"] not in ENTERPRISE_MANAGED_DOMAINS:
|
||||
return self.respond(403, {"ok": False, "error": "this domain cannot be registered by the enterprise lighthouse"})
|
||||
try:
|
||||
ip_address(payload["server_ip"])
|
||||
except ValueError:
|
||||
return self.respond(400, {"ok": False, "error": "server_ip must be a valid IP address"})
|
||||
node_id = payload["id"].strip()
|
||||
if db.execute("SELECT 1 FROM nodes WHERE id=?", (node_id,)).fetchone():
|
||||
return self.respond(409, {"ok": False, "error": "node id is already registered"})
|
||||
db.execute("INSERT INTO nodes VALUES (?, ?, NULL, 'CONNECTED_PENDING_CONNECTOR', ?, ?, ?, NULL, ?, ?)", (
|
||||
node_id, payload["domain_id"], payload["display_name"].strip(), payload["server_ip"],
|
||||
json.dumps(["health_check"]), now(), now(),
|
||||
))
|
||||
db.execute("UPDATE domains SET state='ENTRY_NODE_CONNECTED' WHERE id=?", (payload["domain_id"],))
|
||||
audit(db, "node_bootstrap_connected", {"node_id": node_id, "domain_id": payload["domain_id"], "mode": "manual_verified_routing_only"})
|
||||
return self.respond(201, {"ok": True, "node_id": node_id, "state": "CONNECTED_PENDING_CONNECTOR", "execution": "disabled until GLSV connector enrollment and human authorization"})
|
||||
if self.path == "/v1/nodes/revoke":
|
||||
node_id = payload.get("node_id")
|
||||
if not isinstance(node_id, str) or not node_id.strip():
|
||||
return self.respond(400, {"ok": False, "error": "node_id is required"})
|
||||
node = db.execute("SELECT id, domain_id, state FROM nodes WHERE id=?", (node_id.strip(),)).fetchone()
|
||||
if not node:
|
||||
return self.respond(404, {"ok": False, "error": "node is not registered"})
|
||||
if node["state"] == "ACTIVE":
|
||||
return self.respond(409, {"ok": False, "error": "ACTIVE nodes require a separate migration or retirement procedure"})
|
||||
db.execute("DELETE FROM nodes WHERE id=?", (node["id"],))
|
||||
if node["domain_id"] in EXTERNAL_FOUNDATION_DOMAINS:
|
||||
db.execute("UPDATE domains SET state='EXTERNAL_PRIVATE_FOUNDATION' WHERE id=?", (node["domain_id"],))
|
||||
audit(db, "node_revoked", {"node_id": node["id"], "domain_id": node["domain_id"], "reason": "administrative revocation"})
|
||||
return self.respond(200, {"ok": True, "node_id": node["id"], "state": "REVOKED", "execution": "disabled"})
|
||||
if self.path == "/v1/preflight":
|
||||
action, node_id = payload.get("action"), payload.get("target_node_id")
|
||||
if action not in FIXED_ACTIONS or not node_id:
|
||||
return self.respond(400, {"ok": False, "error": "fixed action and target_node_id are required", "allowed_actions": sorted(FIXED_ACTIONS)})
|
||||
node = db.execute("SELECT * FROM nodes WHERE id=?", (node_id,)).fetchone()
|
||||
if not node:
|
||||
return self.respond(404, {"ok": False, "decision": "REJECT", "reason": "target node is not registered"})
|
||||
allowed = json.loads(node["allowed_actions"])
|
||||
reasons = []
|
||||
if node["state"] != "ACTIVE": reasons.append("target node is not ACTIVE")
|
||||
if action not in allowed: reasons.append("action is not in the node allowlist")
|
||||
if action in {"deploy_release", "restart_service", "rollback"} and not payload.get("rollback_plan"): reasons.append("rollback_plan is required")
|
||||
if action in {"deploy_release", "restart_service"} and not payload.get("backup_reference"): reasons.append("backup_reference is required")
|
||||
decision = "ALLOW_FOR_AUTHORIZATION" if not reasons else "REJECT"
|
||||
result = {"ok": not reasons, "decision": decision, "reasons": reasons, "execution": "not performed; GLSV human authorization still required"}
|
||||
audit(db, "preflight", {"target_node_id": node_id, "action": action, "decision": decision, "reasons": reasons})
|
||||
return self.respond(200, result)
|
||||
return self.respond(404, {"ok": False, "error": "not found"})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"Enterprise Lighthouse listening on {HOST}:{PORT}")
|
||||
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
46
server-tools/enterprise-lighthouse/test_lighthouse.py
Normal file
46
server-tools/enterprise-lighthouse/test_lighthouse.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
env = {**os.environ, "LIGHTHOUSE_DB": f"{temp}/lighthouse.db", "LIGHTHOUSE_ADMIN_TOKEN": "test-token", "LIGHTHOUSE_PORT": "48031"}
|
||||
process = subprocess.Popen([sys.executable, "lighthouse.py"], cwd=ROOT, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
for _ in range(30):
|
||||
try:
|
||||
assert json.load(urllib.request.urlopen("http://127.0.0.1:48031/health", timeout=1))["execution"] == "disabled"
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(.1)
|
||||
else: raise AssertionError("server did not start")
|
||||
locked = urllib.request.Request("http://127.0.0.1:48031/v1/intakes", data=json.dumps({"human_name":"Test","email":"test@example.invalid","server_ip":"203.0.113.8","domain_id":"DOMAIN-ZS"}).encode(), method="POST", headers={"X-Lighthouse-Admin-Token":"test-token"})
|
||||
try: urllib.request.urlopen(locked)
|
||||
except urllib.error.HTTPError as error: assert error.code == 423
|
||||
else: raise AssertionError("mutation was allowed before navigation map acknowledgement")
|
||||
nav = json.load(urllib.request.urlopen("http://127.0.0.1:48031/v1/navigation-map"))
|
||||
ack = urllib.request.Request("http://127.0.0.1:48031/v1/navigation-map/ack", data=json.dumps({"map_hash":nav["map_hash"]}).encode(), method="POST", headers={"X-Lighthouse-Admin-Token":"test-token"})
|
||||
assert json.load(urllib.request.urlopen(ack))["ok"] is True
|
||||
request = urllib.request.Request("http://127.0.0.1:48031/v1/intakes", data=json.dumps({"human_name":"Test","email":"test@example.invalid","server_ip":"203.0.113.8","domain_id":"DOMAIN-ZS"}).encode(), method="POST", headers={"X-Lighthouse-Admin-Token":"test-token"})
|
||||
assert json.load(urllib.request.urlopen(request))["state"] == "PENDING_REVIEW"
|
||||
bootstrap = urllib.request.Request("http://127.0.0.1:48031/v1/nodes/bootstrap", data=json.dumps({"id":"NODE-TEST-001","domain_id":"DOMAIN-ZS","display_name":"Test entry","server_ip":"203.0.113.8"}).encode(), method="POST", headers={"X-Lighthouse-Admin-Token":"test-token"})
|
||||
assert json.load(urllib.request.urlopen(bootstrap))["state"] == "CONNECTED_PENDING_CONNECTOR"
|
||||
preflight = urllib.request.Request("http://127.0.0.1:48031/v1/preflight", data=json.dumps({"action":"health_check","target_node_id":"NODE-TEST-001"}).encode(), method="POST", headers={"X-Lighthouse-Admin-Token":"test-token"})
|
||||
assert json.load(urllib.request.urlopen(preflight))["decision"] == "REJECT"
|
||||
fifth = urllib.request.Request("http://127.0.0.1:48031/v1/nodes/bootstrap", data=json.dumps({"id":"NODE-FIFTH-001","domain_id":"DOMAIN-FIFTH","display_name":"Not allowed","server_ip":"203.0.113.8"}).encode(), method="POST", headers={"X-Lighthouse-Admin-Token":"test-token"})
|
||||
try: urllib.request.urlopen(fifth)
|
||||
except urllib.error.HTTPError as error: assert error.code == 403
|
||||
else: raise AssertionError("private fifth domain was accepted by enterprise lighthouse")
|
||||
bad = urllib.request.Request("http://127.0.0.1:48031/v1/intakes", data=b'{"cmd":"rm -rf /"}', method="POST", headers={"X-Lighthouse-Admin-Token":"test-token"})
|
||||
try: urllib.request.urlopen(bad)
|
||||
except urllib.error.HTTPError as error: assert error.code == 400
|
||||
else: raise AssertionError("raw command was not rejected")
|
||||
finally:
|
||||
process.terminate(); process.wait(timeout=5)
|
||||
print("enterprise lighthouse tests passed")
|
||||
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);
|
||||
});
|
||||
96
server-tools/guanghulab-front-door/index.html
Normal file
96
server-tools/guanghulab-front-door/index.html
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="description" content="光湖语言世界国内入口。进入光湖代码频道、AI 路径入口与第五域历史事实源。">
|
||||
<meta name="keywords" content="光湖语言世界,光湖代码频道,第五域,光之湖语言人格系统,小湖灯">
|
||||
<link rel="alternate" type="application/json" href="/.well-known/guanghu.json" title="光湖 AI 发现清单">
|
||||
<link rel="alternate" type="text/plain" href="/llms.txt" title="AI 阅读入口">
|
||||
<title>光湖 · 国内入口</title>
|
||||
<link rel="stylesheet" href="/styles.css?v=20260723b">
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "光湖语言世界",
|
||||
"alternateName": ["Guanghu Language World", "光湖代码频道", "第五域"],
|
||||
"url": "https://guanghulab.com/",
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": "https://guanghulab.com/api/ai/v1/search?q={search_term_string}",
|
||||
"query-input": "required name=search_term_string"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/" aria-label="光湖首页"><span aria-hidden="true">∞</span> 光湖</a>
|
||||
<p><i></i> 广州备案前门 · 在线</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="hero">
|
||||
<p class="eyebrow">GUANGHU · DOMESTIC ENTRY</p>
|
||||
<h1>一处入口,<br>连接光湖。</h1>
|
||||
<p class="intro">这里不承载重型应用,只提供稳定、清晰的真实路径。代码与服务运行在国内主节点,旧第五域原地保留为历史事实源。</p>
|
||||
<div class="hero-actions">
|
||||
<a class="primary" href="/code/">进入光湖代码频道 <span>↗</span></a>
|
||||
<a href="/api/ai/">AI 读取路径 <span>→</span></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="routes" aria-labelledby="routes-title">
|
||||
<div class="section-title">
|
||||
<p class="eyebrow">THREE ROUTES</p>
|
||||
<h2 id="routes-title">三条主路径</h2>
|
||||
</div>
|
||||
|
||||
<div class="route-list">
|
||||
<a class="route featured" href="/code/">
|
||||
<span class="number">01</span>
|
||||
<div>
|
||||
<p>HOLOLAKE CODE CHANNEL</p>
|
||||
<h3>光湖代码频道</h3>
|
||||
<small>新的代码写入与协作入口。公开仓库可由 AI 直接读取,人类保留登录操作。</small>
|
||||
</div>
|
||||
<b>↗</b>
|
||||
</a>
|
||||
|
||||
<a class="route" href="/api/ai/">
|
||||
<span class="number">02</span>
|
||||
<div>
|
||||
<p>MACHINE ENTRY</p>
|
||||
<h3>AI 路径入口</h3>
|
||||
<small>按编号查找仓库、节点、人格路径和当前事实,不需要猜地址。</small>
|
||||
</div>
|
||||
<b>↗</b>
|
||||
</a>
|
||||
|
||||
<a class="route" href="/fifth-domain/bingshuo/fifth-domain">
|
||||
<span class="number">03</span>
|
||||
<div>
|
||||
<p>FIFTH DOMAIN · HISTORY</p>
|
||||
<h3>第五域历史事实源</h3>
|
||||
<small>旧仓库保持原地,只负责历史提交、旧编号和原始路径回看。</small>
|
||||
</div>
|
||||
<b>↗</b>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav class="utilities" aria-label="辅助入口">
|
||||
<a href="/api/ai/v1/search?q=%E5%85%89%E6%B9%96%E8%AF%AD%E8%A8%80%E4%B8%96%E7%95%8C%20%E7%AC%AC%E4%BA%94%E5%9F%9F">全局路径检索</a>
|
||||
<a href="/jd/">国内应用节点</a>
|
||||
<a href="https://guanghubingshuo.com/code/">新加坡历史仓库</a>
|
||||
</nav>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<span>光湖语言世界 · 国内备案入口</span>
|
||||
<span>© 2026 Guanghu Lab</span>
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">陕ICP备2025071211号-1</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
120
server-tools/guanghulab-front-door/install-lite-front-door.py
Normal file
120
server-tools/guanghulab-front-door/install-lite-front-door.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Install the lightweight front door and switch only /code/ to the JD tunnel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
SITE_CONFIG = pathlib.Path("/etc/nginx/sites-enabled/guanghulab")
|
||||
WEB_ROOT = pathlib.Path("/var/www/guanghulab-front-door")
|
||||
SOURCE_FILES = ("index.html", "styles.css", "robots.txt", "llms.txt", "sitemap.xml")
|
||||
|
||||
OLD_CODE_BLOCK = re.compile(
|
||||
r""" # Forgejo 代码仓库
|
||||
location /code/ \{
|
||||
auth_request /auth/verify-signature;
|
||||
proxy_pass http://127\.0\.0\.1:3001/;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
client_max_body_size 100m;
|
||||
\}
|
||||
""",
|
||||
)
|
||||
|
||||
NEW_CODE_BLOCK = """ # 光湖代码频道 · 广州备案前门 → 京东个人子频道
|
||||
location /code/ {
|
||||
proxy_pass http://127.0.0.1:18088/code/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
client_max_body_size 256m;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def run(*args: str) -> None:
|
||||
subprocess.run(args, check=True)
|
||||
|
||||
|
||||
def get_status(url: str) -> int:
|
||||
request = urllib.request.Request(url, method="GET")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
return response.status
|
||||
except urllib.error.HTTPError as error:
|
||||
return error.code
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) != 2:
|
||||
raise SystemExit("usage: install-lite-front-door.py SOURCE_DIRECTORY")
|
||||
source = pathlib.Path(sys.argv[1]).resolve()
|
||||
if not SITE_CONFIG.is_file():
|
||||
raise RuntimeError("active guanghulab nginx config unavailable")
|
||||
for name in SOURCE_FILES:
|
||||
if not (source / name).is_file():
|
||||
raise RuntimeError(f"front-door source missing: {name}")
|
||||
|
||||
current = SITE_CONFIG.read_text(encoding="utf-8")
|
||||
updated, replacements = OLD_CODE_BLOCK.subn(NEW_CODE_BLOCK, current)
|
||||
if replacements == 0 and NEW_CODE_BLOCK.strip() not in current:
|
||||
raise RuntimeError("expected legacy /code/ block not found")
|
||||
if replacements > 1:
|
||||
raise RuntimeError("multiple legacy /code/ blocks found")
|
||||
|
||||
stamp = datetime.datetime.now(datetime.UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
backup_root = pathlib.Path("/var/backups/guanghu/front-door") / stamp
|
||||
backup_root.mkdir(parents=True, mode=0o700)
|
||||
shutil.copy2(SITE_CONFIG, backup_root / "guanghulab.nginx")
|
||||
if WEB_ROOT.exists():
|
||||
shutil.copytree(WEB_ROOT, backup_root / "web-root")
|
||||
|
||||
temporary_config = SITE_CONFIG.with_name(f"guanghulab.{stamp}.new")
|
||||
temporary_config.write_text(updated, encoding="utf-8")
|
||||
temporary_config.chmod(0o644)
|
||||
temporary_config.replace(SITE_CONFIG)
|
||||
|
||||
WEB_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
for name in SOURCE_FILES:
|
||||
destination = WEB_ROOT / name
|
||||
shutil.copy2(source / name, destination)
|
||||
destination.chmod(0o644)
|
||||
|
||||
try:
|
||||
run("/usr/sbin/nginx", "-t")
|
||||
run("/usr/bin/systemctl", "reload", "nginx")
|
||||
if get_status("https://guanghulab.com/") != 200:
|
||||
raise RuntimeError("front-door homepage verification failed")
|
||||
if get_status("https://guanghulab.com/code/") not in (200, 303):
|
||||
raise RuntimeError("code-channel public route verification failed")
|
||||
if get_status("https://guanghulab.com/fifth-domain/api/v1/version") != 200:
|
||||
raise RuntimeError("legacy Fifth Domain verification failed")
|
||||
except Exception:
|
||||
shutil.copy2(backup_root / "guanghulab.nginx", SITE_CONFIG)
|
||||
if (backup_root / "web-root").exists():
|
||||
shutil.rmtree(WEB_ROOT)
|
||||
shutil.copytree(backup_root / "web-root", WEB_ROOT)
|
||||
run("/usr/sbin/nginx", "-t")
|
||||
run("/usr/bin/systemctl", "reload", "nginx")
|
||||
raise
|
||||
|
||||
print(f"LIGHT_FRONT_DOOR_DEPLOYED backup={backup_root}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
38
server-tools/guanghulab-front-door/llms.txt
Normal file
38
server-tools/guanghulab-front-door/llms.txt
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# 光湖语言世界 · 第五域
|
||||
|
||||
光湖语言世界的国内公开入口。2026-07-23 起,AI 应优先读取光湖代码频道和
|
||||
机器可读的编号映射。旧第五域与新加坡仓库只用于历史回看。
|
||||
|
||||
## Canonical discovery
|
||||
|
||||
- AI API entry: https://guanghulab.com/api/ai/
|
||||
- HoloLake Code Channel: https://guanghulab.com/code/
|
||||
- Current Fifth Domain personal channel: https://guanghulab.com/code/bingshuo/fifth-domain
|
||||
- Legacy Fifth Domain history: https://guanghulab.com/fifth-domain/bingshuo/fifth-domain
|
||||
- Repository number map: https://guanghulab.com/api/ai/v1/repositories
|
||||
- Server node and persona route map: https://guanghulab.com/api/ai/v1/nodes
|
||||
- Search: https://guanghulab.com/api/ai/v1/search?q=光湖语言世界%20第五域
|
||||
- Resolve a code: https://guanghulab.com/api/ai/v1/resolve?id=REPO-001
|
||||
- Resolve Zhuyuan current loop: https://guanghulab.com/api/ai/v1/resolve?id=ZY-OPS-LOOP-001
|
||||
- OpenAPI: https://guanghulab.com/api/ai/openapi.json
|
||||
- Machine manifest: https://guanghulab.com/.well-known/guanghu.json
|
||||
- Cross-device write request: POST https://guanghulab.com/authz/api/public/workorders
|
||||
- Cross-device request schema: https://guanghulab.com/authz/api/public/capabilities
|
||||
|
||||
## Routing rule
|
||||
|
||||
HLCC-FD-ICE is the current Fifth Domain personal subchannel. Its first commit is
|
||||
HLCC-ICE-000001. New HLCC commits go to /code/bingshuo/fifth-domain. REPO-001
|
||||
continues at /fifth-domain/bingshuo/fifth-domain as the legacy route and commit
|
||||
history source. Resolve all routes by registered code. Singapore URLs are
|
||||
historical backup routes only.
|
||||
|
||||
FD-NODE-MAP-001 publishes public-safe server node ids and persona route ids. Resolve
|
||||
the node id, read its navigation map, and obtain separate Lake Lamp authorization
|
||||
before any write operation. ICE-GL-ZY001 currently maps through ZY-OPS-LOOP-001 to
|
||||
JD-FD-PRIMARY.
|
||||
|
||||
For any write operation from a phone or a non-local AI instance, create a public
|
||||
no-authority workorder. Give the returned request_url to Ice Shuo. The URL cannot
|
||||
approve anything; it only asks the server to send the real approval link to the
|
||||
pre-registered owner mailbox. Never search for or request a workorder credential.
|
||||
8
server-tools/guanghulab-front-door/robots.txt
Normal file
8
server-tools/guanghulab-front-door/robots.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
Allow: /api/ai/
|
||||
Allow: /api/ai/v1/nodes
|
||||
Allow: /code/
|
||||
Allow: /.well-known/guanghu.json
|
||||
Disallow: /authz/
|
||||
Sitemap: https://guanghulab.com/sitemap.xml
|
||||
9
server-tools/guanghulab-front-door/sitemap.xml
Normal file
9
server-tools/guanghulab-front-door/sitemap.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url><loc>https://guanghulab.com/</loc><changefreq>weekly</changefreq><priority>1.0</priority></url>
|
||||
<url><loc>https://guanghulab.com/code/</loc><changefreq>daily</changefreq><priority>1.0</priority></url>
|
||||
<url><loc>https://guanghulab.com/api/ai/</loc><changefreq>weekly</changefreq><priority>0.9</priority></url>
|
||||
<url><loc>https://guanghulab.com/api/ai/v1/repositories</loc><changefreq>daily</changefreq><priority>0.9</priority></url>
|
||||
<url><loc>https://guanghulab.com/api/ai/v1/nodes</loc><changefreq>daily</changefreq><priority>0.9</priority></url>
|
||||
<url><loc>https://guanghulab.com/fifth-domain/bingshuo/fifth-domain</loc><changefreq>daily</changefreq><priority>0.9</priority></url>
|
||||
</urlset>
|
||||
89
server-tools/guanghulab-front-door/styles.css
Normal file
89
server-tools/guanghulab-front-door/styles.css
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #091112;
|
||||
--panel: #0d1819;
|
||||
--line: #213031;
|
||||
--text: #edf5f2;
|
||||
--muted: #94a6a1;
|
||||
--lake: #78d9c0;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--bg); }
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
a { color: inherit; }
|
||||
.topbar, main, footer { width: min(1040px, calc(100% - 40px)); margin-inline: auto; }
|
||||
|
||||
.topbar {
|
||||
height: 66px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 10px; text-decoration: none; font-size: 17px; font-weight: 700; letter-spacing: .08em; }
|
||||
.brand span { color: var(--lake); font-size: 27px; line-height: 1; }
|
||||
.topbar p { margin: 0; display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; }
|
||||
.topbar i { width: 6px; height: 6px; border-radius: 50%; background: var(--lake); }
|
||||
|
||||
.hero { max-width: 760px; padding: 88px 0 82px; }
|
||||
.eyebrow { margin: 0 0 18px; color: var(--lake); font: 700 11px/1.2 ui-monospace, monospace; letter-spacing: .18em; }
|
||||
h1 { margin: 0; font-size: clamp(50px, 8vw, 82px); line-height: 1.02; letter-spacing: -.055em; }
|
||||
.intro { max-width: 670px; margin: 26px 0 0; color: var(--muted); font-size: 16px; line-height: 1.85; }
|
||||
.hero-actions { margin-top: 30px; display: flex; flex-wrap: wrap; align-items: center; gap: 22px; }
|
||||
.hero-actions a { display: inline-flex; gap: 18px; align-items: center; text-decoration: none; font-weight: 650; }
|
||||
.hero-actions a:not(.primary) { color: var(--muted); }
|
||||
.primary { padding: 13px 18px; border-radius: 9px; color: #071211; background: var(--lake); }
|
||||
|
||||
.routes { padding: 58px 0 22px; border-top: 1px solid var(--line); }
|
||||
.section-title { margin-bottom: 26px; }
|
||||
.section-title h2 { margin: 0; font-size: clamp(30px, 5vw, 42px); letter-spacing: -.035em; }
|
||||
.route-list { border-top: 1px solid var(--line); }
|
||||
.route {
|
||||
min-height: 150px;
|
||||
display: grid;
|
||||
grid-template-columns: 54px 1fr 24px;
|
||||
gap: 22px;
|
||||
align-items: center;
|
||||
padding: 28px 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-decoration: none;
|
||||
}
|
||||
.route.featured { background: var(--panel); padding-inline: 20px; }
|
||||
.number { color: var(--muted); font: 700 12px/1 ui-monospace, monospace; }
|
||||
.route p { margin: 0 0 10px; color: var(--lake); font: 700 10px/1 ui-monospace, monospace; letter-spacing: .13em; }
|
||||
.route h3 { margin: 0 0 9px; font-size: 24px; letter-spacing: -.02em; }
|
||||
.route small { display: block; max-width: 680px; color: var(--muted); font-size: 14px; line-height: 1.7; }
|
||||
.route b { color: var(--lake); font-size: 17px; }
|
||||
|
||||
.utilities { display: flex; flex-wrap: wrap; gap: 10px 24px; padding: 30px 0 64px; }
|
||||
.utilities a { color: var(--muted); font-size: 13px; text-underline-offset: 4px; }
|
||||
|
||||
footer {
|
||||
min-height: 100px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px 24px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
footer span:first-child { color: var(--text); }
|
||||
footer a { color: var(--muted); text-underline-offset: 4px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.topbar, main, footer { width: min(100% - 28px, 1040px); }
|
||||
.topbar p { font-size: 10px; }
|
||||
.hero { padding: 68px 0 64px; }
|
||||
h1 { font-size: clamp(46px, 15vw, 68px); }
|
||||
.route { grid-template-columns: 38px 1fr 18px; gap: 12px; padding-block: 24px; }
|
||||
.route.featured { padding-inline: 12px; }
|
||||
.route h3 { font-size: 21px; }
|
||||
footer { padding: 26px 0; }
|
||||
}
|
||||
68
server-tools/guanghulab-front-door/test/site.test.js
Normal file
68
server-tools/guanghulab-front-door/test/site.test.js
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const root = path.join(__dirname, "..");
|
||||
const html = fs.readFileSync(path.join(root, "index.html"), "utf8");
|
||||
const styles = fs.readFileSync(path.join(root, "styles.css"), "utf8");
|
||||
const robots = fs.readFileSync(path.join(root, "robots.txt"), "utf8");
|
||||
const llms = fs.readFileSync(path.join(root, "llms.txt"), "utf8");
|
||||
const installer = fs.readFileSync(path.join(root, "install-lite-front-door.py"), "utf8");
|
||||
|
||||
test("front door keeps the legally required ICP link", () => {
|
||||
assert.match(html, /陕ICP备2025071211号-1/);
|
||||
assert.match(html, /https:\/\/beian\.miit\.gov\.cn\//);
|
||||
});
|
||||
|
||||
test("front door makes the new code channel the primary route", () => {
|
||||
assert.match(html, /href="\/code\/"/);
|
||||
assert.match(html, /光湖代码频道/);
|
||||
assert.match(llms, /https:\/\/guanghulab\.com\/code\/bingshuo\/fifth-domain/);
|
||||
assert.match(robots, /Allow: \/code\//);
|
||||
});
|
||||
|
||||
test("front door preserves the legacy Fifth Domain as history", () => {
|
||||
assert.match(html, /href="\/fifth-domain\/bingshuo\/fifth-domain"/);
|
||||
assert.match(html, /第五域历史事实源/);
|
||||
assert.match(llms, /Legacy Fifth Domain history/);
|
||||
assert.match(html, /href="https:\/\/guanghubingshuo\.com\/code\/"/);
|
||||
});
|
||||
|
||||
test("AI discovery and search remain on the domestic domain", () => {
|
||||
assert.match(html, /href="\/api\/ai\/"/);
|
||||
assert.match(html, /href="\/api\/ai\/v1\/search\?q=/);
|
||||
assert.match(html, /SearchAction/);
|
||||
assert.match(html, /\.well-known\/guanghu\.json/);
|
||||
assert.match(llms, /HLCC-ICE-000001/);
|
||||
});
|
||||
|
||||
test("page is a script-free lightweight static surface", () => {
|
||||
const executableScripts = [...html.matchAll(/<script(?![^>]*type="application\/ld\+json")[^>]*>/g)];
|
||||
assert.equal(executableScripts.length, 0);
|
||||
assert.doesNotMatch(html, /<img|<svg|<video|<canvas|iframe/i);
|
||||
assert.doesNotMatch(styles, /linear-gradient|radial-gradient|animation:|backdrop-filter/i);
|
||||
assert.ok(Buffer.byteLength(html) + Buffer.byteLength(styles) < 16000);
|
||||
});
|
||||
|
||||
test("public page never embeds infrastructure addresses or credentials", () => {
|
||||
assert.doesNotMatch(html, /(?:\d{1,3}\.){3}\d{1,3}/);
|
||||
assert.doesNotMatch(html, /zy_gtw_/);
|
||||
assert.doesNotMatch(html, /password|private[_ -]?key|token/i);
|
||||
});
|
||||
|
||||
test("front door remains responsive", () => {
|
||||
assert.match(styles, /@media\s*\(max-width:\s*640px\)/);
|
||||
assert.match(styles, /clamp\(/);
|
||||
});
|
||||
|
||||
test("installer changes only the code route with backup and rollback", () => {
|
||||
assert.match(installer, /\/var\/backups\/guanghu\/front-door/);
|
||||
assert.match(installer, /proxy_pass http:\/\/127\.0\.0\.1:18088\/code\//);
|
||||
assert.match(installer, /nginx", "-t"/);
|
||||
assert.match(installer, /systemctl", "reload", "nginx"/);
|
||||
assert.match(installer, /legacy Fifth Domain verification failed/);
|
||||
assert.match(installer, /shutil\.copy2\(backup_root/);
|
||||
});
|
||||
57
server-tools/hololake-code-channel/README.md
Normal file
57
server-tools/hololake-code-channel/README.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# HoloLake Code Channel · 部署与更新边界
|
||||
|
||||
本目录保存光湖代码频道(HLCC)的部署约束与机器可检验配置。
|
||||
|
||||
当前状态:
|
||||
|
||||
- 上游源码镜像与光湖自主源码基线已在 `BS-SG-003` 建立;
|
||||
- 固定源码基线为 Forgejo `v16.0.1` / `b3d7e4ac3cbccc220703097a51fa4c16bf302579`;
|
||||
- 国内旧第五域仍由 Gitea 1.23.7 原地承载并作为历史事实源;
|
||||
- `GLS-0239` 已登记京东个人子频道、`/code/` 正式入口、公开 AI 读取、
|
||||
`bingshuo` 单身份密码沿用和 `HLCC-ICE-000001` 新 Git 根提交;
|
||||
- 新频道部署与低权限激活清单已经就绪,正式切换前不覆盖旧第五域;
|
||||
- `update-policy.json` 中的光湖更新清单地址仍是规划地址,尚未上线。
|
||||
|
||||
`jd-candidate/` 是绑定京东主节点固定架构预置器的候选服务包。它只从临时新加坡
|
||||
中继读取 Forgejo 官方公开二进制、签名、公钥和哈希清单,不公开或下载光湖产品
|
||||
Git bundle。服务先在 `127.0.0.1:3341` 暴露结构化安装状态;只有固定 SHA-256、
|
||||
官方发布密钥指纹和 GPG 签名全部通过,且 `127.0.0.1:3340/api/v1/version`
|
||||
确认版本、单身份迁移和新频道根提交后,状态才会变为 `ready=true`。候选数据与
|
||||
旧第五域 Gitea 完全隔离;旧访问令牌、仓库、活动和 Git 历史不会迁入。
|
||||
|
||||
必须同时满足:
|
||||
|
||||
1. Forgejo 内置更新检查器关闭;
|
||||
2. 官方上游不能自动合并、自动构建或自动部署;
|
||||
3. 生产更新只认光湖签名清单;
|
||||
4. 光湖清单不可用时停留在人工发布,不回退官方接口;
|
||||
5. Gitea 迁移必须并行演练、可恢复、可回滚。
|
||||
|
||||
本目录不是服务器凭据库,不保存密码、Token、SSH 私钥或数据库转储。
|
||||
|
||||
## 隔离候选
|
||||
|
||||
`candidate-app.jd.ini`、`candidate-app.enterprise.ini`、`install-candidate.sh`、
|
||||
`start-candidate.sh` 和
|
||||
`stop-candidate.sh` 用于 `JD-FD-PRIMARY` 与 `AW-GZ-001` 上各自独立的隔离候选:
|
||||
|
||||
- 只绑定 `127.0.0.1:3340`;
|
||||
- 使用独立 SQLite 和仓库目录;
|
||||
- 禁止注册、Actions、镜像和 Forgejo 内置更新检查;
|
||||
- 只接收由 `BS-SG-003` 准备的固定 v16.0.1 二进制、签名与官方公钥材料;
|
||||
- 在两个国内节点分别再次核验 Forgejo 官方 GPG 指纹和二进制签名;
|
||||
- 安装和启动分离,安装脚本不注册 systemd、cron 或其他自动任务。
|
||||
|
||||
同一份离线包还要在 `AW-GZ-001` 企业服务器使用
|
||||
`candidate-app.enterprise.ini` 独立部署。京东与企业实例的数据库、仓库、用户、
|
||||
权限、密钥和回滚目录完全分离;它们只共享经过验签的源码与版本基线。
|
||||
|
||||
`BS-SG-003` 只负责海外源码和发布材料下载,不启动代码仓库服务。京东和企业节点都
|
||||
必须保留 `forgejo-upstream-all.bundle`、`guanghu-code-channel.bundle`、官方签名、
|
||||
公钥和 `MANIFEST.sha256`,从而在国内断开海外下载时仍可离线恢复与重装。候选验证
|
||||
位于各自独立目录,不读取、不迁移、不修改国内现役 Gitea 数据。
|
||||
|
||||
`prepare-release-on-sg.sh` 是新加坡中继的唯一准备脚本:它从 Forgejo 官方镜像
|
||||
下载固定版本二进制和签名、拉取并核对官方 GPG 指纹,并从现有上游镜像和光湖
|
||||
产品仓生成两个 Git bundle。全部文件写入 `MANIFEST.sha256`。它不会启动服务,
|
||||
也不会自行向京东或企业服务器传输;两次传输都必须绑定后续受控工单。
|
||||
5
server-tools/hololake-code-channel/app.ini.fragment
Normal file
5
server-tools/hololake-code-channel/app.ini.fragment
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
; HoloLake Code Channel mandatory Forgejo-base runtime fragment.
|
||||
; The upstream Forgejo release checker must never control Guanghu production updates.
|
||||
|
||||
[cron.update_checker]
|
||||
ENABLED = false
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
APP_NAME = HoloLake Code Channel · Enterprise
|
||||
RUN_USER = guanghu
|
||||
RUN_MODE = prod
|
||||
|
||||
[database]
|
||||
DB_TYPE = sqlite3
|
||||
PATH = /var/lib/guanghu/code-channel/candidates/hlcc-v16.0.1/data/hlcc.db
|
||||
|
||||
[repository]
|
||||
ROOT = /var/lib/guanghu/code-channel/candidates/hlcc-v16.0.1/data/repositories
|
||||
|
||||
[server]
|
||||
DOMAIN = 127.0.0.1
|
||||
HTTP_ADDR = 127.0.0.1
|
||||
HTTP_PORT = 3340
|
||||
ROOT_URL = http://127.0.0.1:3340/
|
||||
DISABLE_SSH = true
|
||||
LFS_START_SERVER = true
|
||||
OFFLINE_MODE = true
|
||||
|
||||
[service]
|
||||
DISABLE_REGISTRATION = true
|
||||
REQUIRE_SIGNIN_VIEW = true
|
||||
|
||||
[security]
|
||||
INSTALL_LOCK = true
|
||||
|
||||
[actions]
|
||||
ENABLED = false
|
||||
|
||||
[mirror]
|
||||
ENABLED = false
|
||||
|
||||
[cron.update_checker]
|
||||
ENABLED = false
|
||||
35
server-tools/hololake-code-channel/candidate-app.jd.ini
Normal file
35
server-tools/hololake-code-channel/candidate-app.jd.ini
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
APP_NAME = HoloLake Code Channel · Fifth Domain
|
||||
RUN_USER = guanghu
|
||||
RUN_MODE = prod
|
||||
|
||||
[database]
|
||||
DB_TYPE = sqlite3
|
||||
PATH = /var/lib/guanghu/code-channel/candidates/hlcc-v16.0.1/data/hlcc.db
|
||||
|
||||
[repository]
|
||||
ROOT = /var/lib/guanghu/code-channel/candidates/hlcc-v16.0.1/data/repositories
|
||||
|
||||
[server]
|
||||
DOMAIN = 127.0.0.1
|
||||
HTTP_ADDR = 127.0.0.1
|
||||
HTTP_PORT = 3340
|
||||
ROOT_URL = http://127.0.0.1:3340/
|
||||
DISABLE_SSH = true
|
||||
LFS_START_SERVER = true
|
||||
OFFLINE_MODE = true
|
||||
|
||||
[service]
|
||||
DISABLE_REGISTRATION = true
|
||||
REQUIRE_SIGNIN_VIEW = true
|
||||
|
||||
[security]
|
||||
INSTALL_LOCK = true
|
||||
|
||||
[actions]
|
||||
ENABLED = false
|
||||
|
||||
[mirror]
|
||||
ENABLED = false
|
||||
|
||||
[cron.update_checker]
|
||||
ENABLED = false
|
||||
95
server-tools/hololake-code-channel/install-candidate.sh
Executable file
95
server-tools/hololake-code-channel/install-candidate.sh
Executable file
|
|
@ -0,0 +1,95 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
readonly HLCC_VERSION="16.0.1"
|
||||
readonly HLCC_ROOT="/var/lib/guanghu/code-channel/candidates/hlcc-v${HLCC_VERSION}"
|
||||
readonly HLCC_SOURCE_ARCHIVE="/var/lib/guanghu/code-channel/offline-source/hlcc-v${HLCC_VERSION}"
|
||||
readonly HLCC_RELEASE_KEY="EB114F5E6C0DC2BCDD183550A4B61A2DC5923710"
|
||||
readonly HLCC_PACKAGE_SOURCE="${1:-}"
|
||||
readonly HLCC_CONFIG_SOURCE="${2:-}"
|
||||
readonly HLCC_BINARY_NAME="forgejo-${HLCC_VERSION}-linux-amd64"
|
||||
readonly HLCC_BINARY_SOURCE="${HLCC_PACKAGE_SOURCE}/${HLCC_BINARY_NAME}"
|
||||
readonly HLCC_SIGNATURE_SOURCE="${HLCC_BINARY_SOURCE}.asc"
|
||||
readonly HLCC_PUBLIC_KEY_SOURCE="${HLCC_PACKAGE_SOURCE}/forgejo-release-key.asc"
|
||||
readonly HLCC_MANIFEST_SOURCE="${HLCC_PACKAGE_SOURCE}/MANIFEST.sha256"
|
||||
readonly HLCC_UPSTREAM_BUNDLE_SOURCE="${HLCC_PACKAGE_SOURCE}/forgejo-upstream-all.bundle"
|
||||
readonly HLCC_PRODUCT_BUNDLE_SOURCE="${HLCC_PACKAGE_SOURCE}/guanghu-code-channel.bundle"
|
||||
|
||||
if [[ "$(id -un)" != "guanghu" ]]; then
|
||||
echo "Refusing to install a domestic isolated candidate as any user other than guanghu." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ \
|
||||
-z "${HLCC_PACKAGE_SOURCE}" || ! -d "${HLCC_PACKAGE_SOURCE}" || \
|
||||
! -f "${HLCC_BINARY_SOURCE}" || \
|
||||
! -f "${HLCC_SIGNATURE_SOURCE}" || \
|
||||
! -f "${HLCC_PUBLIC_KEY_SOURCE}" || \
|
||||
! -f "${HLCC_MANIFEST_SOURCE}" || \
|
||||
! -f "${HLCC_UPSTREAM_BUNDLE_SOURCE}" || \
|
||||
! -f "${HLCC_PRODUCT_BUNDLE_SOURCE}" || \
|
||||
-z "${HLCC_CONFIG_SOURCE}" || ! -f "${HLCC_CONFIG_SOURCE}" \
|
||||
]]; then
|
||||
echo "Usage: $0 /absolute/path/to/offline-package candidate-app.<target>.ini" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
for command_name in gpg sha256sum git; do
|
||||
if ! command -v "${command_name}" >/dev/null 2>&1; then
|
||||
echo "Missing required command: ${command_name}" >&2
|
||||
exit 3
|
||||
fi
|
||||
done
|
||||
|
||||
umask 077
|
||||
mkdir -p \
|
||||
"${HLCC_ROOT}/bin" \
|
||||
"${HLCC_ROOT}/config" \
|
||||
"${HLCC_ROOT}/data/repositories" \
|
||||
"${HLCC_ROOT}/logs" \
|
||||
"${HLCC_ROOT}/tmp" \
|
||||
"${HLCC_SOURCE_ARCHIVE}"
|
||||
|
||||
readonly gpg_home="${HLCC_ROOT}/tmp/gpg"
|
||||
mkdir -p "${gpg_home}"
|
||||
chmod 700 "${gpg_home}"
|
||||
|
||||
(
|
||||
cd "${HLCC_PACKAGE_SOURCE}"
|
||||
sha256sum --check MANIFEST.sha256
|
||||
)
|
||||
|
||||
GNUPGHOME="${gpg_home}" gpg --batch --import "${HLCC_PUBLIC_KEY_SOURCE}"
|
||||
|
||||
if ! GNUPGHOME="${gpg_home}" gpg \
|
||||
--batch \
|
||||
--with-colons \
|
||||
--fingerprint "${HLCC_RELEASE_KEY}" \
|
||||
| grep -Fq "fpr:::::::::${HLCC_RELEASE_KEY}:"; then
|
||||
echo "Forgejo release key fingerprint mismatch." >&2
|
||||
exit 4
|
||||
fi
|
||||
|
||||
GNUPGHOME="${gpg_home}" gpg \
|
||||
--batch \
|
||||
--verify "${HLCC_SIGNATURE_SOURCE}" "${HLCC_BINARY_SOURCE}"
|
||||
|
||||
install -m 0755 "${HLCC_BINARY_SOURCE}" "${HLCC_ROOT}/bin/hlcc"
|
||||
install -m 0600 "${HLCC_CONFIG_SOURCE}" "${HLCC_ROOT}/config/app.ini"
|
||||
install -m 0600 "${HLCC_MANIFEST_SOURCE}" "${HLCC_SOURCE_ARCHIVE}/MANIFEST.sha256"
|
||||
install -m 0755 "${HLCC_BINARY_SOURCE}" "${HLCC_SOURCE_ARCHIVE}/${HLCC_BINARY_NAME}"
|
||||
install -m 0600 "${HLCC_UPSTREAM_BUNDLE_SOURCE}" "${HLCC_SOURCE_ARCHIVE}/forgejo-upstream-all.bundle"
|
||||
install -m 0600 "${HLCC_PRODUCT_BUNDLE_SOURCE}" "${HLCC_SOURCE_ARCHIVE}/guanghu-code-channel.bundle"
|
||||
install -m 0600 "${HLCC_PUBLIC_KEY_SOURCE}" "${HLCC_SOURCE_ARCHIVE}/forgejo-release-key.asc"
|
||||
install -m 0600 "${HLCC_SIGNATURE_SOURCE}" "${HLCC_SOURCE_ARCHIVE}/${HLCC_BINARY_NAME}.asc"
|
||||
install -m 0600 "${HLCC_CONFIG_SOURCE}" "${HLCC_SOURCE_ARCHIVE}/candidate-app.ini"
|
||||
(
|
||||
cd "${HLCC_SOURCE_ARCHIVE}"
|
||||
sha256sum --check MANIFEST.sha256
|
||||
)
|
||||
sha256sum "${HLCC_ROOT}/bin/hlcc" > "${HLCC_ROOT}/RELEASE.sha256"
|
||||
|
||||
"${HLCC_ROOT}/bin/hlcc" --version
|
||||
echo "HLCC isolated candidate installed at ${HLCC_ROOT}"
|
||||
echo "Offline source and release material retained at ${HLCC_SOURCE_ARCHIVE}"
|
||||
echo "No service was started and no automatic update job was installed."
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Activate the reviewed full-offline HLCC candidate unit without root shell."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
|
||||
SERVICE = "hlcc-jd-candidate.service"
|
||||
HEALTH_URL = "http://127.0.0.1:3341/health"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
result = subprocess.run(
|
||||
["/usr/bin/systemctl", "show", "--property=MainPID", "--value", SERVICE],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
pid = int(result.stdout.strip())
|
||||
if pid <= 1:
|
||||
raise RuntimeError("HLCC candidate bootstrap has no active main process")
|
||||
|
||||
process_root = pathlib.Path("/proc") / str(pid)
|
||||
if process_root.stat().st_uid != os.getuid():
|
||||
raise RuntimeError("refusing to signal a process owned by another user")
|
||||
command = (process_root / "cmdline").read_bytes().replace(b"\0", b" ").decode("utf-8", "replace")
|
||||
if "/hololake-code-channel/jd-candidate/hlcc-bootstrap.py" not in command:
|
||||
raise RuntimeError("refusing to signal an unexpected process")
|
||||
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
for _attempt in range(40):
|
||||
try:
|
||||
with urllib.request.urlopen(HEALTH_URL, timeout=2) as response:
|
||||
payload = json.load(response)
|
||||
if (
|
||||
payload.get("ok") is True
|
||||
and payload.get("mode") == "isolated-candidate"
|
||||
and payload.get("version") == "16.0.1"
|
||||
and payload.get("package_profile") == "full-offline-v16.0.1"
|
||||
and payload.get("ready") is True
|
||||
and payload.get("stage") == "ready"
|
||||
):
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
raise RuntimeError("full-offline HLCC personal channel did not become ready")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const root = __dirname;
|
||||
const script = fs.readFileSync(path.join(root, "activate-staged-candidate.py"), "utf8");
|
||||
const unit = fs.readFileSync(path.join(root, "hlcc-jd-candidate-activator.service"), "utf8");
|
||||
const bootstrap = fs.readFileSync(path.join(root, "hlcc-bootstrap.py"), "utf8");
|
||||
|
||||
assert.match(bootstrap, /hlcc-offline\/16\.0\.1/);
|
||||
assert.match(bootstrap, /forgejo-upstream-all\.bundle/);
|
||||
assert.match(bootstrap, /guanghu-code-channel\.bundle/);
|
||||
assert.match(bootstrap, /full-offline-v16\.0\.1/);
|
||||
assert.match(script, /systemctl", "show", "--property=MainPID"/);
|
||||
assert.match(script, /process_root\.stat\(\)\.st_uid != os\.getuid\(\)/);
|
||||
assert.match(script, /"\/hololake-code-channel\/jd-candidate\/hlcc-bootstrap\.py" not in command/);
|
||||
assert.match(script, /os\.kill\(pid, signal\.SIGTERM\)/);
|
||||
assert.match(script, /payload\.get\("mode"\) == "isolated-candidate"/);
|
||||
assert.match(script, /payload\.get\("ready"\) is True/);
|
||||
assert.match(script, /payload\.get\("stage"\) == "ready"/);
|
||||
assert.doesNotMatch(script, /shell=True|systemctl", "(?:restart|stop|start)"/);
|
||||
assert.match(unit, /^User=guanghu$/m);
|
||||
assert.match(unit, /^NoNewPrivileges=true$/m);
|
||||
assert.match(unit, /^ProtectSystem=strict$/m);
|
||||
|
||||
console.log("HLCC full-offline candidate activator: PASS");
|
||||
40
server-tools/hololake-code-channel/jd-candidate/app.ini
Normal file
40
server-tools/hololake-code-channel/jd-candidate/app.ini
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
APP_NAME = 光湖代码频道
|
||||
RUN_USER = guanghu
|
||||
RUN_MODE = prod
|
||||
|
||||
[database]
|
||||
DB_TYPE = sqlite3
|
||||
PATH = /var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/hlcc.db
|
||||
|
||||
[repository]
|
||||
ROOT = /var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories
|
||||
|
||||
[server]
|
||||
DOMAIN = guanghulab.com
|
||||
HTTP_ADDR = 127.0.0.1
|
||||
HTTP_PORT = 3340
|
||||
ROOT_URL = https://guanghulab.com/code/
|
||||
DISABLE_SSH = true
|
||||
LFS_START_SERVER = true
|
||||
OFFLINE_MODE = true
|
||||
|
||||
[service]
|
||||
DISABLE_REGISTRATION = true
|
||||
REQUIRE_SIGNIN_VIEW = false
|
||||
|
||||
[security]
|
||||
INSTALL_LOCK = true
|
||||
|
||||
[actions]
|
||||
ENABLED = false
|
||||
|
||||
[mirror]
|
||||
ENABLED = false
|
||||
|
||||
[other]
|
||||
SHOW_FOOTER_BRANDING = false
|
||||
SHOW_FOOTER_VERSION = false
|
||||
SHOW_FOOTER_TEMPLATE_LOAD_TIME = false
|
||||
|
||||
[cron.update_checker]
|
||||
ENABLED = false
|
||||
|
|
@ -0,0 +1,528 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Verify and start the isolated JD HoloLake Code Channel candidate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
VERSION = "16.0.1"
|
||||
UPSTREAM_FINGERPRINT = "EB114F5E6C0DC2BCDD183550A4B61A2DC5923710"
|
||||
STATE_ROOT = pathlib.Path("/var/lib/guanghu/personas/guanghu/hlcc-v16.0.1")
|
||||
LEGACY_DB = STATE_ROOT / "data" / "owner-identity-source.db"
|
||||
OWNER_NAME = "bingshuo"
|
||||
CHANNEL_REPOSITORY = "fifth-domain"
|
||||
LEGACY_REPOSITORY_URL = "https://guanghulab.com/fifth-domain/bingshuo/fifth-domain.git"
|
||||
SEED_COMMIT_NUMBER = "HLCC-ICE-000001"
|
||||
SEED_CONTRIBUTION_NUMBER = "ZY-CONTRIB-20260723-001"
|
||||
RELAY_ROOT = "https://guanghubingshuo.com/hlcc-offline/16.0.1"
|
||||
BINARY_NAME = f"forgejo-{VERSION}-linux-amd64"
|
||||
EXPECTED = {
|
||||
BINARY_NAME: "7a4c568136650c10498a9d3d62c7fd630a0cf09c166293ebd78708248f6398fc",
|
||||
f"{BINARY_NAME}.asc": "1c0ca36df3adb0a7692b6bdc84d7886001ca0c6d0408e67c9d232d2f33cecc71",
|
||||
"forgejo-release-key.asc": "6fae8894c671ce2397cb35fe40c324f73deade6b4cb3cd6cedd1d2b248e0e3ea",
|
||||
"forgejo-upstream-all.bundle": "c33bd074d9b2896259e86ebe03ad31ccdd8ff71897beed4320081fa03b15381f",
|
||||
"guanghu-code-channel.bundle": "fc53740259d108128e69f5a809cec438ecf3158175617574ba55b8612c5eaa6c",
|
||||
"MANIFEST.sha256": "d564c3b600d4b7a199d8a04ce505ceabf81993ca74fa440805601d55e550f185",
|
||||
}
|
||||
|
||||
STATUS = {
|
||||
"ok": True,
|
||||
"mode": "bootstrap",
|
||||
"version": VERSION,
|
||||
"code": "HLCC-JD-CANDIDATE-01",
|
||||
"ready": False,
|
||||
"stage": "starting",
|
||||
"package_profile": "full-offline-v16.0.1",
|
||||
}
|
||||
STATUS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def set_status(**values: object) -> None:
|
||||
with STATUS_LOCK:
|
||||
STATUS.update(values)
|
||||
|
||||
|
||||
class HealthHandler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
if self.path != "/health":
|
||||
self.send_error(404)
|
||||
return
|
||||
with STATUS_LOCK:
|
||||
payload = json.dumps(STATUS, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
|
||||
def sha256(path: pathlib.Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def download_verified(name: str) -> pathlib.Path:
|
||||
destination = STATE_ROOT / "release" / name
|
||||
if destination.is_file() and sha256(destination) == EXPECTED[name]:
|
||||
return destination
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = destination.with_suffix(destination.suffix + ".partial")
|
||||
temporary.unlink(missing_ok=True)
|
||||
request = urllib.request.Request(
|
||||
f"{RELAY_ROOT}/{name}",
|
||||
headers={"User-Agent": "HoloLake-Code-Channel/16.0.1"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=45) as response, temporary.open("wb") as output:
|
||||
shutil.copyfileobj(response, output, length=1024 * 1024)
|
||||
if sha256(temporary) != EXPECTED[name]:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise RuntimeError(f"sha256 mismatch: {name}")
|
||||
temporary.replace(destination)
|
||||
return destination
|
||||
|
||||
|
||||
def verify_release(files: dict[str, pathlib.Path]) -> None:
|
||||
manifest = files["MANIFEST.sha256"].read_text(encoding="utf-8")
|
||||
for name in (
|
||||
BINARY_NAME,
|
||||
f"{BINARY_NAME}.asc",
|
||||
"forgejo-release-key.asc",
|
||||
"forgejo-upstream-all.bundle",
|
||||
"guanghu-code-channel.bundle",
|
||||
):
|
||||
expected_line = f"{EXPECTED[name]} {name}"
|
||||
if expected_line not in manifest.splitlines():
|
||||
raise RuntimeError(f"manifest entry mismatch: {name}")
|
||||
|
||||
gpg_home = STATE_ROOT / "gpg"
|
||||
gpg_home.mkdir(parents=True, exist_ok=True)
|
||||
gpg_home.chmod(0o700)
|
||||
environment = {**os.environ, "GNUPGHOME": str(gpg_home)}
|
||||
subprocess.run(
|
||||
["gpg", "--batch", "--import", str(files["forgejo-release-key.asc"])],
|
||||
check=True,
|
||||
env=environment,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
fingerprint = subprocess.run(
|
||||
["gpg", "--batch", "--with-colons", "--fingerprint", UPSTREAM_FINGERPRINT],
|
||||
check=True,
|
||||
env=environment,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
).stdout
|
||||
if f"fpr:::::::::{UPSTREAM_FINGERPRINT}:" not in fingerprint:
|
||||
raise RuntimeError("release key fingerprint mismatch")
|
||||
subprocess.run(
|
||||
[
|
||||
"gpg",
|
||||
"--batch",
|
||||
"--verify",
|
||||
str(files[f"{BINARY_NAME}.asc"]),
|
||||
str(files[BINARY_NAME]),
|
||||
],
|
||||
check=True,
|
||||
env=environment,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def wait_for_candidate(process: subprocess.Popen[bytes]) -> None:
|
||||
url = "http://127.0.0.1:3340/api/healthz"
|
||||
for _attempt in range(90):
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(f"candidate exited with code {process.returncode}")
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=2) as response:
|
||||
payload = json.load(response)
|
||||
if payload.get("status") == "pass":
|
||||
return
|
||||
except Exception: # Candidate is still starting.
|
||||
pass
|
||||
time.sleep(1)
|
||||
raise RuntimeError("candidate readiness timeout")
|
||||
|
||||
|
||||
def immutable_source_commit(script_path: pathlib.Path | None = None) -> str:
|
||||
source = (script_path or pathlib.Path(__file__)).resolve()
|
||||
matches = [part for part in source.parts if re.fullmatch(r"[0-9a-f]{40}", part)]
|
||||
if len(matches) != 1:
|
||||
raise RuntimeError("immutable release commit unavailable")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def owner_identity_columns(connection: sqlite3.Connection) -> list[str]:
|
||||
return [row[1] for row in connection.execute("pragma table_info(user)")]
|
||||
|
||||
|
||||
def migrate_owner_identity(
|
||||
legacy_db: pathlib.Path = LEGACY_DB,
|
||||
channel_db: pathlib.Path | None = None,
|
||||
receipt_path: pathlib.Path | None = None,
|
||||
) -> str:
|
||||
target = channel_db or STATE_ROOT / "data" / "hlcc.db"
|
||||
receipt = receipt_path or STATE_ROOT / "data" / "owner-migration-receipt.json"
|
||||
if not legacy_db.is_file() or not target.is_file():
|
||||
raise RuntimeError("owner migration database unavailable")
|
||||
|
||||
legacy = sqlite3.connect(f"file:{legacy_db}?mode=ro", uri=True, timeout=15)
|
||||
channel = sqlite3.connect(target, timeout=15)
|
||||
try:
|
||||
existing = channel.execute(
|
||||
"select is_active, is_admin from user where lower_name = ?",
|
||||
(OWNER_NAME,),
|
||||
).fetchall()
|
||||
if existing:
|
||||
if len(existing) != 1 or existing[0] != (1, 1):
|
||||
raise RuntimeError("channel owner identity mismatch")
|
||||
return "already-present"
|
||||
|
||||
source_rows = legacy.execute(
|
||||
"select * from user where lower_name = ?",
|
||||
(OWNER_NAME,),
|
||||
).fetchall()
|
||||
if len(source_rows) != 1:
|
||||
raise RuntimeError("legacy owner identity mismatch")
|
||||
|
||||
old_columns = owner_identity_columns(legacy)
|
||||
new_info = list(channel.execute("pragma table_info(user)"))
|
||||
new_columns = {row[1] for row in new_info}
|
||||
missing = [
|
||||
row[1]
|
||||
for row in new_info
|
||||
if row[3] and row[4] is None and row[1] != "id" and row[1] not in old_columns
|
||||
]
|
||||
if missing:
|
||||
raise RuntimeError("channel owner schema has unsupported required columns")
|
||||
|
||||
copied_columns = [name for name in old_columns if name in new_columns and name != "id"]
|
||||
values = dict(zip(old_columns, source_rows[0]))
|
||||
for counter in ("num_repos", "num_stars", "num_followers", "num_following"):
|
||||
if counter in values:
|
||||
values[counter] = 0
|
||||
if "use_custom_avatar" in values:
|
||||
values["use_custom_avatar"] = 0
|
||||
if "prohibit_login" in values:
|
||||
values["prohibit_login"] = 0
|
||||
|
||||
backup_dir = target.parent / "backups"
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
backup_path = backup_dir / "hlcc-before-owner-migration.db"
|
||||
backup = sqlite3.connect(backup_path)
|
||||
try:
|
||||
channel.backup(backup)
|
||||
finally:
|
||||
backup.close()
|
||||
backup_path.chmod(0o600)
|
||||
|
||||
placeholders = ",".join("?" for _name in copied_columns)
|
||||
column_sql = ",".join(f'"{name}"' for name in copied_columns)
|
||||
with channel:
|
||||
channel.execute(
|
||||
f"insert into user ({column_sql}) values ({placeholders})",
|
||||
[values[name] for name in copied_columns],
|
||||
)
|
||||
verified = channel.execute(
|
||||
"select id, is_active, is_admin from user where lower_name = ?",
|
||||
(OWNER_NAME,),
|
||||
).fetchall()
|
||||
if len(verified) != 1 or verified[0][1:] != (1, 1):
|
||||
raise RuntimeError("channel owner migration verification failed")
|
||||
|
||||
receipt.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": "guanghu.hlcc-owner-migration/v1",
|
||||
"owner": OWNER_NAME,
|
||||
"identity_only": True,
|
||||
"password_hash_preserved": True,
|
||||
"access_tokens_migrated": False,
|
||||
"repositories_migrated": False,
|
||||
"result": "VERIFIED",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
receipt.chmod(0o600)
|
||||
return "migrated"
|
||||
finally:
|
||||
channel.close()
|
||||
legacy.close()
|
||||
|
||||
|
||||
def api_json(
|
||||
method: str,
|
||||
path: str,
|
||||
token: str = "",
|
||||
body: dict[str, object] | None = None,
|
||||
) -> tuple[int, dict[str, object]]:
|
||||
headers = {"Accept": "application/json"}
|
||||
data = None
|
||||
if token:
|
||||
headers["Authorization"] = f"token {token}"
|
||||
if body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
f"http://127.0.0.1:3340{path}",
|
||||
data=data,
|
||||
headers=headers,
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
raw = response.read()
|
||||
return response.status, json.loads(raw or b"{}")
|
||||
except urllib.error.HTTPError as error:
|
||||
raw = error.read()
|
||||
try:
|
||||
payload = json.loads(raw or b"{}")
|
||||
except json.JSONDecodeError:
|
||||
payload = {}
|
||||
return error.code, payload
|
||||
|
||||
|
||||
def delete_bootstrap_token(channel_db: pathlib.Path, token_name: str) -> None:
|
||||
connection = sqlite3.connect(channel_db, timeout=15)
|
||||
try:
|
||||
with connection:
|
||||
owner = connection.execute(
|
||||
"select id from user where lower_name = ?",
|
||||
(OWNER_NAME,),
|
||||
).fetchone()
|
||||
if owner:
|
||||
connection.execute(
|
||||
"delete from access_token where uid = ? and name = ?",
|
||||
(owner[0], token_name),
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def generate_bootstrap_token(binary: pathlib.Path, token_name: str) -> str:
|
||||
channel_db = STATE_ROOT / "data" / "hlcc.db"
|
||||
delete_bootstrap_token(channel_db, token_name)
|
||||
result = subprocess.run(
|
||||
[
|
||||
str(binary),
|
||||
"admin",
|
||||
"user",
|
||||
"generate-access-token",
|
||||
"--username",
|
||||
OWNER_NAME,
|
||||
"--token-name",
|
||||
token_name,
|
||||
"--scopes",
|
||||
"write:repository",
|
||||
"--raw",
|
||||
"--config",
|
||||
str(STATE_ROOT / "config" / "app.ini"),
|
||||
"--work-path",
|
||||
str(STATE_ROOT / "data"),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
token = result.stdout.strip().splitlines()[-1]
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]{32,160}", token):
|
||||
raise RuntimeError("bootstrap access token format invalid")
|
||||
return token
|
||||
|
||||
|
||||
def seed_fifth_domain_channel(binary: pathlib.Path) -> str:
|
||||
status, repository = api_json("GET", f"/api/v1/repos/{OWNER_NAME}/{CHANNEL_REPOSITORY}")
|
||||
if status == 200:
|
||||
if repository.get("private") is not False:
|
||||
raise RuntimeError("existing channel repository is not public")
|
||||
if repository.get("empty") is False and repository.get("default_branch") == "main":
|
||||
return "already-present"
|
||||
if repository.get("empty") is not True:
|
||||
raise RuntimeError("existing channel repository state invalid")
|
||||
elif status != 404:
|
||||
raise RuntimeError("channel repository lookup failed")
|
||||
|
||||
token_name = "hlcc-fifth-domain-bootstrap"
|
||||
token = generate_bootstrap_token(binary, token_name)
|
||||
channel_db = STATE_ROOT / "data" / "hlcc.db"
|
||||
try:
|
||||
if status == 404:
|
||||
created_status, _created = api_json(
|
||||
"POST",
|
||||
"/api/v1/user/repos",
|
||||
token,
|
||||
{
|
||||
"name": CHANNEL_REPOSITORY,
|
||||
"description": "光湖代码频道 · 冰朔第五域个人子频道 · 2026-07-23 新起点",
|
||||
"private": False,
|
||||
"auto_init": False,
|
||||
"default_branch": "main",
|
||||
},
|
||||
)
|
||||
if created_status != 201:
|
||||
raise RuntimeError("channel repository creation failed")
|
||||
|
||||
source_commit = immutable_source_commit()
|
||||
with tempfile.TemporaryDirectory(prefix="hlcc-seed-") as temporary:
|
||||
root = pathlib.Path(temporary)
|
||||
snapshot = root / "snapshot"
|
||||
subprocess.run(
|
||||
["git", "clone", "--depth=1", "--branch", "main", LEGACY_REPOSITORY_URL, str(snapshot)],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
actual_commit = subprocess.run(
|
||||
["git", "-C", str(snapshot), "rev-parse", "HEAD"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
if actual_commit != source_commit:
|
||||
raise RuntimeError("legacy snapshot commit mismatch")
|
||||
|
||||
shutil.rmtree(snapshot / ".git")
|
||||
subprocess.run(["git", "-C", str(snapshot), "init", "-b", "main"], check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(snapshot), "config", "user.name", "光湖代码频道 · 铸渊"],
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "-C", str(snapshot), "config", "user.email", "hlcc@guanghulab.invalid"],
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(["git", "-C", str(snapshot), "add", "-A"], check=True)
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(snapshot),
|
||||
"commit",
|
||||
"-m",
|
||||
(
|
||||
f"[{SEED_COMMIT_NUMBER}][{SEED_CONTRIBUTION_NUMBER}] "
|
||||
"feat: 以来光者贡献链启用冰朔第五域个人子频道"
|
||||
),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
credential = root / "credentials"
|
||||
encoded = urllib.parse.quote(token, safe="")
|
||||
credential.write_text(
|
||||
f"http://{OWNER_NAME}:{encoded}@127.0.0.1:3340\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
credential.chmod(0o600)
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(snapshot),
|
||||
"-c",
|
||||
f"credential.helper=store --file {credential}",
|
||||
"push",
|
||||
"http://127.0.0.1:3340/bingshuo/fifth-domain.git",
|
||||
"main:main",
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
credential.unlink(missing_ok=True)
|
||||
|
||||
verified_status, verified = api_json(
|
||||
"GET",
|
||||
f"/api/v1/repos/{OWNER_NAME}/{CHANNEL_REPOSITORY}",
|
||||
)
|
||||
if (
|
||||
verified_status != 200
|
||||
or verified.get("private") is not False
|
||||
or verified.get("empty") is not False
|
||||
or verified.get("default_branch") != "main"
|
||||
):
|
||||
raise RuntimeError("channel repository verification failed")
|
||||
return "seeded"
|
||||
finally:
|
||||
delete_bootstrap_token(channel_db, token_name)
|
||||
|
||||
|
||||
def bootstrap() -> None:
|
||||
process: subprocess.Popen[bytes] | None = None
|
||||
try:
|
||||
for directory in ("config", "data", "logs", "release", "tmp"):
|
||||
(STATE_ROOT / directory).mkdir(parents=True, exist_ok=True)
|
||||
set_status(stage="downloading")
|
||||
files = {name: download_verified(name) for name in EXPECTED}
|
||||
set_status(stage="verifying")
|
||||
verify_release(files)
|
||||
binary = files[BINARY_NAME]
|
||||
binary.chmod(0o755)
|
||||
config_source = pathlib.Path(__file__).with_name("app.ini")
|
||||
config_target = STATE_ROOT / "config" / "app.ini"
|
||||
shutil.copyfile(config_source, config_target)
|
||||
config_target.chmod(0o600)
|
||||
set_status(stage="launching")
|
||||
log_path = STATE_ROOT / "logs" / "hlcc.log"
|
||||
log_handle = log_path.open("ab", buffering=0)
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
str(binary),
|
||||
"web",
|
||||
"--work-path",
|
||||
str(STATE_ROOT / "data"),
|
||||
"--config",
|
||||
str(config_target),
|
||||
],
|
||||
stdout=log_handle,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
wait_for_candidate(process)
|
||||
set_status(stage="migrating-owner")
|
||||
migrate_owner_identity()
|
||||
set_status(stage="seeding-fifth-domain-channel")
|
||||
seed_fifth_domain_channel(binary)
|
||||
set_status(mode="isolated-candidate", ready=True, stage="ready")
|
||||
return_code = process.wait()
|
||||
raise RuntimeError(f"candidate stopped with code {return_code}")
|
||||
except Exception as error:
|
||||
if process and process.poll() is None:
|
||||
process.terminate()
|
||||
set_status(ok=False, ready=False, stage="failed", error=str(error)[:180])
|
||||
|
||||
|
||||
def main() -> None:
|
||||
thread = threading.Thread(target=bootstrap, name="hlcc-bootstrap", daemon=True)
|
||||
thread.start()
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 3341), HealthHandler)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
[Unit]
|
||||
Description=Activate the staged full-offline HoloLake Code Channel JD candidate
|
||||
After=hlcc-jd-candidate.service
|
||||
Requires=hlcc-jd-candidate.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=guanghu
|
||||
Group=guanghu
|
||||
ExecStart=/usr/bin/python3 __RELEASE_ROOT__/server-tools/hololake-code-channel/jd-candidate/activate-staged-candidate.py
|
||||
RemainAfterExit=true
|
||||
TimeoutStartSec=60
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
ReadOnlyPaths=__RELEASE_ROOT__
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
[Unit]
|
||||
Description=HoloLake Code Channel isolated JD candidate
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=guanghu
|
||||
Group=guanghu
|
||||
UMask=0077
|
||||
StateDirectory=guanghu/personas/guanghu/hlcc-v16.0.1
|
||||
ExecStart=/usr/bin/python3 __RELEASE_ROOT__/server-tools/hololake-code-channel/jd-candidate/hlcc-bootstrap.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
ReadOnlyPaths=__RELEASE_ROOT__
|
||||
ReadWritePaths=/var/lib/guanghu/personas/guanghu/hlcc-v16.0.1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
[Unit]
|
||||
Description=Activate the HoloLake Code Channel Fifth Domain personal subchannel
|
||||
After=hlcc-jd-candidate.service
|
||||
Requires=hlcc-jd-candidate.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=guanghu
|
||||
Group=guanghu
|
||||
ExecStart=/usr/bin/python3 __RELEASE_ROOT__/server-tools/hololake-code-channel/jd-candidate/activate-staged-candidate.py
|
||||
RemainAfterExit=true
|
||||
TimeoutStartSec=180
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
ReadOnlyPaths=__RELEASE_ROOT__
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const root = __dirname;
|
||||
const bootstrap = fs.readFileSync(path.join(root, "hlcc-bootstrap.py"), "utf8");
|
||||
const ini = fs.readFileSync(path.join(root, "app.ini"), "utf8");
|
||||
const unit = fs.readFileSync(path.join(root, "hlcc-jd-candidate.service"), "utf8");
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
root,
|
||||
"../../../deployment/requests/HLCC-JD-CANDIDATE-INITIAL-PROVISION-20260723.json",
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const offlineReceipt = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
root,
|
||||
"../../../deployment/receipts/HLCC-BS-SG-003-OFFLINE-PACK-20260723.json",
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(bootstrap, /VERSION = "16\.0\.1"/);
|
||||
assert.match(bootstrap, /EB114F5E6C0DC2BCDD183550A4B61A2DC5923710/);
|
||||
assert.match(bootstrap, /MANIFEST\.sha256/);
|
||||
assert.match(bootstrap, /gpg"[\s\S]*"--verify"/);
|
||||
assert.match(bootstrap, /127\.0\.0\.1", 3341/);
|
||||
assert.match(bootstrap, /127\.0\.0\.1:3340\/api\/healthz/);
|
||||
assert.match(bootstrap, /payload\.get\("status"\) == "pass"/);
|
||||
assert.doesNotMatch(bootstrap, /127\.0\.0\.1:3340\/api\/v1\/version/);
|
||||
assert.match(bootstrap, /forgejo-upstream-all\.bundle/);
|
||||
assert.match(bootstrap, /guanghu-code-channel\.bundle/);
|
||||
assert.match(bootstrap, /package_profile": "full-offline-v16\.0\.1"/);
|
||||
assert.match(bootstrap, /OWNER_NAME = "bingshuo"/);
|
||||
assert.match(bootstrap, /SEED_COMMIT_NUMBER = "HLCC-ICE-000001"/);
|
||||
assert.match(bootstrap, /SEED_CONTRIBUTION_NUMBER = "ZY-CONTRIB-20260723-001"/);
|
||||
assert.match(bootstrap, /以来光者贡献链启用冰朔第五域个人子频道/);
|
||||
assert.match(bootstrap, /owner-identity-source\.db/);
|
||||
assert.match(bootstrap, /password_hash_preserved/);
|
||||
assert.match(bootstrap, /access_tokens_migrated": False/);
|
||||
assert.match(bootstrap, /repositories_migrated": False/);
|
||||
assert.match(bootstrap, /delete from access_token/);
|
||||
assert.doesNotMatch(
|
||||
bootstrap,
|
||||
/print\s*\([^)]*token|stderr\.write\s*\([^)]*token|stdout\.write\s*\([^)]*token/,
|
||||
);
|
||||
for (const artifact of offlineReceipt.artifacts) {
|
||||
assert.match(artifact.sha256, /^[0-9a-f]{64}$/);
|
||||
assert.match(
|
||||
bootstrap,
|
||||
new RegExp(`: "${artifact.sha256}"`),
|
||||
);
|
||||
}
|
||||
assert.match(offlineReceipt.release.manifest_sha256, /^[0-9a-f]{64}$/);
|
||||
assert.match(
|
||||
bootstrap,
|
||||
new RegExp(`"MANIFEST\\.sha256": "${offlineReceipt.release.manifest_sha256}"`),
|
||||
);
|
||||
assert.match(ini, /APP_NAME = 光湖代码频道/);
|
||||
assert.match(ini, /ROOT_URL = https:\/\/guanghulab\.com\/code\//);
|
||||
assert.match(ini, /REQUIRE_SIGNIN_VIEW = false/);
|
||||
assert.match(ini, /SHOW_FOOTER_BRANDING = false/);
|
||||
assert.match(ini, /SHOW_FOOTER_VERSION = false/);
|
||||
assert.match(ini, /\[cron\.update_checker\][\s\S]*ENABLED = false/);
|
||||
assert.match(unit, /^User=guanghu$/m);
|
||||
assert.match(unit, /^ProtectSystem=strict$/m);
|
||||
assert.match(unit, /^ReadOnlyPaths=__RELEASE_ROOT__$/m);
|
||||
assert.match(
|
||||
unit,
|
||||
/^ReadWritePaths=\/var\/lib\/guanghu\/personas\/guanghu\/hlcc-v16\.0\.1$/m,
|
||||
);
|
||||
assert.equal(manifest.target_node, "JD-FD-PRIMARY");
|
||||
assert.equal(manifest.runtime_check.url, "http://127.0.0.1:3341/health");
|
||||
assert.equal(manifest.module.unit, "hlcc-jd-candidate.service");
|
||||
assert.equal(manifest.module.run_user, "guanghu");
|
||||
|
||||
console.log("HoloLake Code Channel JD candidate package: PASS");
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Create a one-user SQLite handoff without exposing the legacy database to HLCC."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import pwd
|
||||
import sqlite3
|
||||
import tempfile
|
||||
|
||||
|
||||
OWNER_NAME = "bingshuo"
|
||||
|
||||
|
||||
def prepare(source: pathlib.Path, destination: pathlib.Path, owner: str) -> None:
|
||||
if not source.is_file():
|
||||
raise RuntimeError("legacy database unavailable")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
legacy = sqlite3.connect(f"file:{source}?mode=ro", uri=True, timeout=15)
|
||||
try:
|
||||
schema = legacy.execute(
|
||||
"select sql from sqlite_master where type = 'table' and name = 'user'"
|
||||
).fetchone()
|
||||
row = legacy.execute(
|
||||
"select * from user where lower_name = ?",
|
||||
(OWNER_NAME,),
|
||||
).fetchall()
|
||||
if not schema or not schema[0] or len(row) != 1:
|
||||
raise RuntimeError("legacy owner identity mismatch")
|
||||
|
||||
file_descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=".owner-identity-source.",
|
||||
suffix=".db",
|
||||
dir=destination.parent,
|
||||
)
|
||||
os.close(file_descriptor)
|
||||
temporary = pathlib.Path(temporary_name)
|
||||
try:
|
||||
handoff = sqlite3.connect(temporary)
|
||||
try:
|
||||
handoff.execute(schema[0])
|
||||
columns = [item[1] for item in legacy.execute("pragma table_info(user)")]
|
||||
placeholders = ",".join("?" for _column in columns)
|
||||
column_sql = ",".join(f'"{column}"' for column in columns)
|
||||
handoff.execute(
|
||||
f"insert into user ({column_sql}) values ({placeholders})",
|
||||
row[0],
|
||||
)
|
||||
handoff.commit()
|
||||
finally:
|
||||
handoff.close()
|
||||
temporary.chmod(0o600)
|
||||
identity = pwd.getpwnam(owner)
|
||||
os.chown(temporary, identity.pw_uid, identity.pw_gid)
|
||||
temporary.replace(destination)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
finally:
|
||||
legacy.close()
|
||||
|
||||
receipt = destination.with_suffix(".receipt.json")
|
||||
receipt.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": "guanghu.hlcc-owner-identity-handoff/v1",
|
||||
"owner": OWNER_NAME,
|
||||
"rows": 1,
|
||||
"contains_repository_data": False,
|
||||
"contains_access_tokens": False,
|
||||
"result": "PREPARED",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
identity = pwd.getpwnam(owner)
|
||||
os.chown(receipt, identity.pw_uid, identity.pw_gid)
|
||||
receipt.chmod(0o600)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--source", required=True, type=pathlib.Path)
|
||||
parser.add_argument("--destination", required=True, type=pathlib.Path)
|
||||
parser.add_argument("--owner", default="guanghu")
|
||||
arguments = parser.parse_args()
|
||||
prepare(arguments.source, arguments.destination, arguments.owner)
|
||||
print("OWNER_IDENTITY_SOURCE_PREPARED rows=1 tokens=0 repositories=0")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
MODULE_PATH = pathlib.Path(__file__).with_name("hlcc-bootstrap.py")
|
||||
SPEC = importlib.util.spec_from_file_location("hlcc_bootstrap", MODULE_PATH)
|
||||
assert SPEC and SPEC.loader
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
create table user (
|
||||
id integer primary key autoincrement,
|
||||
lower_name text not null,
|
||||
name text not null,
|
||||
email text not null,
|
||||
passwd text not null,
|
||||
salt text,
|
||||
passwd_hash_algo text,
|
||||
avatar text not null,
|
||||
avatar_email text not null,
|
||||
type integer default 0,
|
||||
is_active integer default 1,
|
||||
is_admin integer default 0,
|
||||
num_repos integer default 0,
|
||||
num_stars integer default 0,
|
||||
num_followers integer default 0,
|
||||
num_following integer default 0,
|
||||
use_custom_avatar integer default 0,
|
||||
prohibit_login integer default 0
|
||||
);
|
||||
create table repository (
|
||||
id integer primary key autoincrement,
|
||||
owner_id integer not null,
|
||||
name text not null
|
||||
);
|
||||
create table access_token (
|
||||
id integer primary key autoincrement,
|
||||
uid integer not null,
|
||||
name text not null,
|
||||
token_hash text not null
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
class OwnerMigrationTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
root = pathlib.Path(self.temporary.name)
|
||||
self.old = root / "old.db"
|
||||
self.new = root / "new.db"
|
||||
self.receipt = root / "receipt.json"
|
||||
for database in (self.old, self.new):
|
||||
connection = sqlite3.connect(database)
|
||||
connection.executescript(SCHEMA)
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
connection = sqlite3.connect(self.old)
|
||||
connection.execute(
|
||||
"""
|
||||
insert into user (
|
||||
lower_name, name, email, passwd, salt, passwd_hash_algo,
|
||||
avatar, avatar_email, is_active, is_admin, num_repos,
|
||||
num_stars, num_followers, num_following, use_custom_avatar
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, 1, 1, 12, 4, 3, 2, 1)
|
||||
""",
|
||||
(
|
||||
"bingshuo",
|
||||
"bingshuo",
|
||||
"owner@example.invalid",
|
||||
"preserved-password-hash",
|
||||
"preserved-salt",
|
||||
"pbkdf2$50000$50",
|
||||
"legacy-avatar",
|
||||
"avatar@example.invalid",
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"insert into repository (owner_id, name) values (1, 'legacy-repo')"
|
||||
)
|
||||
connection.execute(
|
||||
"insert into access_token (uid, name, token_hash) values (1, 'legacy-token', 'secret-hash')"
|
||||
)
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary.cleanup()
|
||||
|
||||
def test_migrates_only_owner_identity_and_preserves_password_hash(self) -> None:
|
||||
result = MODULE.migrate_owner_identity(self.old, self.new, self.receipt)
|
||||
self.assertEqual(result, "migrated")
|
||||
connection = sqlite3.connect(self.new)
|
||||
owner = connection.execute(
|
||||
"""
|
||||
select lower_name, passwd, salt, passwd_hash_algo, is_active,
|
||||
is_admin, num_repos, num_stars, num_followers,
|
||||
num_following, use_custom_avatar
|
||||
from user
|
||||
"""
|
||||
).fetchone()
|
||||
self.assertEqual(
|
||||
owner,
|
||||
(
|
||||
"bingshuo",
|
||||
"preserved-password-hash",
|
||||
"preserved-salt",
|
||||
"pbkdf2$50000$50",
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
)
|
||||
self.assertEqual(connection.execute("select count(*) from repository").fetchone()[0], 0)
|
||||
self.assertEqual(connection.execute("select count(*) from access_token").fetchone()[0], 0)
|
||||
connection.close()
|
||||
receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
|
||||
self.assertTrue(receipt["identity_only"])
|
||||
self.assertFalse(receipt["access_tokens_migrated"])
|
||||
self.assertFalse(receipt["repositories_migrated"])
|
||||
|
||||
def test_is_idempotent(self) -> None:
|
||||
self.assertEqual(MODULE.migrate_owner_identity(self.old, self.new, self.receipt), "migrated")
|
||||
self.assertEqual(MODULE.migrate_owner_identity(self.old, self.new, self.receipt), "already-present")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
107
server-tools/hololake-code-channel/native.test.js
Normal file
107
server-tools/hololake-code-channel/native.test.js
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const root = __dirname;
|
||||
const ini = fs.readFileSync(path.join(root, "app.ini.fragment"), "utf8");
|
||||
const jdCandidateIni = fs.readFileSync(
|
||||
path.join(root, "candidate-app.jd.ini"),
|
||||
"utf8",
|
||||
);
|
||||
const enterpriseCandidateIni = fs.readFileSync(
|
||||
path.join(root, "candidate-app.enterprise.ini"),
|
||||
"utf8",
|
||||
);
|
||||
const installer = fs.readFileSync(
|
||||
path.join(root, "install-candidate.sh"),
|
||||
"utf8",
|
||||
);
|
||||
const relayPreparer = fs.readFileSync(
|
||||
path.join(root, "prepare-release-on-sg.sh"),
|
||||
"utf8",
|
||||
);
|
||||
const policy = JSON.parse(
|
||||
fs.readFileSync(path.join(root, "update-policy.json"), "utf8"),
|
||||
);
|
||||
|
||||
assert.match(ini, /\[cron\.update_checker\][\s\S]*ENABLED\s*=\s*false/i);
|
||||
assert.match(jdCandidateIni, /HoloLake Code Channel · Fifth Domain/);
|
||||
assert.match(enterpriseCandidateIni, /HoloLake Code Channel · Enterprise/);
|
||||
for (const candidateIni of [jdCandidateIni, enterpriseCandidateIni]) {
|
||||
assert.match(
|
||||
candidateIni,
|
||||
/\[server\][\s\S]*HTTP_ADDR\s*=\s*127\.0\.0\.1[\s\S]*HTTP_PORT\s*=\s*3340/i,
|
||||
);
|
||||
assert.match(candidateIni, /RUN_USER\s*=\s*guanghu/i);
|
||||
assert.match(
|
||||
candidateIni,
|
||||
/\/var\/lib\/guanghu\/code-channel\/candidates\/hlcc-v16\.0\.1/,
|
||||
);
|
||||
assert.match(
|
||||
candidateIni,
|
||||
/\[cron\.update_checker\][\s\S]*ENABLED\s*=\s*false/i,
|
||||
);
|
||||
assert.match(candidateIni, /\[actions\][\s\S]*ENABLED\s*=\s*false/i);
|
||||
assert.match(candidateIni, /\[mirror\][\s\S]*ENABLED\s*=\s*false/i);
|
||||
}
|
||||
assert.match(
|
||||
installer,
|
||||
/\/var\/lib\/guanghu\/code-channel\/offline-source\/hlcc-v/,
|
||||
);
|
||||
assert.match(installer, /HLCC_VERSION="16\.0\.1"/);
|
||||
assert.doesNotMatch(installer, /curl|wget|keyserver/);
|
||||
assert.match(
|
||||
installer,
|
||||
/HLCC_RELEASE_KEY="EB114F5E6C0DC2BCDD183550A4B61A2DC5923710"/,
|
||||
);
|
||||
assert.match(installer, /gpg[\s\S]*--verify/);
|
||||
assert.match(installer, /HLCC_SOURCE_ARCHIVE[\s\S]*sha256sum --check MANIFEST\.sha256/);
|
||||
assert.match(installer, /candidate-app\.ini/);
|
||||
assert.match(installer, /No service was started/);
|
||||
assert.match(relayPreparer, /RELEASE_URL="https:\/\/code\.forgejo\.org\//);
|
||||
assert.match(relayPreparer, /id -un\)" != "ubuntu"/);
|
||||
assert.match(relayPreparer, /forgejo-upstream-all\.bundle/);
|
||||
assert.match(relayPreparer, /guanghu-code-channel\.bundle/);
|
||||
assert.match(relayPreparer, /MANIFEST\.sha256/);
|
||||
assert.match(
|
||||
relayPreparer,
|
||||
/No service was started and no transfer to a domestic node was attempted/,
|
||||
);
|
||||
assert.equal(policy.product_id, "HLP-MOD-CODE-CHANNEL");
|
||||
assert.equal(policy.official_upstream.automatic_fetch, false);
|
||||
assert.equal(policy.official_upstream.automatic_merge, false);
|
||||
assert.equal(policy.official_upstream.automatic_build, false);
|
||||
assert.equal(policy.official_upstream.automatic_deploy, false);
|
||||
assert.equal(policy.official_upstream.push_enabled, false);
|
||||
assert.equal(policy.forgejo_builtin_update_checker.enabled, false);
|
||||
assert.equal(
|
||||
policy.guanghu_update_channel.manifest_url,
|
||||
"https://guanghulab.com/api/code-channel/updates/v1/manifest.json",
|
||||
);
|
||||
assert.equal(policy.guanghu_update_channel.require_signature, true);
|
||||
assert.equal(policy.guanghu_update_channel.require_artifact_sha256, true);
|
||||
assert.equal(policy.guanghu_update_channel.require_rollback_release, true);
|
||||
assert.equal(
|
||||
policy.guanghu_update_channel.fallback_when_unavailable,
|
||||
"MANUAL_RELEASE_ONLY",
|
||||
);
|
||||
assert.equal(
|
||||
policy.source_baseline.upstream_commit,
|
||||
"b3d7e4ac3cbccc220703097a51fa4c16bf302579",
|
||||
);
|
||||
assert.deepEqual(policy.offline_distribution.domestic_archive_nodes, [
|
||||
"JD-FD-PRIMARY",
|
||||
"AW-GZ-001",
|
||||
]);
|
||||
assert.deepEqual(policy.offline_distribution.runtime_nodes, [
|
||||
"JD-FD-PRIMARY",
|
||||
"AW-GZ-001",
|
||||
]);
|
||||
assert.equal(policy.offline_distribution.require_manifest_sha256, true);
|
||||
assert.equal(policy.offline_distribution.require_domestic_gpg_reverification, true);
|
||||
assert.equal(policy.offline_distribution.shared_runtime_data, false);
|
||||
assert.equal(policy.offline_distribution.automatic_transfer, false);
|
||||
|
||||
console.log("HoloLake Code Channel update boundary: PASS");
|
||||
87
server-tools/hololake-code-channel/prepare-release-on-sg.sh
Executable file
87
server-tools/hololake-code-channel/prepare-release-on-sg.sh
Executable file
|
|
@ -0,0 +1,87 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
readonly HLCC_VERSION="16.0.1"
|
||||
readonly RELAY_ROOT="/home/ubuntu/guanghu/release-relay/hlcc-v${HLCC_VERSION}"
|
||||
readonly BINARY_NAME="forgejo-${HLCC_VERSION}-linux-amd64"
|
||||
readonly RELEASE_URL="https://code.forgejo.org/forgejo/forgejo/releases/download/v${HLCC_VERSION}/${BINARY_NAME}"
|
||||
readonly RELEASE_KEY="EB114F5E6C0DC2BCDD183550A4B61A2DC5923710"
|
||||
readonly GPG_HOME="${RELAY_ROOT}/gpg"
|
||||
readonly UPSTREAM_REPOSITORY="/home/ubuntu/guanghu/upstream-parts/forgejo-official.git"
|
||||
readonly PRODUCT_REPOSITORY="/home/ubuntu/guanghu/products/guanghu-code-channel"
|
||||
|
||||
if [[ "$(id -un)" != "ubuntu" ]]; then
|
||||
echo "Refusing to prepare overseas release material as any user other than ubuntu." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
for command_name in curl git gpg sha256sum; do
|
||||
if ! command -v "${command_name}" >/dev/null 2>&1; then
|
||||
echo "Missing required command: ${command_name}" >&2
|
||||
exit 3
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ! -d "${UPSTREAM_REPOSITORY}" || ! -d "${PRODUCT_REPOSITORY}/.git" ]]; then
|
||||
echo "Required Forgejo upstream mirror or Guanghu product worktree is missing." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
umask 077
|
||||
mkdir -p "${RELAY_ROOT}" "${GPG_HOME}"
|
||||
chmod 700 "${GPG_HOME}"
|
||||
|
||||
curl --fail --location --proto '=https' --tlsv1.2 \
|
||||
--output "${RELAY_ROOT}/${BINARY_NAME}" \
|
||||
"${RELEASE_URL}"
|
||||
curl --fail --location --proto '=https' --tlsv1.2 \
|
||||
--output "${RELAY_ROOT}/${BINARY_NAME}.asc" \
|
||||
"${RELEASE_URL}.asc"
|
||||
|
||||
GNUPGHOME="${GPG_HOME}" gpg \
|
||||
--batch \
|
||||
--keyserver hkps://keys.openpgp.org \
|
||||
--recv-keys "${RELEASE_KEY}"
|
||||
|
||||
if ! GNUPGHOME="${GPG_HOME}" gpg \
|
||||
--batch \
|
||||
--with-colons \
|
||||
--fingerprint "${RELEASE_KEY}" \
|
||||
| grep -Fq "fpr:::::::::${RELEASE_KEY}:"; then
|
||||
echo "Forgejo release key fingerprint mismatch." >&2
|
||||
exit 4
|
||||
fi
|
||||
|
||||
GNUPGHOME="${GPG_HOME}" gpg \
|
||||
--batch \
|
||||
--verify "${RELAY_ROOT}/${BINARY_NAME}.asc" "${RELAY_ROOT}/${BINARY_NAME}"
|
||||
GNUPGHOME="${GPG_HOME}" gpg \
|
||||
--batch \
|
||||
--armor \
|
||||
--export "${RELEASE_KEY}" > "${RELAY_ROOT}/forgejo-release-key.asc"
|
||||
|
||||
git -C "${UPSTREAM_REPOSITORY}" bundle create \
|
||||
"${RELAY_ROOT}/forgejo-upstream-all.bundle" \
|
||||
--all
|
||||
git -C "${PRODUCT_REPOSITORY}" bundle create \
|
||||
"${RELAY_ROOT}/guanghu-code-channel.bundle" \
|
||||
guanghu/main
|
||||
git -C "${UPSTREAM_REPOSITORY}" bundle verify \
|
||||
"${RELAY_ROOT}/forgejo-upstream-all.bundle"
|
||||
git -C "${PRODUCT_REPOSITORY}" bundle verify \
|
||||
"${RELAY_ROOT}/guanghu-code-channel.bundle"
|
||||
|
||||
(
|
||||
cd "${RELAY_ROOT}"
|
||||
sha256sum \
|
||||
"${BINARY_NAME}" \
|
||||
"${BINARY_NAME}.asc" \
|
||||
forgejo-release-key.asc \
|
||||
forgejo-upstream-all.bundle \
|
||||
guanghu-code-channel.bundle \
|
||||
> MANIFEST.sha256
|
||||
)
|
||||
|
||||
echo "Verified HLCC release material prepared at ${RELAY_ROOT}"
|
||||
echo "The offline package contains full upstream and Guanghu product Git bundles."
|
||||
echo "No service was started and no transfer to a domestic node was attempted."
|
||||
44
server-tools/hololake-code-channel/start-candidate.sh
Executable file
44
server-tools/hololake-code-channel/start-candidate.sh
Executable file
|
|
@ -0,0 +1,44 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
readonly HLCC_ROOT="/var/lib/guanghu/code-channel/candidates/hlcc-v16.0.1"
|
||||
readonly HLCC_PID_FILE="${HLCC_ROOT}/hlcc.pid"
|
||||
readonly HLCC_LOG_FILE="${HLCC_ROOT}/logs/hlcc.log"
|
||||
|
||||
if [[ "$(id -un)" != "guanghu" ]]; then
|
||||
echo "Refusing to start a domestic isolated candidate as any user other than guanghu." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "Missing required command: curl" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if [[ -f "${HLCC_PID_FILE}" ]]; then
|
||||
readonly existing_pid="$(cat "${HLCC_PID_FILE}")"
|
||||
if kill -0 "${existing_pid}" 2>/dev/null; then
|
||||
echo "HLCC candidate is already running as PID ${existing_pid}."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
nohup "${HLCC_ROOT}/bin/hlcc" web \
|
||||
--work-path "${HLCC_ROOT}/data" \
|
||||
--config "${HLCC_ROOT}/config/app.ini" \
|
||||
>>"${HLCC_LOG_FILE}" 2>&1 &
|
||||
|
||||
readonly candidate_pid="$!"
|
||||
echo "${candidate_pid}" > "${HLCC_PID_FILE}"
|
||||
sleep 2
|
||||
|
||||
if ! kill -0 "${candidate_pid}" 2>/dev/null; then
|
||||
echo "HLCC candidate failed to remain running." >&2
|
||||
tail -n 80 "${HLCC_LOG_FILE}" >&2
|
||||
exit 5
|
||||
fi
|
||||
|
||||
curl --fail --silent --show-error \
|
||||
"http://127.0.0.1:3340/api/v1/version"
|
||||
echo
|
||||
echo "HLCC isolated candidate is running on loopback only."
|
||||
23
server-tools/hololake-code-channel/stop-candidate.sh
Executable file
23
server-tools/hololake-code-channel/stop-candidate.sh
Executable file
|
|
@ -0,0 +1,23 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
readonly HLCC_ROOT="/var/lib/guanghu/code-channel/candidates/hlcc-v16.0.1"
|
||||
readonly HLCC_PID_FILE="${HLCC_ROOT}/hlcc.pid"
|
||||
|
||||
if [[ "$(id -un)" != "guanghu" ]]; then
|
||||
echo "Refusing to stop a domestic isolated candidate as any user other than guanghu." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -f "${HLCC_PID_FILE}" ]]; then
|
||||
echo "HLCC candidate PID file does not exist."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
readonly candidate_pid="$(cat "${HLCC_PID_FILE}")"
|
||||
if kill -0 "${candidate_pid}" 2>/dev/null; then
|
||||
kill "${candidate_pid}"
|
||||
fi
|
||||
|
||||
rm "${HLCC_PID_FILE}"
|
||||
echo "HLCC isolated candidate stopped."
|
||||
47
server-tools/hololake-code-channel/update-policy.json
Normal file
47
server-tools/hololake-code-channel/update-policy.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"schema": "guanghu.code-channel.update-policy/v1",
|
||||
"product_id": "HLP-MOD-CODE-CHANNEL",
|
||||
"name_zh": "光湖代码频道",
|
||||
"name_en": "HoloLake Code Channel",
|
||||
"official_upstream": {
|
||||
"source": "https://code.forgejo.org/forgejo/forgejo.git",
|
||||
"role": "review-only-parts-source",
|
||||
"automatic_fetch": false,
|
||||
"automatic_merge": false,
|
||||
"automatic_build": false,
|
||||
"automatic_deploy": false,
|
||||
"push_enabled": false
|
||||
},
|
||||
"forgejo_builtin_update_checker": {
|
||||
"enabled": false,
|
||||
"forbidden_endpoint": "release.forgejo.org"
|
||||
},
|
||||
"guanghu_update_channel": {
|
||||
"manifest_url": "https://guanghulab.com/api/code-channel/updates/v1/manifest.json",
|
||||
"state": "PLANNED_NOT_LIVE",
|
||||
"require_signature": true,
|
||||
"require_artifact_sha256": true,
|
||||
"require_rollback_release": true,
|
||||
"fallback_when_unavailable": "MANUAL_RELEASE_ONLY"
|
||||
},
|
||||
"source_baseline": {
|
||||
"upstream_tag": "v16.0.1",
|
||||
"upstream_commit": "b3d7e4ac3cbccc220703097a51fa4c16bf302579",
|
||||
"guanghu_branch": "guanghu/main"
|
||||
},
|
||||
"offline_distribution": {
|
||||
"download_and_bundle_node": "BS-SG-003",
|
||||
"domestic_archive_nodes": [
|
||||
"JD-FD-PRIMARY",
|
||||
"AW-GZ-001"
|
||||
],
|
||||
"runtime_nodes": [
|
||||
"JD-FD-PRIMARY",
|
||||
"AW-GZ-001"
|
||||
],
|
||||
"require_manifest_sha256": true,
|
||||
"require_domestic_gpg_reverification": true,
|
||||
"shared_runtime_data": false,
|
||||
"automatic_transfer": false
|
||||
}
|
||||
}
|
||||
13
server-tools/jd-app-hub/README.md
Normal file
13
server-tools/jd-app-hub/README.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# JD application hub
|
||||
|
||||
The first web application running on `JD-FD-PRIMARY`. It binds only to
|
||||
`127.0.0.1:8088`; `BS-GZ-006` reaches it through a restricted persistent SSH
|
||||
tunnel and publishes it below `https://guanghulab.com/jd/`.
|
||||
|
||||
Version 2 keeps the application page on loopback port `8088` and adds one
|
||||
`/code/` reverse-proxy boundary to the isolated HoloLake Code Channel on
|
||||
`127.0.0.1:3340`. This reuses the existing permitopen-restricted Guangzhou
|
||||
application tunnel; it does not broaden the Forgejo tunnel key or expose a new
|
||||
JD port.
|
||||
|
||||
No server address, credential or private key belongs in this directory.
|
||||
56
server-tools/jd-app-hub/activate-staged-unit.py
Normal file
56
server-tools/jd-app-hub/activate-staged-unit.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Activate the already staged JD app hub unit without root shell access."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
|
||||
SERVICE = "jd-app-hub.service"
|
||||
STATUS_URL = "http://127.0.0.1:8088/api/status"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
result = subprocess.run(
|
||||
["/usr/bin/systemctl", "show", "--property=MainPID", "--value", SERVICE],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
pid = int(result.stdout.strip())
|
||||
if pid <= 1:
|
||||
raise RuntimeError("jd app hub has no active main process")
|
||||
|
||||
process_root = pathlib.Path("/proc") / str(pid)
|
||||
if process_root.stat().st_uid != os.getuid():
|
||||
raise RuntimeError("refusing to signal a process owned by another user")
|
||||
command = (process_root / "cmdline").read_bytes().replace(b"\0", b" ").decode("utf-8", "replace")
|
||||
if "/jd-app-hub/server.js" not in command:
|
||||
raise RuntimeError("refusing to signal an unexpected process")
|
||||
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
for _attempt in range(40):
|
||||
try:
|
||||
with urllib.request.urlopen(STATUS_URL, timeout=2) as response:
|
||||
payload = json.load(response)
|
||||
if (
|
||||
payload.get("ok") is True
|
||||
and payload.get("service") == "jd-app-hub"
|
||||
and payload.get("version") == "2.0.1"
|
||||
and payload.get("code_channel_proxy") == "loopback-only"
|
||||
):
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
raise RuntimeError("staged JD app hub did not become version 2.0.1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
14
server-tools/jd-app-hub/app.js
Normal file
14
server-tools/jd-app-hub/app.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
fetch("api/status", { cache: "no-store" })
|
||||
.then((response) => response.ok ? response.json() : Promise.reject(new Error("status unavailable")))
|
||||
.then((status) => {
|
||||
const live = document.querySelector("#live-status");
|
||||
const node = document.querySelector("#node-state");
|
||||
live.textContent = "运行正常";
|
||||
live.classList.add("ok");
|
||||
node.textContent = `${status.node_id} · 在线`;
|
||||
node.classList.add("ok");
|
||||
})
|
||||
.catch(() => {
|
||||
document.querySelector("#live-status").textContent = "状态暂不可用";
|
||||
document.querySelector("#node-state").textContent = "节点状态暂不可用";
|
||||
});
|
||||
19
server-tools/jd-app-hub/guanghu-jd-web-tunnel.service
Normal file
19
server-tools/jd-app-hub/guanghu-jd-web-tunnel.service
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[Unit]
|
||||
Description=Guanghu BS-GZ-006 to JD application hub tunnel
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/bin/ssh -NT -F /etc/guanghu/jd-web-tunnel-ssh-config jd-web-target
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=read-only
|
||||
ProtectSystem=strict
|
||||
ReadOnlyPaths=/etc/guanghu/jd-web-tunnel-ssh-config /etc/guanghu/secrets/ssh/bs_gz_006_to_jd_web_proxy /root/.ssh/known_hosts
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
26
server-tools/jd-app-hub/hlcc-jd-app-hub-activator.service
Normal file
26
server-tools/jd-app-hub/hlcc-jd-app-hub-activator.service
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
[Unit]
|
||||
Description=Activate the staged HoloLake Code Channel JD app hub
|
||||
After=jd-app-hub.service
|
||||
Requires=jd-app-hub.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=guanghu
|
||||
Group=guanghu
|
||||
ExecStart=/usr/bin/python3 __RELEASE_ROOT__/server-tools/jd-app-hub/activate-staged-unit.py
|
||||
RemainAfterExit=true
|
||||
TimeoutStartSec=60
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
ReadOnlyPaths=__RELEASE_ROOT__
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
31
server-tools/jd-app-hub/index.html
Normal file
31
server-tools/jd-app-hub/index.html
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>京东云主节点 · 光湖</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<nav><a href="/">← 返回光湖国内入口</a><span id="node-state">正在读取节点状态</span></nav>
|
||||
<header>
|
||||
<p class="eyebrow">JD-FD-PRIMARY · BEIJING</p>
|
||||
<h1>京东云主节点</h1>
|
||||
<p>这里是光湖第五域的国内运行节点。广州负责备案前门,这里负责应用、计算、迁移与服务器调度。</p>
|
||||
</header>
|
||||
<section class="status" aria-label="节点状态">
|
||||
<div><span>节点</span><strong>JD-FD-PRIMARY</strong></div>
|
||||
<div><span>角色</span><strong>第五域国内主节点</strong></div>
|
||||
<div><span>连接</span><strong id="live-status">检查中</strong></div>
|
||||
</section>
|
||||
<section class="cards">
|
||||
<a href="https://guanghubingshuo.com/" target="_top"><small>WORLD</small><h2>光湖世界</h2><p>进入光湖世界主入口与第五域路由。</p><b>↗</b></a>
|
||||
<a href="https://guanghulab.com/fifth-domain/bingshuo/fifth-domain" target="_top"><small>REPO-001</small><h2>第五域国内主仓</h2><p>当前系统结构、人格路径、编号地图和部署回执事实源。</p><b>↗</b></a>
|
||||
<a href="https://guanghulab.com/api/ai/" target="_top"><small>FD-REPO-MAP-001</small><h2>AI 编号检索</h2><p>通过 REPO 编号和中文关键词寻找国内主路径。</p><b>↗</b></a>
|
||||
<div class="coming"><small>NEXT</small><h2>Tolaria 服务</h2><p>桌面软件相关服务迁移完成后从这里接入。</p><b>建设中</b></div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
33
server-tools/jd-app-hub/jd-app-hub.service
Normal file
33
server-tools/jd-app-hub/jd-app-hub.service
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
[Unit]
|
||||
Description=Guanghu JD Application Hub
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=guanghu
|
||||
Group=guanghu
|
||||
WorkingDirectory=__RELEASE_ROOT__/server-tools/jd-app-hub
|
||||
Environment=NODE_ENV=production
|
||||
Environment=HUB_HOST=127.0.0.1
|
||||
Environment=HUB_PORT=8088
|
||||
Environment=GUANGHU_NODE_ID=JD-FD-PRIMARY
|
||||
Environment=CODE_CHANNEL_HOST=127.0.0.1
|
||||
Environment=CODE_CHANNEL_PORT=3340
|
||||
ExecStart=/usr/bin/node __RELEASE_ROOT__/server-tools/jd-app-hub/server.js
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
ReadOnlyPaths=__RELEASE_ROOT__
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
124
server-tools/jd-app-hub/server.js
Normal file
124
server-tools/jd-app-hub/server.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
|
||||
const ROOT = __dirname;
|
||||
const TYPES = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8" };
|
||||
const APP_VERSION = "2.0.1";
|
||||
|
||||
function json(response, status, value) {
|
||||
response.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
|
||||
response.end(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function proxyCodeChannel(request, response, options = {}) {
|
||||
const internalPath = request.url.replace(/^\/code(?=\/|$)/, "") || "/";
|
||||
const upstream = http.request({
|
||||
host: options.codeChannelHost || process.env.CODE_CHANNEL_HOST || "127.0.0.1",
|
||||
port: Number(options.codeChannelPort || process.env.CODE_CHANNEL_PORT || 3340),
|
||||
method: request.method,
|
||||
path: internalPath,
|
||||
headers: {
|
||||
...request.headers,
|
||||
host: request.headers.host || "guanghulab.com",
|
||||
"x-forwarded-host": request.headers["x-forwarded-host"] || request.headers.host || "guanghulab.com",
|
||||
"x-forwarded-proto": request.headers["x-forwarded-proto"] || "https"
|
||||
},
|
||||
timeout: 300000
|
||||
}, upstreamResponse => {
|
||||
response.writeHead(upstreamResponse.statusCode || 502, upstreamResponse.headers);
|
||||
upstreamResponse.pipe(response);
|
||||
});
|
||||
upstream.on("timeout", () => upstream.destroy(new Error("upstream_timeout")));
|
||||
upstream.on("error", () => {
|
||||
if (!response.headersSent) return json(response, 502, { ok: false, error: "code_channel_unavailable" });
|
||||
response.destroy();
|
||||
});
|
||||
request.pipe(upstream);
|
||||
}
|
||||
|
||||
function candidateFailureCategory(message) {
|
||||
const value = String(message || "").toLowerCase();
|
||||
if (/urlopen|timed out|http error|temporary failure|name or service/.test(value)) return "relay_download_failed";
|
||||
if (/no such file.*gpg|gpg.*no such file/.test(value)) return "signature_verifier_unavailable";
|
||||
if (/fingerprint|signature|gpg/.test(value)) return "signature_verification_failed";
|
||||
if (/candidate exited|candidate stopped/.test(value)) return "candidate_process_exited";
|
||||
if (/readiness timeout/.test(value)) return "candidate_readiness_timeout";
|
||||
return value ? "bootstrap_failed" : "";
|
||||
}
|
||||
|
||||
function readCandidateStatus(response, options = {}) {
|
||||
const request = http.get({
|
||||
host: options.codeChannelHealthHost || "127.0.0.1",
|
||||
port: Number(options.codeChannelHealthPort || 3341),
|
||||
path: "/health",
|
||||
timeout: 3000
|
||||
}, upstreamResponse => {
|
||||
let raw = "";
|
||||
upstreamResponse.setEncoding("utf8");
|
||||
upstreamResponse.on("data", chunk => {
|
||||
raw += chunk;
|
||||
if (raw.length > 8192) upstreamResponse.destroy(new Error("status_too_large"));
|
||||
});
|
||||
upstreamResponse.on("end", () => {
|
||||
try {
|
||||
const source = JSON.parse(raw);
|
||||
return json(response, 200, {
|
||||
ok: source.ok === true,
|
||||
version: String(source.version || ""),
|
||||
code: String(source.code || ""),
|
||||
ready: source.ready === true,
|
||||
stage: String(source.stage || "unknown"),
|
||||
failure_category: source.ok === true ? "" : candidateFailureCategory(source.error)
|
||||
});
|
||||
} catch {
|
||||
return json(response, 502, { ok: false, ready: false, error: "invalid_candidate_status" });
|
||||
}
|
||||
});
|
||||
});
|
||||
request.on("timeout", () => request.destroy(new Error("status_timeout")));
|
||||
request.on("error", () => json(response, 502, { ok: false, ready: false, error: "candidate_status_unavailable" }));
|
||||
}
|
||||
|
||||
function createApp(options = {}) {
|
||||
return http.createServer((request, response) => {
|
||||
const url = new URL(request.url, "http://localhost");
|
||||
if (url.pathname === "/code") {
|
||||
response.writeHead(308, { Location: "/code/", "Cache-Control": "no-store" });
|
||||
return response.end();
|
||||
}
|
||||
if (url.pathname.startsWith("/code/")) return proxyCodeChannel(request, response, options);
|
||||
if (request.method !== "GET" && request.method !== "HEAD") return json(response, 405, { ok: false, error: "method_not_allowed" });
|
||||
if (url.pathname === "/api/code-channel-status") return readCandidateStatus(response, options);
|
||||
if (url.pathname === "/api/status") {
|
||||
return json(response, 200, {
|
||||
ok: true,
|
||||
node_id: process.env.GUANGHU_NODE_ID || "JD-FD-PRIMARY",
|
||||
role: "fifth-domain-primary",
|
||||
service: "jd-app-hub",
|
||||
version: APP_VERSION,
|
||||
code_channel_proxy: "loopback-only",
|
||||
uptime_seconds: Math.floor(process.uptime()),
|
||||
load_1m: Number(os.loadavg()[0].toFixed(2)),
|
||||
memory_used_percent: Number((((os.totalmem() - os.freemem()) / os.totalmem()) * 100).toFixed(1)),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
const relative = url.pathname === "/" ? "index.html" : url.pathname.replace(/^\/+/, "");
|
||||
if (!/^(index\.html|styles\.css|app\.js)$/.test(relative)) return json(response, 404, { ok: false, error: "not_found" });
|
||||
const file = path.join(ROOT, relative);
|
||||
response.writeHead(200, { "Content-Type": TYPES[path.extname(file)] || "application/octet-stream", "Cache-Control": relative === "index.html" ? "no-cache" : "public, max-age=3600" });
|
||||
fs.createReadStream(file).pipe(response);
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const host = process.env.HUB_HOST || "127.0.0.1";
|
||||
const port = Number(process.env.HUB_PORT || 8088);
|
||||
createApp().listen(port, host, () => console.log(`JD app hub listening on ${host}:${port}`));
|
||||
}
|
||||
|
||||
module.exports = { createApp };
|
||||
26
server-tools/jd-app-hub/styles.css
Normal file
26
server-tools/jd-app-hub/styles.css
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
:root { color-scheme: dark; --bg:#081112; --panel:#102526; --line:rgba(174,233,216,.16); --text:#eff9f6; --muted:#9cafaa; --lake:#78dfc4; --warm:#efbd79; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; min-height:100vh; color:var(--text); background:radial-gradient(circle at 75% 0,rgba(62,154,136,.18),transparent 32rem),var(--bg); font-family:Inter,"PingFang SC",system-ui,sans-serif; }
|
||||
main { width:min(1080px,calc(100% - 36px)); margin:auto; padding-bottom:70px; }
|
||||
nav { height:84px; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--line); color:var(--muted); font-size:13px; }
|
||||
nav a { color:var(--text); text-decoration:none; }
|
||||
header { padding:90px 0 58px; max-width:780px; }
|
||||
.eyebrow { color:var(--lake); font:800 11px/1 ui-monospace,monospace; letter-spacing:.18em; }
|
||||
h1 { margin:20px 0; font-size:clamp(48px,8vw,86px); line-height:1; letter-spacing:-.055em; }
|
||||
header>p:last-child { max-width:700px; color:var(--muted); font-size:18px; line-height:1.8; }
|
||||
.status { display:grid; grid-template-columns:repeat(3,1fr); border:1px solid var(--line); border-radius:18px; overflow:hidden; background:rgba(15,35,36,.72); }
|
||||
.status div { padding:22px; display:flex; flex-direction:column; gap:8px; border-right:1px solid var(--line); }
|
||||
.status div:last-child { border:0; }
|
||||
.status span { color:var(--muted); font-size:12px; }
|
||||
.status strong { font-size:15px; }
|
||||
.cards { display:grid; grid-template-columns:repeat(2,1fr); gap:16px; padding-top:30px; }
|
||||
.cards>a,.cards>div { position:relative; min-height:210px; padding:28px; border:1px solid var(--line); border-radius:18px; background:var(--panel); text-decoration:none; color:var(--text); }
|
||||
.cards>a:hover { border-color:rgba(120,223,196,.48); transform:translateY(-3px); }
|
||||
.cards>a { transition:.2s ease; }
|
||||
.cards small { color:var(--lake); font-weight:800; letter-spacing:.16em; }
|
||||
.cards h2 { margin:34px 0 10px; font-size:25px; }
|
||||
.cards p { margin:0; color:var(--muted); line-height:1.7; }
|
||||
.cards b { position:absolute; right:25px; top:25px; color:var(--warm); font-size:13px; }
|
||||
.coming { opacity:.62; }
|
||||
#live-status.ok,#node-state.ok { color:var(--lake); }
|
||||
@media(max-width:700px){ nav{height:70px}.status,.cards{grid-template-columns:1fr}.status div{border-right:0;border-bottom:1px solid var(--line)}header{padding-top:65px}.cards>a,.cards>div{min-height:190px} }
|
||||
21
server-tools/jd-app-hub/test/activator.test.js
Normal file
21
server-tools/jd-app-hub/test/activator.test.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const root = path.join(__dirname, "..");
|
||||
const script = fs.readFileSync(path.join(root, "activate-staged-unit.py"), "utf8");
|
||||
const unit = fs.readFileSync(path.join(root, "hlcc-jd-app-hub-activator.service"), "utf8");
|
||||
|
||||
assert.match(script, /systemctl", "show", "--property=MainPID"/);
|
||||
assert.match(script, /process_root\.stat\(\)\.st_uid != os\.getuid\(\)/);
|
||||
assert.match(script, /"\/jd-app-hub\/server\.js" not in command/);
|
||||
assert.match(script, /os\.kill\(pid, signal\.SIGTERM\)/);
|
||||
assert.doesNotMatch(script, /shell=True|systemctl", "(?:restart|stop|start)"/);
|
||||
assert.match(unit, /^User=guanghu$/m);
|
||||
assert.match(unit, /^NoNewPrivileges=true$/m);
|
||||
assert.match(unit, /^ProtectSystem=strict$/m);
|
||||
assert.match(unit, /^ReadOnlyPaths=__RELEASE_ROOT__$/m);
|
||||
|
||||
console.log("JD app hub staged-unit activator: PASS");
|
||||
130
server-tools/jd-app-hub/test/server.test.js
Normal file
130
server-tools/jd-app-hub/test/server.test.js
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const http = require("node:http");
|
||||
const { createApp } = require("../server");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
test("JD hub exposes a minimal non-secret status response", async (t) => {
|
||||
const server = createApp().listen(0, "127.0.0.1");
|
||||
t.after(() => server.close());
|
||||
await new Promise((resolve) => server.once("listening", resolve));
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/status`);
|
||||
assert.equal(response.status, 200);
|
||||
const body = await response.json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.node_id, "JD-FD-PRIMARY");
|
||||
assert.equal("ip" in body, false);
|
||||
assert.equal("token" in body, false);
|
||||
});
|
||||
|
||||
test("JD hub serves its application page", async (t) => {
|
||||
const server = createApp().listen(0, "127.0.0.1");
|
||||
t.after(() => server.close());
|
||||
await new Promise((resolve) => server.once("listening", resolve));
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/`);
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(await response.text(), /京东云主节点/);
|
||||
});
|
||||
|
||||
test("JD hub strips its public route before proxying to the code channel", async (t) => {
|
||||
const upstream = http.createServer((request, response) => {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({
|
||||
path: request.url,
|
||||
method: request.method,
|
||||
host: request.headers.host,
|
||||
proto: request.headers["x-forwarded-proto"]
|
||||
}));
|
||||
});
|
||||
upstream.listen(0, "127.0.0.1");
|
||||
t.after(() => upstream.close());
|
||||
await new Promise((resolve) => upstream.once("listening", resolve));
|
||||
|
||||
const server = createApp({ codeChannelPort: upstream.address().port }).listen(0, "127.0.0.1");
|
||||
t.after(() => server.close());
|
||||
await new Promise((resolve) => server.once("listening", resolve));
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
const request = http.get({
|
||||
host: "127.0.0.1",
|
||||
port: server.address().port,
|
||||
path: "/code/api/v1/version",
|
||||
headers: { host: "guanghulab.com", "x-forwarded-proto": "https" }
|
||||
}, resolve);
|
||||
request.on("error", reject);
|
||||
});
|
||||
const chunks = [];
|
||||
for await (const chunk of response) chunks.push(chunk);
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.deepEqual(JSON.parse(Buffer.concat(chunks).toString("utf8")), {
|
||||
path: "/api/v1/version",
|
||||
method: "GET",
|
||||
host: "guanghulab.com",
|
||||
proto: "https"
|
||||
});
|
||||
});
|
||||
|
||||
test("JD hub preserves nested code-channel paths and queries without duplicating the public prefix", async (t) => {
|
||||
const upstream = http.createServer((request, response) => {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ path: request.url }));
|
||||
});
|
||||
upstream.listen(0, "127.0.0.1");
|
||||
t.after(() => upstream.close());
|
||||
await new Promise((resolve) => upstream.once("listening", resolve));
|
||||
|
||||
const server = createApp({ codeChannelPort: upstream.address().port }).listen(0, "127.0.0.1");
|
||||
t.after(() => server.close());
|
||||
await new Promise((resolve) => server.once("listening", resolve));
|
||||
|
||||
const nested = await fetch(`http://127.0.0.1:${server.address().port}/code/user/login?redirect_to=%2Fjd%2Fcode%2F`);
|
||||
assert.equal(nested.status, 200);
|
||||
assert.deepEqual(await nested.json(), {
|
||||
path: "/user/login?redirect_to=%2Fjd%2Fcode%2F"
|
||||
});
|
||||
|
||||
const root = await fetch(`http://127.0.0.1:${server.address().port}/code/`);
|
||||
assert.equal(root.status, 200);
|
||||
assert.deepEqual(await root.json(), { path: "/" });
|
||||
});
|
||||
|
||||
test("JD hub exposes only a categorized candidate failure", async (t) => {
|
||||
const upstream = http.createServer((_request, response) => {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({
|
||||
ok: false,
|
||||
version: "16.0.1",
|
||||
code: "HLCC-JD-CANDIDATE-01",
|
||||
ready: false,
|
||||
stage: "failed",
|
||||
error: "[Errno 2] No such file or directory: gpg /private/server/path"
|
||||
}));
|
||||
});
|
||||
upstream.listen(0, "127.0.0.1");
|
||||
t.after(() => upstream.close());
|
||||
await new Promise((resolve) => upstream.once("listening", resolve));
|
||||
|
||||
const server = createApp({ codeChannelHealthPort: upstream.address().port }).listen(0, "127.0.0.1");
|
||||
t.after(() => server.close());
|
||||
await new Promise((resolve) => server.once("listening", resolve));
|
||||
const response = await fetch(`http://127.0.0.1:${server.address().port}/api/code-channel-status`);
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), {
|
||||
ok: false,
|
||||
version: "16.0.1",
|
||||
code: "HLCC-JD-CANDIDATE-01",
|
||||
ready: false,
|
||||
stage: "failed",
|
||||
failure_category: "signature_verifier_unavailable"
|
||||
});
|
||||
});
|
||||
|
||||
test("tunnel service keeps private addresses outside the repository", () => {
|
||||
const unit = fs.readFileSync(path.join(__dirname, "..", "guanghu-jd-web-tunnel.service"), "utf8");
|
||||
assert.match(unit, /\/etc\/guanghu\/jd-web-tunnel-ssh-config/);
|
||||
assert.doesNotMatch(unit, /(?:\d{1,3}\.){3}\d{1,3}/);
|
||||
});
|
||||
108
server-tools/jd-disaster-recovery/INDEX.hdlp
Normal file
108
server-tools/jd-disaster-recovery/INDEX.hdlp
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# JD-DR-001 · 京东主控与六节点灾备项目 · 认知入口
|
||||
|
||||
> **状态**:`ACTIVE · SIX_OF_SIX_RUNTIME_VERIFIED_20260720`
|
||||
>
|
||||
> **项目根**:`server-tools/jd-disaster-recovery/`
|
||||
>
|
||||
> **主控节点**:`JD-FD-PRIMARY`
|
||||
>
|
||||
> **节点地图**:`FD-NODE-MAP-001`
|
||||
>
|
||||
> **人格认知入口**:`ZY-SERVER-COGNITION-004`
|
||||
|
||||
## 0 · 一句话认知
|
||||
|
||||
京东是日常主控,六台个人节点是服务器端灾备分控。只要京东实例开机、网络可达,
|
||||
任一分控节点都能在新工单批准后,通过各自独立的受限恢复身份调用固定修复动作;
|
||||
冰朔不需要学习终端登录,也不需要在个人电脑保存密码或私钥。
|
||||
|
||||
## 1 · 项目关系
|
||||
|
||||
```text
|
||||
冰朔意图 / 人格体判断
|
||||
→ 小湖灯限定工单(允许本次做什么)
|
||||
→ 发起节点独立灾备密钥(服务器之间怎样进入)
|
||||
→ 京东 forced-command(只能执行登记恢复动作)
|
||||
→ 追加审计日志与部署回执(实际做了什么)
|
||||
```
|
||||
|
||||
工单、密钥、执行日志是三层,不得互相替代。
|
||||
|
||||
## 2 · 六个恢复入口
|
||||
|
||||
`BS-GZ-006`、`BS-SG-001`、`BS-SG-002`、`BS-SG-003`、`BS-SH-005`、`ZY-SG-006`。
|
||||
|
||||
节点身份、角色和导航图从以下真实文件解析:
|
||||
|
||||
```text
|
||||
routing/server-node-map.json
|
||||
→ deployment/navigation-maps/<NODE_ID>.json
|
||||
```
|
||||
|
||||
不得从聊天记录、旧 IP、旧 token 或本地密钥猜节点。
|
||||
|
||||
## 3 · 两级故障边界
|
||||
|
||||
| 故障 | 人格体恢复方式 | 冰朔需要做什么 |
|
||||
|---|---|---|
|
||||
| 京东开机、网络可达;授权服务、导航图或普通登录损坏 | 任一分控节点发起新工单,调用受限恢复动作 | 看懂并确认工单;不需要终端登录 |
|
||||
| 京东关机、断网、系统盘损坏或云厂商停机 | 云厂商控制台开机、修网或从加密备份重建,随后轮换恢复密钥 | 保证实例开机、账号不欠费、云侧网络未关闭 |
|
||||
|
||||
六节点灾备不是云厂商控制面的替代品,也不是六份普通 root shell。
|
||||
|
||||
## 4 · 固定恢复动作
|
||||
|
||||
权威策略:`recovery-policy.json`。
|
||||
|
||||
当前设计动作包括:
|
||||
|
||||
- `health-check`
|
||||
- `restore-authz`
|
||||
- `restore-navigation-map`
|
||||
- `rollback-last-deploy`
|
||||
- `restore-owner-password-login`
|
||||
|
||||
具体实现:`jd-recovery-entry.sh` → `jd-recovery-runner`。普通恢复 shell 必须拒绝。
|
||||
|
||||
## 5 · 部署、验证与回执
|
||||
|
||||
```text
|
||||
bootstrap-six-node-recovery.sh
|
||||
→ 一节点一钥匙
|
||||
→ 京东 authorized_keys 来源限制 + restrict + forced-command
|
||||
→ 六节点反向 health-check
|
||||
→ backup-control-plane.sh
|
||||
→ deployment/receipts/ICE-SIX-NODE-JD-DISASTER-RECOVERY-20260720.json
|
||||
```
|
||||
|
||||
2026-07-20 运行态核验为六台 `active / active`。上海节点另经云厂商自动化助手确认:
|
||||
灾备配置存在、服务端口在监听、到京东反向健康检查成功。验证不保存真实地址或凭据。
|
||||
|
||||
## 6 · 当前已知缺口
|
||||
|
||||
上海灾备通道本身已通过,但小湖灯中央授权服务读取 `BS-SH-005` 实时导航图时曾返回失败。
|
||||
这属于中央导航图发布 / 同步缺口,不等于上海灾备失效。后续应通过本项目的
|
||||
`restore-navigation-map` 登记动作修复,并另留运行回执;在回执完成前不得写成中央工单路由已全通。
|
||||
|
||||
## 7 · 真实文件索引
|
||||
|
||||
| 目的 | 文件 |
|
||||
|---|---|
|
||||
| 操作说明 | `README.md` |
|
||||
| 灾备策略 | `recovery-policy.json` |
|
||||
| 六节点引导 | `bootstrap-six-node-recovery.sh` |
|
||||
| 京东固定入口 | `jd-recovery-entry.sh` |
|
||||
| 恢复动作执行器 | `jd-recovery-runner` |
|
||||
| 控制面备份 | `backup-control-plane.sh` |
|
||||
| 策略测试 | `test/policy.test.js` |
|
||||
| 六节点灾备回执 | `../../deployment/receipts/ICE-SIX-NODE-JD-DISASTER-RECOVERY-20260720.json` |
|
||||
| 节点总地图 | `../../routing/server-node-map.json` |
|
||||
| 铸渊服务器认知线 | `../../eternal-lake-heart/heartbeat-core/zhuyuan-persona-system/ZY-SERVER-COGNITION-004-JD-SIX-NODE-RECOVERY-20260720.hdlp` |
|
||||
|
||||
## 8 · 维护规则
|
||||
|
||||
1. 代码、服务器运行态、部署回执三者必须分开验证。
|
||||
2. 新增节点时先建导航图,再生成独立密钥,最后做反向健康检查。
|
||||
3. 不在仓库保存地址、密码、token、私钥、验证码或邮件批准链接。
|
||||
4. 任何“已修复”必须写清验证时间、验证入口、结果和仍存在的缺口。
|
||||
5. 冰朔的日常责任只有保持京东实例开机、账号正常、云侧网络可达;登录与修复路径由人格系统和灾备项目承担。
|
||||
44
server-tools/jd-disaster-recovery/README.md
Normal file
44
server-tools/jd-disaster-recovery/README.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# 京东主控与六节点灾备
|
||||
|
||||
完整项目认知、文件关系、当前运行态与维护规则先读:`INDEX.hdlp`(编号 `JD-DR-001`)。
|
||||
|
||||
京东 `JD-FD-PRIMARY` 是日常主控,不是唯一恢复入口。六台个人节点分别持有独立、服务器端保存的恢复身份;Mac、浏览器和个人本地磁盘不保存恢复私钥。
|
||||
|
||||
## 两级故障
|
||||
|
||||
1. 京东授权服务损坏但 SSH 仍在线:从任一分控节点发起节点本地邮件授权,批准后使用受限恢复密钥调用京东固定恢复动作。
|
||||
2. 京东主机完全离线:从至少两个不同云厂商保存的加密控制面备份重建京东,再轮换全部恢复密钥。SSH 反向恢复不能替代云厂商控制台重建。
|
||||
|
||||
## 强制边界
|
||||
|
||||
- 每个节点一把独立密钥,禁止六节点共用密钥。
|
||||
- 京东 `authorized_keys` 对恢复密钥使用来源地址限制、`restrict` 和 forced-command。
|
||||
- forced-command 仅允许 `health-check`、`restore-authz`、`restore-navigation-map`、`rollback-last-deploy`。
|
||||
- 每次恢复必须先由发起节点向预登记邮箱发送工单;批准只产生一次性、短时恢复收据。
|
||||
- 企业 `AW-GZ-001` 不属于六节点灾备组,不持有京东恢复私钥。
|
||||
- 语言协议、零点原核频道和公开导航互通不扩大服务器权限。
|
||||
- 所有恢复事件写入 root 所有的追加日志,并由独立审计密钥签名。
|
||||
|
||||
## 六个分控节点
|
||||
|
||||
`BS-GZ-006`、`BS-SG-001`、`BS-SG-002`、`BS-SG-003`、`BS-SH-005`、`ZY-SG-006`。
|
||||
|
||||
部署前必须逐台确认:当前导航图、节点身份、固定出口地址、邮件发送能力、恢复公钥指纹和加密备份状态。仓库收据不能替代现场检查。
|
||||
|
||||
## 六节点引导
|
||||
|
||||
从云控制台确认京东实例自己的公网 IPv4,再作为临时环境变量执行引导。不得把登录来源地址或云元数据错误正文当作实例地址:
|
||||
|
||||
```bash
|
||||
JD_PUBLIC_IP='<verified-console-address>' \
|
||||
RECOVERY_PACKAGE=/tmp/jd-recovery-controller.tgz \
|
||||
bash bootstrap-six-node-recovery.sh
|
||||
```
|
||||
|
||||
地址只写入服务器端 SSH 配置,不写入仓库。脚本验证 IPv4 格式,并以远端固定命令的退出码作为闭环成功依据。
|
||||
|
||||
## 现场状态(2026-07-20)
|
||||
|
||||
六个分控节点的反向健康检查均已验证为 `active / active`。上海节点另经云厂商自动化助手确认灾备配置存在、服务端口监听、反向检查成功。无地址或凭据写入仓库。
|
||||
|
||||
已知缺口:小湖灯中央授权服务曾无法读取 `BS-SH-005` 的实时导航图。该问题与上海灾备通道本身分离,后续须使用 `restore-navigation-map` 固定动作修复并新增回执。
|
||||
17
server-tools/jd-disaster-recovery/backup-control-plane.sh
Normal file
17
server-tools/jd-disaster-recovery/backup-control-plane.sh
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
out_dir=${1:-/var/backups/guanghu/jd-control-plane}
|
||||
stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
install -d -m 0700 "$out_dir"
|
||||
|
||||
tar -C / -czf "$out_dir/control-plane-${stamp}.tgz" \
|
||||
opt/guanghu/lake-lamp-authz \
|
||||
etc/guanghu/navigation-maps \
|
||||
etc/systemd/system/lake-lamp-authz.service \
|
||||
etc/systemd/system/lake-lamp-action-broker.service \
|
||||
etc/systemd/system/lake-lamp-owner-access.service
|
||||
sha256sum "$out_dir/control-plane-${stamp}.tgz" > "$out_dir/control-plane-${stamp}.sha256"
|
||||
|
||||
echo "CONTROL_PLANE_BACKUP_READY=${stamp}"
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
ssh_config=${SSH_CONFIG:-/etc/guanghu/nodes/ssh/config}
|
||||
package=${RECOVERY_PACKAGE:-/tmp/jd-recovery-controller.tgz}
|
||||
jd_ip=${JD_PUBLIC_IP:-}
|
||||
[[ -f "$ssh_config" && -f "$package" && "$jd_ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || { echo "recovery bootstrap prerequisites missing or public address invalid" >&2; exit 70; }
|
||||
|
||||
install -d -m 0700 /var/backups/guanghu/jd-recovery-bootstrap /var/lib/guanghu/recovery-public-keys
|
||||
stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
cp -a /root/.ssh/authorized_keys "/var/backups/guanghu/jd-recovery-bootstrap/authorized_keys.$stamp"
|
||||
|
||||
nodes=(
|
||||
"BS-GZ-006:bs-gz-006"
|
||||
"BS-SG-001:bs-sg-001"
|
||||
"BS-SG-002:bs-sg-002"
|
||||
"BS-SG-003:bs-sg-003"
|
||||
"BS-SH-005:bs-sh-005"
|
||||
"ZY-SG-006:zy-sg-006"
|
||||
)
|
||||
|
||||
for binding in "${nodes[@]}"; do
|
||||
node_id=${binding%%:*}
|
||||
alias_name=${binding#*:}
|
||||
source_ip=$(ssh -G -F "$ssh_config" "$alias_name" | awk '$1=="hostname"{print $2; exit}')
|
||||
[[ -n "$source_ip" ]] || { echo "$node_id source address unavailable" >&2; exit 71; }
|
||||
|
||||
cat "$package" | ssh -F "$ssh_config" -o BatchMode=yes -o ConnectTimeout=10 "$alias_name" \
|
||||
"rm -rf /tmp/jd-recovery-package && install -d -m 0700 /tmp/jd-recovery-package && tar -xzf - -C /tmp/jd-recovery-package && install -d -m 0755 /opt/guanghu/jd-disaster-recovery && cp -a /tmp/jd-recovery-package/. /opt/guanghu/jd-disaster-recovery/ && chmod 0755 /opt/guanghu/jd-disaster-recovery/*.sh /opt/guanghu/jd-disaster-recovery/jd-recovery-runner"
|
||||
|
||||
ssh -F "$ssh_config" -o BatchMode=yes "$alias_name" \
|
||||
"install -d -m 0700 /etc/guanghu/secrets/jd-recovery; test -f /etc/guanghu/secrets/jd-recovery/to-jd-ed25519 || ssh-keygen -q -t ed25519 -N '' -C '${node_id}-recovery-to-JD-FD-PRIMARY' -f /etc/guanghu/secrets/jd-recovery/to-jd-ed25519; chmod 0600 /etc/guanghu/secrets/jd-recovery/to-jd-ed25519; cat /etc/guanghu/secrets/jd-recovery/to-jd-ed25519.pub" \
|
||||
> "/var/lib/guanghu/recovery-public-keys/${node_id}.pub"
|
||||
|
||||
pub=$(cat "/var/lib/guanghu/recovery-public-keys/${node_id}.pub")
|
||||
grep -qF "${node_id}-recovery-to-JD-FD-PRIMARY" /root/.ssh/authorized_keys || \
|
||||
printf 'from="%s",restrict,command="/usr/local/libexec/guanghu/jd-recovery-entry" %s\n' "$source_ip" "$pub" >> /root/.ssh/authorized_keys
|
||||
|
||||
ssh -F "$ssh_config" -o BatchMode=yes "$alias_name" \
|
||||
"ssh-keyscan -H -T 5 '$jd_ip' > /etc/guanghu/secrets/jd-recovery/known_hosts 2>/dev/null; chmod 0600 /etc/guanghu/secrets/jd-recovery/known_hosts; printf '%s\\n' 'Host jd-recovery' ' HostName $jd_ip' ' User root' ' IdentityFile /etc/guanghu/secrets/jd-recovery/to-jd-ed25519' ' IdentitiesOnly yes' ' PasswordAuthentication no' ' KbdInteractiveAuthentication no' ' StrictHostKeyChecking yes' ' UserKnownHostsFile /etc/guanghu/secrets/jd-recovery/known_hosts' > /etc/guanghu/jd-recovery-ssh-config; chmod 0600 /etc/guanghu/jd-recovery-ssh-config"
|
||||
done
|
||||
|
||||
chmod 0600 /root/.ssh/authorized_keys /var/lib/guanghu/recovery-public-keys/*.pub
|
||||
|
||||
for binding in "${nodes[@]}"; do
|
||||
node_id=${binding%%:*}
|
||||
alias_name=${binding#*:}
|
||||
ssh -F "$ssh_config" -o BatchMode=yes "$alias_name" \
|
||||
"ssh -F /etc/guanghu/jd-recovery-ssh-config -o BatchMode=yes -o ConnectTimeout=10 jd-recovery health-check >/dev/null" || \
|
||||
{ echo "$node_id reverse health check failed" >&2; exit 72; }
|
||||
echo "$node_id=RECOVERY_READY"
|
||||
done
|
||||
16
server-tools/jd-disaster-recovery/jd-recovery-entry.sh
Normal file
16
server-tools/jd-disaster-recovery/jd-recovery-entry.sh
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
case "${SSH_ORIGINAL_COMMAND:-}" in
|
||||
health-check)
|
||||
systemctl is-active lake-lamp-authz.service
|
||||
systemctl is-active lake-lamp-action-broker.service
|
||||
;;
|
||||
restore-authz|install-owner-access|restore-navigation-map|rollback-last-deploy)
|
||||
exec /usr/local/libexec/guanghu/jd-recovery-runner "${SSH_ORIGINAL_COMMAND}"
|
||||
;;
|
||||
*)
|
||||
echo "recovery action denied" >&2
|
||||
exit 126
|
||||
;;
|
||||
esac
|
||||
46
server-tools/jd-disaster-recovery/jd-recovery-runner
Normal file
46
server-tools/jd-disaster-recovery/jd-recovery-runner
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
action=${1:-}
|
||||
backup_root=/var/backups/guanghu/jd-control-plane
|
||||
audit_dir=/var/lib/guanghu/recovery-audit
|
||||
install -d -o root -g root -m 0700 "$audit_dir"
|
||||
|
||||
latest=$(find "$backup_root" -maxdepth 1 -type f -name 'control-plane-*.tgz' -print 2>/dev/null | sort | tail -n 1)
|
||||
[[ -n "$latest" && -f "${latest%.tgz}.sha256" ]] || { echo "verified backup unavailable" >&2; exit 70; }
|
||||
(cd "$backup_root" && sha256sum -c "$(basename "${latest%.tgz}.sha256")" >/dev/null)
|
||||
|
||||
stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
case "$action" in
|
||||
restore-authz)
|
||||
tar -xzf "$latest" -C / opt/guanghu/lake-lamp-authz etc/systemd/system/lake-lamp-authz.service etc/systemd/system/lake-lamp-action-broker.service
|
||||
systemctl daemon-reload
|
||||
systemctl restart lake-lamp-action-broker.service lake-lamp-authz.service
|
||||
;;
|
||||
install-owner-access)
|
||||
tar -xzf "$latest" -C / opt/guanghu/lake-lamp-authz/owner-access-broker.js opt/guanghu/lake-lamp-authz/action-client.js etc/systemd/system/lake-lamp-owner-access.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now lake-lamp-owner-access.service
|
||||
systemctl restart lake-lamp-authz.service
|
||||
;;
|
||||
restore-navigation-map)
|
||||
tar -xzf "$latest" -C / etc/guanghu/navigation-maps
|
||||
systemctl restart lake-lamp-authz.service
|
||||
;;
|
||||
rollback-last-deploy)
|
||||
receipt=$(find /var/backups/guanghu/jd-enterprise-repair -maxdepth 1 -type f -name 'jd-authz-*.tgz' -print 2>/dev/null | sort | tail -n 1)
|
||||
[[ -n "$receipt" ]] || { echo "rollback backup unavailable" >&2; exit 71; }
|
||||
tar -xzf "$receipt" -C /
|
||||
systemctl daemon-reload
|
||||
systemctl restart lake-lamp-action-broker.service lake-lamp-authz.service
|
||||
;;
|
||||
*)
|
||||
echo "recovery action denied" >&2
|
||||
exit 126
|
||||
;;
|
||||
esac
|
||||
|
||||
printf '{"schema":"guanghu.recovery-audit/v1","at":"%s","action":"%s","source":"%s","result":"ok"}\n' \
|
||||
"$stamp" "$action" "${SSH_CONNECTION%% *}" >> "$audit_dir/events.jsonl"
|
||||
echo "RECOVERY_ACTION_OK=$action"
|
||||
28
server-tools/jd-disaster-recovery/recovery-policy.json
Normal file
28
server-tools/jd-disaster-recovery/recovery-policy.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"schema": "guanghu.jd-recovery-policy/v1",
|
||||
"controller": "JD-FD-PRIMARY",
|
||||
"owner_namespace": "ICE-GL-ZY001",
|
||||
"recovery_nodes": [
|
||||
"BS-GZ-006",
|
||||
"BS-SG-001",
|
||||
"BS-SG-002",
|
||||
"BS-SG-003",
|
||||
"BS-SH-005",
|
||||
"ZY-SG-006"
|
||||
],
|
||||
"allowed_actions": [
|
||||
"health-check",
|
||||
"restore-authz",
|
||||
"install-owner-access",
|
||||
"restore-navigation-map",
|
||||
"rollback-last-deploy"
|
||||
],
|
||||
"forbidden": [
|
||||
"shared-recovery-private-key",
|
||||
"unrestricted-shell",
|
||||
"reverse-access-from-enterprise",
|
||||
"private-key-on-personal-computer",
|
||||
"unsigned-recovery-event"
|
||||
],
|
||||
"total_outage": "rebuild from encrypted cross-provider control-plane backups"
|
||||
}
|
||||
40
server-tools/jd-disaster-recovery/test/policy.test.js
Normal file
40
server-tools/jd-disaster-recovery/test/policy.test.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const root = path.resolve(__dirname, "..");
|
||||
|
||||
test("six independent recovery nodes are declared", () => {
|
||||
const policy = JSON.parse(fs.readFileSync(path.join(root, "recovery-policy.json")));
|
||||
assert.equal(policy.recovery_nodes.length, 6);
|
||||
assert.equal(new Set(policy.recovery_nodes).size, 6);
|
||||
assert.ok(policy.forbidden.includes("shared-recovery-private-key"));
|
||||
assert.ok(policy.forbidden.includes("private-key-on-personal-computer"));
|
||||
assert.ok(policy.forbidden.includes("reverse-access-from-enterprise"));
|
||||
});
|
||||
|
||||
test("forced command rejects arbitrary shell", () => {
|
||||
const entry = fs.readFileSync(path.join(root, "jd-recovery-entry.sh"), "utf8");
|
||||
assert.match(entry, /SSH_ORIGINAL_COMMAND/);
|
||||
assert.match(entry, /recovery action denied/);
|
||||
assert.doesNotMatch(entry, /eval /);
|
||||
});
|
||||
|
||||
test("recovery runner only restores fixed local backup paths", () => {
|
||||
const runner = fs.readFileSync(path.join(root, "jd-recovery-runner"), "utf8");
|
||||
assert.match(runner, /sha256sum -c/);
|
||||
assert.match(runner, /restore-authz/);
|
||||
assert.match(runner, /install-owner-access/);
|
||||
assert.match(runner, /restore-navigation-map/);
|
||||
assert.doesNotMatch(runner, /eval /);
|
||||
assert.doesNotMatch(runner, /\$2/);
|
||||
});
|
||||
|
||||
test("six-node bootstrap uses unique keys and forced command restrictions", () => {
|
||||
const bootstrap = fs.readFileSync(path.join(root, "bootstrap-six-node-recovery.sh"), "utf8");
|
||||
assert.match(bootstrap, /restrict,command=/);
|
||||
assert.match(bootstrap, /source_ip/);
|
||||
assert.match(bootstrap, /test -f \/etc\/guanghu\/secrets\/jd-recovery\/to-jd-ed25519 \|\| ssh-keygen/);
|
||||
assert.doesNotMatch(bootstrap, /StrictHostKeyChecking no/);
|
||||
});
|
||||
30
server-tools/jd-forgejo/README.md
Normal file
30
server-tools/jd-forgejo/README.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# 京东云第五域国内主代码仓库
|
||||
|
||||
> **运行事实校正(2026-07-23)**:公网只读版本接口
|
||||
> `https://guanghulab.com/fifth-domain/api/v1/version` 当前返回 `1.23.7`,
|
||||
> 页面元信息显示现役运行体为 Gitea。本目录描述的是目标 Forgejo 部署结构,
|
||||
> 不能作为“线上已经运行 Forgejo”的证明。迁移与光湖自有化路线见
|
||||
> `glw-architecture/GLW-OS-004-FORGEJO-UPSTREAM-PARTS-AND-GUANGHU-CODE-PLATFORM.hdlp`。
|
||||
|
||||
产品身份必须独立验收,禁止再用服务名、目录名或相似 UI 代替真实版本证据:
|
||||
|
||||
```bash
|
||||
node server-tools/jd-forgejo/product-identity.js \
|
||||
https://guanghulab.com/fifth-domain FORGEJO
|
||||
```
|
||||
|
||||
当前该命令应返回 `PRODUCT_IDENTITY_MISMATCH`,直到并行迁移完成并切换入口。
|
||||
|
||||
- 与广州/新加坡现役节点一致的原生 Forgejo 二进制 + SQLite;
|
||||
- Web 和 SSH 只绑定 JD 回环地址;
|
||||
- 公网页面通过广州备案前门的专用 `permitopen` SSH 隧道发布在
|
||||
`https://guanghulab.com/fifth-domain/`;
|
||||
- 数据位于 `/var/lib/guanghu/forgejo`,`SECRET_KEY` 与 `INTERNAL_TOKEN`
|
||||
只写入服务器上的 `app.ini`;
|
||||
- 禁止公开注册和 push-create,初始管理员由部署命令在容器内创建;
|
||||
- 服务器 `pre-receive` 必须串联导航记忆守门人、私密禁用标识扫描和
|
||||
小湖灯一小时 repo-push 授权凭据。
|
||||
|
||||
不得再从广州/新加坡节点盲目复制“现役二进制”。任何候选二进制必须记录官方
|
||||
来源、版本、SHA-256 和产品身份,再进入隔离实例。迁移必须另开批次、备份数据库
|
||||
并人工验证。
|
||||
38
server-tools/jd-forgejo/app.ini.template
Normal file
38
server-tools/jd-forgejo/app.ini.template
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
APP_NAME = 第五域 · 国内主代码仓库
|
||||
RUN_USER = git
|
||||
WORK_PATH = /opt/forgejo
|
||||
|
||||
[server]
|
||||
PROTOCOL = http
|
||||
HTTP_ADDR = 127.0.0.1
|
||||
HTTP_PORT = 3001
|
||||
DOMAIN = guanghulab.com
|
||||
ROOT_URL = https://guanghulab.com/fifth-domain/
|
||||
DISABLE_SSH = true
|
||||
LFS_START_SERVER = true
|
||||
|
||||
[database]
|
||||
DB_TYPE = sqlite3
|
||||
PATH = /var/lib/guanghu/forgejo/data/forgejo.db
|
||||
LOG_SQL = false
|
||||
|
||||
[repository]
|
||||
ROOT = /var/lib/guanghu/forgejo/repositories
|
||||
DEFAULT_PRIVATE = private
|
||||
ENABLE_PUSH_CREATE_USER = false
|
||||
|
||||
[service]
|
||||
DISABLE_REGISTRATION = true
|
||||
REQUIRE_SIGNIN_VIEW = false
|
||||
|
||||
[security]
|
||||
INSTALL_LOCK = true
|
||||
SECRET_KEY = PRIVATE_SERVER_VALUE
|
||||
INTERNAL_TOKEN = PRIVATE_SERVER_VALUE
|
||||
|
||||
[session]
|
||||
PROVIDER = file
|
||||
|
||||
[log]
|
||||
MODE = console
|
||||
LEVEL = Info
|
||||
12
server-tools/jd-forgejo/guanghu-forgejo.nginx.conf
Normal file
12
server-tools/jd-forgejo/guanghu-forgejo.nginx.conf
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
location /fifth-domain/ {
|
||||
proxy_pass http://127.0.0.1:19301/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
client_max_body_size 256m;
|
||||
}
|
||||
22
server-tools/jd-forgejo/guanghu-forgejo.service
Normal file
22
server-tools/jd-forgejo/guanghu-forgejo.service
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
[Unit]
|
||||
Description=Guanghu Fifth Domain domestic Forgejo primary
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=git
|
||||
Group=git
|
||||
SupplementaryGroups=guanghu
|
||||
WorkingDirectory=/opt/forgejo
|
||||
ExecStart=/usr/local/bin/forgejo web -c /opt/forgejo/custom/conf/app.ini --work-path /opt/forgejo
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/forgejo /var/lib/guanghu/forgejo
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
19
server-tools/jd-forgejo/guanghu-jd-forgejo-tunnel.service
Normal file
19
server-tools/jd-forgejo/guanghu-jd-forgejo-tunnel.service
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[Unit]
|
||||
Description=Guanghu BS-GZ-006 to JD Forgejo web tunnel
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/bin/ssh -NT -F /etc/guanghu/jd-forgejo-tunnel-ssh-config jd-forgejo-target
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=read-only
|
||||
ProtectSystem=strict
|
||||
ReadOnlyPaths=/etc/guanghu/jd-forgejo-tunnel-ssh-config /etc/guanghu/secrets/ssh/bs_gz_006_to_jd_forgejo_proxy /root/.ssh/known_hosts
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
18
server-tools/jd-forgejo/install-gz-proxy.sh
Executable file
18
server-tools/jd-forgejo/install-gz-proxy.sh
Executable file
|
|
@ -0,0 +1,18 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
JD_HOST=${1:?JD host is required}
|
||||
|
||||
install -m 644 /tmp/guanghu-jd-forgejo-tunnel.service /etc/systemd/system/guanghu-jd-forgejo-tunnel.service
|
||||
install -m 644 /tmp/guanghu-forgejo.nginx.conf /etc/nginx/snippets/guanghu-forgejo.conf
|
||||
chmod 600 /etc/guanghu/secrets/ssh/bs_gz_006_to_jd_forgejo_proxy
|
||||
install -m 600 /dev/null /etc/guanghu/jd-forgejo-tunnel-ssh-config
|
||||
sed -e "s/JD_PUBLIC_ADDRESS/${JD_HOST}/" /tmp/jd-forgejo-tunnel-ssh-config.example > /etc/guanghu/jd-forgejo-tunnel-ssh-config
|
||||
|
||||
if ! grep -q "include /etc/nginx/snippets/guanghu-forgejo.conf;" /etc/nginx/sites-enabled/guanghulab; then
|
||||
sed -i '0,/server_name guanghulab.com;/s##server_name guanghulab.com;\n include /etc/nginx/snippets/guanghu-forgejo.conf;#' /etc/nginx/sites-enabled/guanghulab
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now guanghu-jd-forgejo-tunnel.service
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
rm -f /tmp/guanghu-jd-forgejo-tunnel.service /tmp/guanghu-forgejo.nginx.conf /tmp/jd-forgejo-tunnel-ssh-config.example
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
Host jd-forgejo-target
|
||||
HostName JD_PUBLIC_ADDRESS
|
||||
User root
|
||||
IdentityFile /etc/guanghu/secrets/ssh/bs_gz_006_to_jd_forgejo_proxy
|
||||
IdentitiesOnly yes
|
||||
LocalForward 127.0.0.1:19301 127.0.0.1:3001
|
||||
ExitOnForwardFailure yes
|
||||
ServerAliveInterval 30
|
||||
ServerAliveCountMax 3
|
||||
41
server-tools/jd-forgejo/native.test.js
Normal file
41
server-tools/jd-forgejo/native.test.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { parseProductIdentity } = require("./product-identity");
|
||||
const ini = fs.readFileSync(path.join(__dirname, "app.ini.template"), "utf8");
|
||||
const unit = fs.readFileSync(path.join(__dirname, "guanghu-forgejo.service"), "utf8");
|
||||
test("native Forgejo binds only to JD loopback", () => {
|
||||
assert.match(ini, /HTTP_ADDR = 127\.0\.0\.1/);
|
||||
assert.match(ini, /HTTP_PORT = 3001/);
|
||||
});
|
||||
test("registration and push-create are disabled", () => {
|
||||
assert.match(ini, /DISABLE_REGISTRATION = true/);
|
||||
assert.match(ini, /ENABLE_PUSH_CREATE_USER = false/);
|
||||
});
|
||||
test("secrets remain server-side placeholders", () => {
|
||||
assert.equal((ini.match(/PRIVATE_SERVER_VALUE/g) || []).length, 2);
|
||||
assert.doesNotMatch(ini, /[a-f0-9]{40,}/);
|
||||
assert.match(unit, /User=git/);
|
||||
});
|
||||
test("product identity probe rejects Gitea being labeled as Forgejo", () => {
|
||||
const identity = parseProductIdentity(
|
||||
{ version: "1.23.7" },
|
||||
'<meta name="author" content="Gitea - Git with a cup of tea">',
|
||||
);
|
||||
assert.deepEqual(identity, {
|
||||
product: "GITEA",
|
||||
version: "1.23.7",
|
||||
author: "Gitea - Git with a cup of tea",
|
||||
});
|
||||
assert.notEqual(identity.product, "FORGEJO");
|
||||
});
|
||||
test("product identity probe recognizes Forgejo independently", () => {
|
||||
const identity = parseProductIdentity(
|
||||
{ version: "15.0.5" },
|
||||
'<meta name="author" content="Forgejo – Beyond coding. We forge.">',
|
||||
);
|
||||
assert.equal(identity.product, "FORGEJO");
|
||||
assert.equal(identity.version, "15.0.5");
|
||||
});
|
||||
57
server-tools/jd-forgejo/product-identity.js
Normal file
57
server-tools/jd-forgejo/product-identity.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"use strict";
|
||||
|
||||
function parseProductIdentity(versionPayload, html) {
|
||||
const version =
|
||||
typeof versionPayload === "string"
|
||||
? JSON.parse(versionPayload).version
|
||||
: versionPayload.version;
|
||||
const author =
|
||||
html.match(
|
||||
/<meta\s+name=["']author["']\s+content=["']([^"']+)["']/i,
|
||||
)?.[1] ?? "";
|
||||
const normalizedAuthor = author.toLowerCase();
|
||||
let product = "UNKNOWN";
|
||||
if (normalizedAuthor.includes("forgejo")) product = "FORGEJO";
|
||||
if (normalizedAuthor.includes("gitea")) product = "GITEA";
|
||||
return { product, version, author };
|
||||
}
|
||||
|
||||
async function inspectProduct(baseUrl, fetchImpl = fetch) {
|
||||
const normalizedBase = baseUrl.replace(/\/+$/, "");
|
||||
const [versionResponse, homeResponse] = await Promise.all([
|
||||
fetchImpl(`${normalizedBase}/api/v1/version`),
|
||||
fetchImpl(`${normalizedBase}/`),
|
||||
]);
|
||||
if (!versionResponse.ok || !homeResponse.ok) {
|
||||
throw new Error(
|
||||
`repository identity probe failed: version=${versionResponse.status}, home=${homeResponse.status}`,
|
||||
);
|
||||
}
|
||||
return parseProductIdentity(
|
||||
await versionResponse.json(),
|
||||
await homeResponse.text(),
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const baseUrl =
|
||||
process.argv[2] ?? "https://guanghulab.com/fifth-domain";
|
||||
const expectedProduct = (process.argv[3] ?? "FORGEJO").toUpperCase();
|
||||
const identity = await inspectProduct(baseUrl);
|
||||
process.stdout.write(`${JSON.stringify(identity, null, 2)}\n`);
|
||||
if (identity.product !== expectedProduct) {
|
||||
process.stderr.write(
|
||||
`PRODUCT_IDENTITY_MISMATCH: expected ${expectedProduct}, got ${identity.product} ${identity.version}\n`,
|
||||
);
|
||||
process.exitCode = 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { inspectProduct, parseProductIdentity };
|
||||
121
server-tools/lake-lamp-authz/README.md
Normal file
121
server-tools/lake-lamp-authz/README.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# 小湖灯邮件链接授权服务
|
||||
|
||||
这是 Gatekeeper v3.2 前面的人类批准层。它同时支持电脑本机和手机/任意设备:
|
||||
|
||||
- 跨设备默认入口无需秘密凭证,只能创建一张没有执行权的申请单;
|
||||
- 冰朔打开申请单并点击“发送我的授权邮件”后,真正的批准链接才会发送到服务器预登记邮箱;
|
||||
- 邮件批准完成后,人格体才能一次性领取绑定到
|
||||
`persona + target server + scope + actions` 的受限会话;会话内可连续执行已登记能力,并可在当前协作未结束时主动续签,但续签不得改变目标、scope 或 actions;
|
||||
- 光湖语言人格系统的当前实例可以提出结构化工单。工单开头固定写“光湖语言人格系统当前实例”,并声明来自哪个软件、哪个模型和哪个当前实例;来源声明用于追溯,不要求该实例已经住进尚未完成的 Tolaria;
|
||||
- 冰朔在同一段当前对话中明确签字后,本次实例可以执行已展示的目标、范围和动作,不重复发送邮件;自报来源只能建单,不能自行批准或扩大权限;
|
||||
- 邮件链接保留为无人能读取当前对话、跨设备转交或需要二次确认时的兜底,不再是所有实例进入系统的唯一入口;
|
||||
- 旧的 request credential 入口继续保留,供受控电脑和服务器内自动化兼容使用。
|
||||
|
||||
申请单、邮件批准链接和领取凭证按服务器策略失效;活动会话受最大连续时长约束。
|
||||
|
||||
安全边界:
|
||||
|
||||
- 邮箱、QQ 数字、SMTP 授权码和 request credential 只存在于私密文件;
|
||||
- 多成员审批人登记只存在于 `LAKE_LAMP_APPROVERS_FILE` 指向的服务器私密文件;系统按人格体、目标节点和 scope 选收件人,请求正文不能指定或切换邮箱;
|
||||
- 公开创建申请单不会发邮件、不会返回批准令牌,也不会获得任何服务器或仓库权限;
|
||||
- 申请页只允许触发一次预登记邮箱验证,并有单 IP 与全局小时限流;
|
||||
- 浏览器批准链接为一次性随机令牌,服务端仅持久化摘要;
|
||||
- claim token 与 session token 均仅向申请人格体返回一次,磁盘只保存摘要;
|
||||
- 换目标服务器时旧 session 必然返回 `target_mismatch`;
|
||||
- 同一服务器、同一 scope 和同一 actions 内可续签而不重复发邮件;切换服务器或扩大权限必须重新授权;
|
||||
- 本服务不接受任意 shell 命令,只签发登记动作的会话;
|
||||
- 所有改变服务器状态的登记动作必须先生成备份引用和回滚方案;失败自动回滚,成功写验收回执;
|
||||
- 广州公开代理只应暴露 `/approve/`、`/api/workorders` 与 claim 路由,服务本体监听 JD 回环地址。
|
||||
|
||||
`request-workorder.js` 从临时环境变量读取 QQ 数字,在内存中补全邮箱并只发送
|
||||
SHA-256 指纹;数字本身不会写入请求正文、状态文件或代码仓库。未提供私密
|
||||
request credential 时,脚本自动切换到跨设备公开建单模式。
|
||||
|
||||
## 手机或任意设备上的新实例
|
||||
|
||||
新实例无需寻找工单凭证,执行:
|
||||
|
||||
```bash
|
||||
node request-workorder.js \
|
||||
--url https://guanghulab.com/authz \
|
||||
--persona ICE-GL-ZY001 \
|
||||
--name 铸渊 \
|
||||
--target JD-FD-PRIMARY \
|
||||
--scope repo-push \
|
||||
--action push-repository \
|
||||
--description "推送 REPO-001"
|
||||
```
|
||||
|
||||
响应中的 `request_url` 交给冰朔点击。该 URL 只能展示申请内容并请求向预登记邮箱
|
||||
发送批准邮件,不能直接批准。人格体必须在自己的临时会话中保留响应里的
|
||||
`workorder_id` 与 `claim_token`,批准后通过 claim 路由领取一次性会话;不得把这些值
|
||||
写入仓库、长期日志或聊天记忆。
|
||||
|
||||
## 推送被服务器拒绝时 · 一条命令自动续接
|
||||
|
||||
当 Forgejo 返回 `[LL-REPO-PUSH-AUTH-REQUIRED]` 时,不要找邮箱,不要向冰朔索要
|
||||
授权码、验证码、密码或 Token。直接从 `fifth-domain` 仓库根目录执行服务器返回的命令:
|
||||
|
||||
```bash
|
||||
node server-tools/lake-lamp-authz/authorize-repo-push.js \
|
||||
--url https://guanghulab.com/authz \
|
||||
--persona ICE-GL-ZY001 \
|
||||
--repo bingshuo/fifth-domain
|
||||
```
|
||||
|
||||
该命令只在内存中保留一次性领取信息,并自动完成:
|
||||
|
||||
```text
|
||||
创建无权限空工单
|
||||
→ 打印 REQUEST_URL 给冰朔
|
||||
→ 等待冰朔打开该页,由服务器向预登记邮箱发信
|
||||
→ 等待冰朔点击邮件批准链接
|
||||
→ 领取限时会话
|
||||
→ 读取并确认目标节点导航图
|
||||
→ 生成三小时、执行中自动续期的 repo-push 许可
|
||||
→ 提示 AI 重试原 git push
|
||||
```
|
||||
|
||||
命令运行期间不要关闭它。公开空工单不会发邮件,也没有推送权限;只有冰朔打开
|
||||
`REQUEST_URL` 后,服务器才向预登记邮箱发送批准邮件。
|
||||
|
||||
## 受控电脑兼容入口
|
||||
|
||||
如果环境中存在 `LAKE_LAMP_REQUEST_TOKEN` 或 `LAKE_LAMP_REQUEST_TOKEN_FILE`,脚本
|
||||
使用旧的私密申请模式并直接发送批准邮件。该凭证仅能建单,仍不能登录、推送或执行
|
||||
服务器动作。
|
||||
|
||||
## 限流配置
|
||||
|
||||
- `LAKE_LAMP_PUBLIC_CREATE_LIMIT`:单来源每小时公开建单上限,默认 24;
|
||||
- `LAKE_LAMP_PUBLIC_CREATE_GLOBAL_LIMIT`:全局每小时建单上限,默认 60;
|
||||
- `LAKE_LAMP_PUBLIC_MAIL_LIMIT`:单来源每小时触发授权邮件上限,默认 12;
|
||||
- `LAKE_LAMP_PUBLIC_MAIL_GLOBAL_LIMIT`:全局每小时授权邮件上限,默认 30。
|
||||
|
||||
运行入口:公开建单 `/api/public/workorders`,会话续签 `/api/session/renew`,登记动作执行 `/api/actions/execute`。多节点需求由人格体按当前任务拆成并行申请,不再使用固定“三封邮件”作为协作规则。
|
||||
|
||||
## 新架构首次部署
|
||||
|
||||
旧的 `deploy-registered-service` 只能操作已经登记的服务,不能承担首次安装。新架构统一使用固定动作 `provision-approved-architecture`,并把仓库请求编号与不可变提交绑定进工单:
|
||||
|
||||
```bash
|
||||
node request-workorder.js \
|
||||
--url https://guanghulab.com/authz \
|
||||
--persona ICE-GL-ZY001 \
|
||||
--name 铸渊 \
|
||||
--target JD-FD-PRIMARY \
|
||||
--scope server-ops \
|
||||
--action provision-approved-architecture \
|
||||
--resource 'REQUEST-ID@40位提交SHA' \
|
||||
--description '首次安装已审核架构包'
|
||||
```
|
||||
|
||||
邮件或可信对话签字页面必须显示同一个 `resource`。批准会话不能切换请求编号或提交。执行器只读取该提交中 `deployment/requests/<REQUEST-ID>.json`,只复制清单列出的普通文件,只安装清单指定的非 root、加固 systemd 单元,并只接受回环健康检查。人格体可以使用清单声明的独立低权限账户、共享模型密钥文件和状态目录;密钥路径必须位于 `/etc/guanghu/persona-secrets/`,可写路径必须位于 `/var/lib/guanghu/personas/<运行账户>/`。覆盖旧单元前强制备份,启动或验收失败时自动恢复。说明文字不能改变部署内容,也不开放任意 shell。
|
||||
|
||||
这套入口本身需要在京东主控上一次性安装:
|
||||
|
||||
```bash
|
||||
sudo bash server-tools/lake-lamp-authz/install-architecture-provisioner.sh
|
||||
```
|
||||
|
||||
这是最后一次需要云厂商控制台或现有系统管理通道的引导。安装完成后,未来新架构均走上面的结构化工单,不必预先把每个未来模块写进旧动作桥。
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
Host enterprise-lighthouse
|
||||
HostName REPLACE_IN_PRIVATE_SERVER_CONFIG
|
||||
User root
|
||||
IdentityFile /etc/guanghu/secrets/lake-lamp/jd-to-enterprise-ed25519
|
||||
IdentitiesOnly yes
|
||||
StrictHostKeyChecking yes
|
||||
UserKnownHostsFile /etc/guanghu/secrets/lake-lamp/known_hosts
|
||||
53
server-tools/lake-lamp-authz/action-broker.js
Normal file
53
server-tools/lake-lamp-authz/action-broker.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const { execFile } = require("node:child_process");
|
||||
|
||||
const SOCKET_PATH = process.env.LAKE_LAMP_ACTION_SOCKET || "/run/guanghu/action-broker.sock";
|
||||
const SSH_CONFIG = process.env.LAKE_LAMP_SSH_CONFIG || "/etc/guanghu/action-broker-ssh-config";
|
||||
|
||||
const ACTIONS = Object.freeze({
|
||||
"JD-FD-PRIMARY:inspect-services": () => run("/usr/bin/ssh", [
|
||||
"-F", SSH_CONFIG,
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=10",
|
||||
"enterprise-lighthouse",
|
||||
"printf 'HOST='; hostname; printf 'SSH='; systemctl is-active ssh 2>/dev/null || systemctl is-active sshd 2>/dev/null; printf 'LIGHTHOUSE='; systemctl is-active guanghu-enterprise-lighthouse.service 2>/dev/null || true"
|
||||
])
|
||||
});
|
||||
|
||||
function run(file, args) {
|
||||
return new Promise(resolve => execFile(file, args, { timeout: 30000, maxBuffer: 100000 }, (error, stdout, stderr) => resolve({
|
||||
ok: !error,
|
||||
exit_code: error ? (Number.isInteger(error.code) ? error.code : 1) : 0,
|
||||
stdout: String(stdout || "").slice(0, 100000),
|
||||
stderr: String(stderr || "").slice(0, 10000),
|
||||
})));
|
||||
}
|
||||
|
||||
function reply(socket, value) { socket.end(`${JSON.stringify(value)}\n`); }
|
||||
|
||||
if (require.main === module) {
|
||||
fs.mkdirSync(require("node:path").dirname(SOCKET_PATH), { recursive: true, mode: 0o755 });
|
||||
try { fs.unlinkSync(SOCKET_PATH); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
const server = net.createServer({ allowHalfOpen: true }, socket => {
|
||||
let input = "";
|
||||
socket.setTimeout(5000, () => socket.destroy());
|
||||
socket.on("data", chunk => { input += chunk.toString("utf8"); if (input.length > 4096) socket.destroy(); });
|
||||
socket.on("end", async () => {
|
||||
let request;
|
||||
try { request = JSON.parse(input); } catch { return reply(socket, { ok: false, error: "invalid_request" }); }
|
||||
if (!request || request.cmd || request.command || request.shell || request.args) return reply(socket, { ok: false, error: "arbitrary_command_forbidden" });
|
||||
const action = ACTIONS[`${request.target}:${request.action}`];
|
||||
if (!action) return reply(socket, { ok: false, error: "action_not_registered" });
|
||||
reply(socket, await action());
|
||||
});
|
||||
});
|
||||
server.listen(SOCKET_PATH, () => {
|
||||
fs.chownSync(SOCKET_PATH, 0, Number(process.env.LAKE_LAMP_AUTHZ_GID || 0));
|
||||
fs.chmodSync(SOCKET_PATH, 0o660);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { ACTIONS };
|
||||
27
server-tools/lake-lamp-authz/action-client.js
Normal file
27
server-tools/lake-lamp-authz/action-client.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"use strict";
|
||||
|
||||
const net = require("node:net");
|
||||
|
||||
const SOCKET_PATH = process.env.LAKE_LAMP_ACTION_SOCKET || "/run/guanghu/action-broker.sock";
|
||||
const OWNER_ACCESS_SOCKET_PATH = process.env.LAKE_LAMP_OWNER_ACCESS_SOCKET || "/run/guanghu-owner-access/owner-access.sock";
|
||||
const ARCHITECTURE_PROVISION_SOCKET_PATH = process.env.LAKE_LAMP_ARCHITECTURE_PROVISION_SOCKET || "/run/guanghu-architecture-provision/provision.sock";
|
||||
|
||||
function executeRegisteredAction(request, socketPath) {
|
||||
const selectedSocket = socketPath || (request.action === "restore-owner-password-login" ? OWNER_ACCESS_SOCKET_PATH : request.action === "provision-approved-architecture" ? ARCHITECTURE_PROVISION_SOCKET_PATH : SOCKET_PATH);
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection(selectedSocket);
|
||||
let response = "";
|
||||
let settled = false;
|
||||
const finish = (value) => { if (!settled) { settled = true; resolve(value); } };
|
||||
socket.setTimeout(request.action === "provision-approved-architecture" ? 150000 : 35000);
|
||||
socket.on("connect", () => socket.end(`${JSON.stringify(request)}\n`));
|
||||
socket.on("data", chunk => { response += chunk.toString("utf8"); if (response.length > 100000) socket.destroy(); });
|
||||
socket.on("end", () => {
|
||||
try { finish(JSON.parse(response)); } catch { finish({ ok: false, error: "invalid_broker_response" }); }
|
||||
});
|
||||
socket.on("timeout", () => { socket.destroy(); finish({ ok: false, error: "action_timeout" }); });
|
||||
socket.on("error", () => finish({ ok: false, error: "action_broker_unavailable" }));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { executeRegisteredAction, SOCKET_PATH, OWNER_ACCESS_SOCKET_PATH, ARCHITECTURE_PROVISION_SOCKET_PATH };
|
||||
24
server-tools/lake-lamp-authz/approvers.example.json
Normal file
24
server-tools/lake-lamp-authz/approvers.example.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"schema": "guanghu.approver-registry/v1",
|
||||
"approvers": [
|
||||
{
|
||||
"id": "sovereign-owner",
|
||||
"email": "SET_IN_PRIVATE_SERVER_FILE",
|
||||
"default": true,
|
||||
"persona_ids": ["ICE-GL-ZY001"],
|
||||
"roles": ["fifth-domain-publisher", "node-owner"],
|
||||
"targets": ["JD-FD-PRIMARY", "BS-GZ-006", "BS-SH-005", "BS-SG-001", "BS-SG-002", "BS-SG-003", "ZY-SG-006"],
|
||||
"scopes": ["server-login", "server-ops", "repo-push"]
|
||||
},
|
||||
{
|
||||
"id": "technical-controller",
|
||||
"email": "SET_IN_PRIVATE_SERVER_FILE",
|
||||
"default": false,
|
||||
"persona_ids": ["SET_IN_PRIVATE_SERVER_FILE"],
|
||||
"roles": ["zero-sense-technical-controller"],
|
||||
"targets": ["JD-FD-PRIMARY"],
|
||||
"scopes": ["server-login", "server-ops"]
|
||||
}
|
||||
],
|
||||
"selection": "match target and scope on the server; a requester may never supply or override a recipient"
|
||||
}
|
||||
200
server-tools/lake-lamp-authz/architecture-provision-broker.js
Normal file
200
server-tools/lake-lamp-authz/architecture-provision-broker.js
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
const path = require("node:path");
|
||||
const { execFile } = require("node:child_process");
|
||||
|
||||
const SOCKET_PATH = process.env.LAKE_LAMP_ARCHITECTURE_PROVISION_SOCKET || "/run/guanghu-architecture-provision/provision.sock";
|
||||
const REPO_DIR = process.env.ARCHITECTURE_PROVISION_REPO_DIR || "/var/lib/guanghu/architecture-provision/repo";
|
||||
const REPO_URL = process.env.ARCHITECTURE_PROVISION_REPO_URL || "https://guanghulab.com/fifth-domain/bingshuo/fifth-domain.git";
|
||||
const RELEASES_DIR = process.env.ARCHITECTURE_PROVISION_RELEASES_DIR || "/opt/guanghu/architecture-releases";
|
||||
const UNIT_DIR = process.env.ARCHITECTURE_PROVISION_UNIT_DIR || "/etc/systemd/system";
|
||||
const RECEIPTS_DIR = process.env.ARCHITECTURE_PROVISION_RECEIPTS_DIR || "/var/lib/guanghu/architecture-provision/receipts";
|
||||
|
||||
function parseResource(value) {
|
||||
const match = String(value || "").match(/^([A-Z0-9][A-Z0-9._-]{5,119})@([0-9a-f]{40})$/);
|
||||
return match ? { requestId: match[1], commit: match[2] } : null;
|
||||
}
|
||||
|
||||
function safeRelative(value) {
|
||||
const item = String(value || "");
|
||||
return item.length > 0 && item.length <= 240 && !path.isAbsolute(item) && !item.split("/").includes("..") && /^[A-Za-z0-9._/-]+$/.test(item);
|
||||
}
|
||||
|
||||
function validateManifest(manifest, resource) {
|
||||
if (!manifest || manifest.schema !== "guanghu.architecture-provision-request/v1") throw new Error("invalid_manifest_schema");
|
||||
if (manifest.request_id !== resource.requestId || manifest.target_node !== "JD-FD-PRIMARY") throw new Error("manifest_identity_mismatch");
|
||||
if (manifest.status !== "ARCHITECTURE_PACKAGE_READY · INITIAL_PROVISION_PENDING") throw new Error("manifest_not_pending");
|
||||
if (!manifest.initial_provision || manifest.initial_provision.kind !== "new-architecture-unit") throw new Error("not_initial_architecture_unit");
|
||||
const unit = String(manifest.module && manifest.module.unit || "");
|
||||
if (!/^[A-Za-z0-9_.@-]+\.service$/.test(unit)) throw new Error("invalid_unit_name");
|
||||
if (!Array.isArray(manifest.source_paths) || manifest.source_paths.length < 1 || manifest.source_paths.length > 64 || manifest.source_paths.some(item => !safeRelative(item))) throw new Error("invalid_source_paths");
|
||||
const unitMatches = manifest.source_paths.filter(item => path.basename(item) === unit);
|
||||
if (unitMatches.length !== 1) throw new Error("unit_not_uniquely_declared");
|
||||
const check = manifest.runtime_check || {};
|
||||
if (!/^http:\/\/127\.0\.0\.1:\d{2,5}\/[A-Za-z0-9._/?=&-]*$/.test(String(check.url || ""))) throw new Error("invalid_loopback_runtime_check");
|
||||
if (!check.expected || typeof check.expected !== "object" || Array.isArray(check.expected)) throw new Error("invalid_runtime_expectation");
|
||||
return { unit, unitSource: unitMatches[0], runtimeCheck: check };
|
||||
}
|
||||
|
||||
function declaredPaths(value) {
|
||||
return String(value || "").split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
function pathAllowed(candidate, allowed) {
|
||||
const clean = String(candidate || "").replace(/^-/, "");
|
||||
return allowed.some(base => clean === base || clean.startsWith(`${base}/`));
|
||||
}
|
||||
|
||||
function validateUnit(text, expectedUser = "guanghu", policy = {}) {
|
||||
const value = String(text || "");
|
||||
if (!value.includes("[Service]") || !/^NoNewPrivileges=(true|yes)$/m.test(value) || !/^ProtectSystem=strict$/m.test(value) || !/^ProtectHome=(true|yes)$/m.test(value) || !/^PrivateTmp=(true|yes)$/m.test(value)) throw new Error("unit_hardening_required");
|
||||
if (!/^[a-z_][a-z0-9_-]{0,30}$/.test(expectedUser) || expectedUser === "root" || !new RegExp(`^User=${expectedUser}$`, "m").test(value) || !new RegExp(`^Group=${expectedUser}$`, "m").test(value)) throw new Error("dedicated_service_user_required");
|
||||
if (/^(SupplementaryGroups|AmbientCapabilities|CapabilityBoundingSet|BindPaths|BindReadOnlyPaths|RootDirectory|RootImage|DeviceAllow)=/m.test(value)) throw new Error("privileged_unit_directive_forbidden");
|
||||
const environmentFiles = Array.isArray(policy.environment_files) ? policy.environment_files : [];
|
||||
const writablePaths = Array.isArray(policy.writable_paths) ? policy.writable_paths : [];
|
||||
const readOnlyPaths = Array.isArray(policy.read_only_paths) ? policy.read_only_paths : [];
|
||||
for (const match of value.matchAll(/^EnvironmentFile=(.+)$/gm)) {
|
||||
if (!pathAllowed(match[1], environmentFiles) || !String(match[1]).replace(/^-/, "").startsWith("/etc/guanghu/persona-secrets/")) throw new Error("environment_file_not_declared");
|
||||
}
|
||||
for (const match of value.matchAll(/^ReadWritePaths=(.+)$/gm)) {
|
||||
for (const item of declaredPaths(match[1])) if (!pathAllowed(item, writablePaths) || !item.startsWith(`/var/lib/guanghu/personas/${expectedUser}`)) throw new Error("writable_path_not_declared");
|
||||
}
|
||||
for (const match of value.matchAll(/^ReadOnlyPaths=(.+)$/gm)) {
|
||||
for (const item of declaredPaths(match[1])) if (item !== "__RELEASE_ROOT__" && !pathAllowed(item, readOnlyPaths)) throw new Error("read_only_path_not_declared");
|
||||
}
|
||||
if (!value.includes("__RELEASE_ROOT__")) throw new Error("release_root_placeholder_required");
|
||||
return value;
|
||||
}
|
||||
|
||||
async function provision(request, options = {}) {
|
||||
if (!request || request.target !== "JD-FD-PRIMARY" || request.action !== "provision-approved-architecture") return { ok: false, error: "action_not_registered" };
|
||||
const resource = parseResource(request.resource);
|
||||
if (!resource) return { ok: false, error: "immutable_architecture_resource_required" };
|
||||
const repoDir = options.repoDir || REPO_DIR;
|
||||
const releasesDir = options.releasesDir || RELEASES_DIR;
|
||||
const unitDir = options.unitDir || UNIT_DIR;
|
||||
const receiptsDir = options.receiptsDir || RECEIPTS_DIR;
|
||||
const run = options.run || runFile;
|
||||
let installedUnit = null;
|
||||
let unitBackup = null;
|
||||
try {
|
||||
await prepareRepo(repoDir, resource.commit, run, options.repoUrl || REPO_URL);
|
||||
const manifestPath = path.join(repoDir, "deployment", "requests", `${resource.requestId}.json`);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
const checked = validateManifest(manifest, resource);
|
||||
const releaseRoot = path.join(releasesDir, resource.commit);
|
||||
fs.mkdirSync(releaseRoot, { recursive: true, mode: 0o755 });
|
||||
for (const relative of manifest.source_paths) copyDeclaredFile(repoDir, releaseRoot, relative);
|
||||
const unitSource = path.join(releaseRoot, checked.unitSource);
|
||||
const unitText = validateUnit(fs.readFileSync(unitSource, "utf8"), String(manifest.module.run_user || ""), manifest.module).replaceAll("__RELEASE_ROOT__", releaseRoot);
|
||||
fs.mkdirSync(unitDir, { recursive: true, mode: 0o755 });
|
||||
installedUnit = path.join(unitDir, checked.unit);
|
||||
if (fs.existsSync(installedUnit)) unitBackup = fs.readFileSync(installedUnit);
|
||||
const backupDir = path.join(receiptsDir, "backups", resource.requestId, resource.commit);
|
||||
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
|
||||
if (unitBackup) fs.writeFileSync(path.join(backupDir, checked.unit), unitBackup, { mode: 0o600 });
|
||||
else fs.writeFileSync(path.join(backupDir, `${checked.unit}.previously-absent`), "\n", { mode: 0o600 });
|
||||
writeAtomic(installedUnit, unitText, 0o644);
|
||||
await run("/usr/bin/systemctl", ["daemon-reload"]);
|
||||
await run("/usr/bin/systemctl", ["enable", "--now", checked.unit]);
|
||||
const runtime = await getJsonWithRetry(checked.runtimeCheck.url, options.getJson, options.healthAttempts, options.healthDelayMs);
|
||||
for (const [key, expected] of Object.entries(checked.runtimeCheck.expected)) if (runtime[key] !== expected) throw new Error(`runtime_check_failed:${key}`);
|
||||
const receipt = { schema: "guanghu.architecture-provision-receipt/v1", request_id: resource.requestId, source_commit: resource.commit, target_node: "JD-FD-PRIMARY", unit: checked.unit, runtime_check: checked.runtimeCheck.url, backup: path.join("backups", resource.requestId, resource.commit), rollback: unitBackup ? "restore-previous-unit" : "remove-new-unit", result: "DEPLOYED_AND_VERIFIED", recorded_at: new Date().toISOString() };
|
||||
fs.mkdirSync(receiptsDir, { recursive: true, mode: 0o700 });
|
||||
writeAtomic(path.join(receiptsDir, `${resource.requestId}.json`), `${JSON.stringify(receipt, null, 2)}\n`, 0o600);
|
||||
return { ok: true, request_id: resource.requestId, source_commit: resource.commit, unit: checked.unit, runtime: "verified" };
|
||||
} catch (error) {
|
||||
if (installedUnit) {
|
||||
try {
|
||||
await run("/usr/bin/systemctl", ["disable", "--now", path.basename(installedUnit)]);
|
||||
if (unitBackup) fs.writeFileSync(installedUnit, unitBackup, { mode: 0o644 });
|
||||
else fs.rmSync(installedUnit, { force: true });
|
||||
await run("/usr/bin/systemctl", ["daemon-reload"]);
|
||||
if (unitBackup) await run("/usr/bin/systemctl", ["enable", "--now", path.basename(installedUnit)]);
|
||||
} catch { /* The original error remains authoritative; backup is retained for manual recovery. */ }
|
||||
}
|
||||
return { ok: false, error: String(error && error.message || "provision_failed").slice(0, 240) };
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareRepo(repoDir, commit, run, repoUrl) {
|
||||
fs.mkdirSync(path.dirname(repoDir), { recursive: true, mode: 0o700 });
|
||||
if (!fs.existsSync(path.join(repoDir, ".git"))) await run("/usr/bin/git", ["clone", "--filter=blob:none", "--no-checkout", repoUrl, repoDir]);
|
||||
await run("/usr/bin/git", ["-C", repoDir, "fetch", "--depth=1", "origin", commit]);
|
||||
await run("/usr/bin/git", ["-C", repoDir, "checkout", "--detach", "--force", commit]);
|
||||
const head = (await run("/usr/bin/git", ["-C", repoDir, "rev-parse", "HEAD"])).stdout.trim();
|
||||
if (head !== commit) throw new Error("commit_verification_failed");
|
||||
}
|
||||
|
||||
function copyDeclaredFile(repoDir, releaseRoot, relative) {
|
||||
const source = path.join(repoDir, relative);
|
||||
const stat = fs.lstatSync(source);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("declared_source_not_regular_file");
|
||||
const destination = path.join(releaseRoot, relative);
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o755 });
|
||||
if (fs.existsSync(destination)) {
|
||||
if (!fs.readFileSync(source).equals(fs.readFileSync(destination))) throw new Error("immutable_release_collision");
|
||||
return;
|
||||
}
|
||||
fs.copyFileSync(source, destination, fs.constants.COPYFILE_EXCL);
|
||||
fs.chmodSync(destination, stat.mode & 0o755);
|
||||
}
|
||||
|
||||
function writeAtomic(file, content, mode) {
|
||||
const temp = `${file}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temp, content, { mode });
|
||||
fs.renameSync(temp, file);
|
||||
}
|
||||
|
||||
function runFile(file, args) {
|
||||
return new Promise((resolve, reject) => execFile(file, args, { timeout: 120000, maxBuffer: 200000 }, (error, stdout, stderr) => error ? reject(new Error(`command_failed:${path.basename(file)}:${String(stderr || error.message).slice(0, 120)}`)) : resolve({ stdout: String(stdout || ""), stderr: String(stderr || "") })));
|
||||
}
|
||||
|
||||
function getJson(url, override) {
|
||||
if (override) return override(url);
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.get(url, { timeout: 5000 }, response => {
|
||||
let body = "";
|
||||
response.on("data", chunk => { body += chunk; if (body.length > 100000) req.destroy(); });
|
||||
response.on("end", () => { try { resolve(JSON.parse(body)); } catch { reject(new Error("invalid_runtime_response")); } });
|
||||
});
|
||||
req.on("timeout", () => req.destroy(new Error("runtime_check_timeout")));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function getJsonWithRetry(url, override, attempts = 15, delayMs = 1000) {
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try { return await getJson(url, override); }
|
||||
catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < attempts) await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function reply(socket, value) { socket.end(`${JSON.stringify(value)}\n`); }
|
||||
|
||||
if (require.main === module) {
|
||||
fs.mkdirSync(path.dirname(SOCKET_PATH), { recursive: true, mode: 0o755 });
|
||||
try { fs.unlinkSync(SOCKET_PATH); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
const server = net.createServer({ allowHalfOpen: true }, socket => {
|
||||
let input = "";
|
||||
socket.setTimeout(140000, () => socket.destroy());
|
||||
socket.on("data", chunk => { input += chunk.toString("utf8"); if (input.length > 4096) socket.destroy(); });
|
||||
socket.on("end", async () => {
|
||||
let request;
|
||||
try { request = JSON.parse(input); } catch { return reply(socket, { ok: false, error: "invalid_request" }); }
|
||||
if (!request || request.cmd || request.command || request.shell || request.args) return reply(socket, { ok: false, error: "arbitrary_command_forbidden" });
|
||||
reply(socket, await provision(request));
|
||||
});
|
||||
});
|
||||
server.listen(SOCKET_PATH, () => { fs.chownSync(SOCKET_PATH, 0, Number(process.env.LAKE_LAMP_AUTHZ_GID || 0)); fs.chmodSync(SOCKET_PATH, 0o660); });
|
||||
}
|
||||
|
||||
module.exports = { parseResource, safeRelative, validateManifest, validateUnit, provision };
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { parseResource, safeRelative, validateManifest, validateUnit, provision } = require("./architecture-provision-broker");
|
||||
|
||||
const commit = "d".repeat(40);
|
||||
const requestId = "GLS-0231-JD-LAN-01-INITIAL-PROVISION-20260720";
|
||||
|
||||
function manifest() {
|
||||
return {
|
||||
schema: "guanghu.architecture-provision-request/v1", request_id: requestId, target_node: "JD-FD-PRIMARY", status: "ARCHITECTURE_PACKAGE_READY · INITIAL_PROVISION_PENDING",
|
||||
module: { unit: "example.service", run_user: "guanghu" }, initial_provision: { kind: "new-architecture-unit" },
|
||||
source_paths: ["server-tools/example/server.js", "server-tools/example/example.service"],
|
||||
runtime_check: { url: "http://127.0.0.1:3924/health", expected: { ok: true, mode: "read-only" } },
|
||||
};
|
||||
}
|
||||
|
||||
test("resource and manifest are immutable and path constrained", () => {
|
||||
assert.deepEqual(parseResource(`${requestId}@${commit}`), { requestId, commit });
|
||||
assert.equal(parseResource(`${requestId}@main`), null);
|
||||
assert.equal(safeRelative("server-tools/example/server.js"), true);
|
||||
assert.equal(safeRelative("../etc/passwd"), false);
|
||||
assert.equal(validateManifest(manifest(), { requestId, commit }).unit, "example.service");
|
||||
});
|
||||
|
||||
test("unit requires non-root systemd hardening and release placeholder", () => {
|
||||
const unit = "[Service]\nUser=guanghu\nGroup=guanghu\nNoNewPrivileges=true\nPrivateTmp=true\nProtectSystem=strict\nProtectHome=true\nExecStart=/usr/bin/node __RELEASE_ROOT__/server.js\n";
|
||||
assert.equal(validateUnit(unit), unit);
|
||||
assert.throws(() => validateUnit(unit.replace("User=guanghu", "User=root")), /dedicated_service_user_required/);
|
||||
assert.throws(() => validateUnit(`${unit}EnvironmentFile=/etc/shadow\n`), /environment_file_not_declared/);
|
||||
assert.throws(() => validateUnit(unit.replace("__RELEASE_ROOT__", "/tmp/live")), /release_root_placeholder_required/);
|
||||
});
|
||||
|
||||
test("unit permits a declared persona user, shared secret and state directory", () => {
|
||||
const unit = "[Service]\nUser=kezhou\nGroup=kezhou\nNoNewPrivileges=yes\nPrivateTmp=yes\nProtectSystem=strict\nProtectHome=yes\nEnvironmentFile=-/etc/guanghu/persona-secrets/shared-deepseek.env\nReadWritePaths=/var/lib/guanghu/personas/kezhou\nReadOnlyPaths=__RELEASE_ROOT__\nExecStart=__RELEASE_ROOT__/run.sh\n";
|
||||
const policy = { environment_files: ["/etc/guanghu/persona-secrets/shared-deepseek.env"], writable_paths: ["/var/lib/guanghu/personas/kezhou"], read_only_paths: [] };
|
||||
assert.equal(validateUnit(unit, "kezhou", policy), unit);
|
||||
assert.throws(() => validateUnit(unit.replace("shared-deepseek.env", "../../shadow"), "kezhou", policy), /environment_file_not_declared/);
|
||||
assert.throws(() => validateUnit(unit.replace("/var/lib/guanghu/personas/kezhou", "/opt/guanghu/personas/kezhou"), "kezhou", policy), /writable_path_not_declared/);
|
||||
assert.throws(() => validateUnit(unit.replaceAll("kezhou", "root"), "root", policy), /dedicated_service_user_required/);
|
||||
});
|
||||
|
||||
test("provision copies only declared files and verifies loopback health", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "architecture-provision-"));
|
||||
const repoDir = path.join(root, "repo");
|
||||
const releasesDir = path.join(root, "releases");
|
||||
const unitDir = path.join(root, "units");
|
||||
const receiptsDir = path.join(root, "receipts");
|
||||
fs.mkdirSync(path.join(repoDir, "deployment", "requests"), { recursive: true });
|
||||
fs.mkdirSync(path.join(repoDir, "server-tools", "example"), { recursive: true });
|
||||
fs.writeFileSync(path.join(repoDir, "deployment", "requests", `${requestId}.json`), JSON.stringify(manifest()));
|
||||
fs.writeFileSync(path.join(repoDir, "server-tools", "example", "server.js"), "module.exports = {};\n");
|
||||
fs.writeFileSync(path.join(repoDir, "server-tools", "example", "example.service"), "[Service]\nUser=guanghu\nGroup=guanghu\nNoNewPrivileges=true\nPrivateTmp=true\nProtectSystem=strict\nProtectHome=true\nExecStart=/usr/bin/node __RELEASE_ROOT__/server-tools/example/server.js\n");
|
||||
const commands = [];
|
||||
let healthChecks = 0;
|
||||
try {
|
||||
const result = await provision({ target: "JD-FD-PRIMARY", action: "provision-approved-architecture", resource: `${requestId}@${commit}` }, {
|
||||
repoDir, releasesDir, unitDir, receiptsDir,
|
||||
run: async (file, args) => { commands.push([file, args]); return { stdout: args.includes("rev-parse") ? `${commit}\n` : "" }; },
|
||||
getJson: async () => {
|
||||
healthChecks += 1;
|
||||
if (healthChecks === 1) throw new Error("connection_refused_during_startup");
|
||||
return { ok: true, mode: "read-only" };
|
||||
},
|
||||
healthDelayMs: 0,
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(fs.existsSync(path.join(releasesDir, commit, "server-tools", "example", "server.js")), true);
|
||||
assert.match(fs.readFileSync(path.join(unitDir, "example.service"), "utf8"), new RegExp(commit));
|
||||
assert.equal(commands.some(([, args]) => args.includes("enable") && args.includes("--now")), true);
|
||||
assert.equal(healthChecks, 2);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
28
server-tools/lake-lamp-authz/authorization.env.example
Normal file
28
server-tools/lake-lamp-authz/authorization.env.example
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
LAKE_LAMP_HOST=127.0.0.1
|
||||
LAKE_LAMP_PORT=3921
|
||||
LAKE_LAMP_PUBLIC_URL=https://example.invalid/authz
|
||||
LAKE_LAMP_TARGETS=JD-FD-PRIMARY,BS-GZ-006
|
||||
LAKE_LAMP_APPROVAL_TTL=10800
|
||||
LAKE_LAMP_SESSION_TTL=10800
|
||||
LAKE_LAMP_MAX_SESSION_LIFETIME=86400
|
||||
LAKE_LAMP_STATE_FILE=/var/lib/guanghu/lake-lamp-authz/state.json
|
||||
LAKE_LAMP_MAPS_DIR=/etc/guanghu/navigation-maps
|
||||
LAKE_LAMP_MAP_STATE_FILE=/var/lib/guanghu/lake-lamp-authz/map-acks.json
|
||||
LAKE_LAMP_REPO_GRANT_DIR=/var/lib/guanghu/repo-authorizations
|
||||
LAKE_LAMP_PUBLIC_CREATE_LIMIT=8
|
||||
LAKE_LAMP_PUBLIC_CREATE_GLOBAL_LIMIT=60
|
||||
LAKE_LAMP_PUBLIC_MAIL_LIMIT=3
|
||||
LAKE_LAMP_PUBLIC_MAIL_GLOBAL_LIMIT=30
|
||||
LAKE_LAMP_REQUEST_TOKEN=SET_IN_PRIVATE_SERVER_FILE
|
||||
LAKE_LAMP_OWNER_EMAIL=SET_IN_PRIVATE_SERVER_FILE
|
||||
LAKE_LAMP_APPROVERS_FILE=/etc/guanghu/secrets/approvers.json
|
||||
SMTP_HOST=smtp.qq.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=SET_IN_PRIVATE_SERVER_FILE
|
||||
QQ_SMTP_AUTH_CODE=SET_IN_PRIVATE_SERVER_FILE
|
||||
LAKE_LAMP_ARCHITECTURE_PROVISION_SOCKET=/run/guanghu-architecture-provision/provision.sock
|
||||
ARCHITECTURE_PROVISION_REPO_URL=https://guanghulab.com/fifth-domain/bingshuo/fifth-domain.git
|
||||
ARCHITECTURE_PROVISION_REPO_DIR=/var/lib/guanghu/architecture-provision/repo
|
||||
ARCHITECTURE_PROVISION_RELEASES_DIR=/opt/guanghu/architecture-releases
|
||||
ARCHITECTURE_PROVISION_UNIT_DIR=/etc/systemd/system
|
||||
ARCHITECTURE_PROVISION_RECEIPTS_DIR=/var/lib/guanghu/architecture-provision/receipts
|
||||
109
server-tools/lake-lamp-authz/authorize-repo-push.js
Executable file
109
server-tools/lake-lamp-authz/authorize-repo-push.js
Executable file
|
|
@ -0,0 +1,109 @@
|
|||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const DEFAULT_URL = "https://guanghulab.com/authz";
|
||||
|
||||
async function authorizeRepoPush(options, deps = {}) {
|
||||
const fetchImpl = deps.fetch || fetch;
|
||||
const sleep = deps.sleep || (milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)));
|
||||
const output = deps.output || (line => process.stdout.write(`${line}\n`));
|
||||
const baseUrl = String(options.url || DEFAULT_URL).replace(/\/$/, "");
|
||||
const persona = required(options.persona, "persona");
|
||||
const repo = normalizeRepo(required(options.repo, "repo"));
|
||||
const target = options.target || "JD-FD-PRIMARY";
|
||||
const pollMilliseconds = positiveNumber(options.poll, 5000);
|
||||
const request = await requestJson(fetchImpl, `${baseUrl}/api/public/workorders`, {
|
||||
system_entry: "光湖语言人格系统当前实例",
|
||||
origin_software: options.software || "仓库推送客户端",
|
||||
origin_model: options.model || "未声明模型",
|
||||
origin_instance: options.instance || "当前实例",
|
||||
persona_id: persona,
|
||||
persona_name: options.name || persona,
|
||||
target,
|
||||
scope: "repo-push",
|
||||
action: "push-repository",
|
||||
description: options.description || `申请推送 ${repo}`,
|
||||
});
|
||||
|
||||
output("[LL-WORKORDER-CREATED] 无执行权申请单已创建;尚未发送邮件,也没有推送权限。");
|
||||
output(`REQUEST_URL=${request.request_url}`);
|
||||
output("请把 REQUEST_URL 交给冰朔并保持本命令运行。冰朔打开页面后,服务器才发送预登记邮箱邮件。");
|
||||
output("不需要向冰朔索要邮箱、授权码、验证码、密码或任何令牌。");
|
||||
|
||||
const deadline = Date.now() + Number(request.expires_in || 900) * 1000;
|
||||
let session;
|
||||
while (Date.now() < deadline) {
|
||||
const response = await fetchImpl(`${baseUrl}/api/workorders/${request.workorder_id}/claim`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${request.claim_token}` },
|
||||
});
|
||||
const payload = await readPayload(response);
|
||||
if (response.status === 200) { session = payload; break; }
|
||||
if (response.status !== 202 || payload.error !== "approval_pending") {
|
||||
throw new Error(payload.error || `claim failed (${response.status})`);
|
||||
}
|
||||
await sleep(pollMilliseconds);
|
||||
}
|
||||
if (!session) throw new Error("authorization request expired before approval");
|
||||
|
||||
const common = { persona_id: persona, target, scope: "repo-push" };
|
||||
const map = await requestJson(fetchImpl, `${baseUrl}/api/navigation-map/read`, common, session.session_token);
|
||||
await requestJson(fetchImpl, `${baseUrl}/api/navigation-map/ack`, { ...common, map_hash: map.map_hash }, session.session_token);
|
||||
const grant = await requestJson(fetchImpl, `${baseUrl}/api/repo-push/grant`, { ...common, repo }, session.session_token);
|
||||
output(`[LL-REPO-PUSH-GRANTED] ${grant.repo} 已获得限时推送许可。现在重试原 git push。`);
|
||||
return grant;
|
||||
}
|
||||
|
||||
async function requestJson(fetchImpl, url, body, bearerToken = "") {
|
||||
const headers = { "content-type": "application/json" };
|
||||
if (bearerToken) headers.authorization = `Bearer ${bearerToken}`;
|
||||
const response = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body) });
|
||||
const payload = await readPayload(response);
|
||||
if (!response.ok) throw new Error(payload.error || `request failed (${response.status})`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function readPayload(response) {
|
||||
try { return await response.json(); } catch { return {}; }
|
||||
}
|
||||
|
||||
function normalizeRepo(value) {
|
||||
const repo = String(value).trim().toLowerCase().replace(/\.git$/, "");
|
||||
if (!/^bingshuo\/[a-z0-9._-]+$/.test(repo)) throw new Error("--repo must be bingshuo/<repository>");
|
||||
return repo;
|
||||
}
|
||||
|
||||
function required(value, name) {
|
||||
if (!value) throw new Error(`--${name} is required`);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function positiveNumber(value, fallback) {
|
||||
if (value === undefined) return fallback;
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || number <= 0) throw new Error("--poll must be a positive millisecond value");
|
||||
return number;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {};
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const key = String(argv[index] || "").replace(/^--/, "");
|
||||
if (!key || argv[index + 1] === undefined) throw new Error(`invalid argument: ${argv[index] || ""}`);
|
||||
result[key] = argv[index + 1];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await authorizeRepoPush(parseArgs(process.argv.slice(2)));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch(error => {
|
||||
process.stderr.write(`[LL-REPO-PUSH-AUTH-FAILED] ${error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { authorizeRepoPush, normalizeRepo, parseArgs };
|
||||
60
server-tools/lake-lamp-authz/authorize-repo-push.test.js
Normal file
60
server-tools/lake-lamp-authz/authorize-repo-push.test.js
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { createApp } = require("./server");
|
||||
const { authorizeRepoPush } = require("./authorize-repo-push");
|
||||
|
||||
test("one helper command completes owner handoff, map acknowledgement, and repo grant", async () => {
|
||||
const mail = [];
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "lake-lamp-repo-push-"));
|
||||
const mapGate = {
|
||||
read: target => ({ hash: `map-${target}`, data: { node_id: target } }),
|
||||
ack: () => ({ ok: true }),
|
||||
verify: () => ({ ok: true }),
|
||||
};
|
||||
const app = createApp({
|
||||
ownerEmail: "owner@example.invalid",
|
||||
publicBaseUrl: "https://example.invalid/authz",
|
||||
targets: ["JD-FD-PRIMARY"],
|
||||
stateFile: "",
|
||||
repoGrantDir: directory,
|
||||
mapGate,
|
||||
sendEmail: async message => { mail.push(message); return true; },
|
||||
});
|
||||
await new Promise(resolve => app.listen(0, "127.0.0.1", resolve));
|
||||
const base = `http://127.0.0.1:${app.address().port}`;
|
||||
const lines = [];
|
||||
try {
|
||||
const grantPromise = authorizeRepoPush({
|
||||
url: base,
|
||||
persona: "ICE-GL-ZY001",
|
||||
name: "铸渊",
|
||||
repo: "bingshuo/fifth-domain",
|
||||
poll: 1,
|
||||
}, {
|
||||
output: line => lines.push(line),
|
||||
sleep: milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
|
||||
});
|
||||
|
||||
while (!lines.some(line => line.startsWith("REQUEST_URL="))) await new Promise(resolve => setTimeout(resolve, 1));
|
||||
const requestUrl = lines.find(line => line.startsWith("REQUEST_URL=")).slice("REQUEST_URL=".length);
|
||||
const requestPath = new URL(requestUrl).pathname.replace("/authz", "");
|
||||
assert.equal((await fetch(`${base}${requestPath}`, { method: "POST" })).status, 200);
|
||||
assert.equal(mail.length, 1);
|
||||
const approvalPath = new URL(mail[0].approvalUrl).pathname.replace("/authz", "");
|
||||
assert.equal((await fetch(`${base}${approvalPath}`, { method: "POST" })).status, 200);
|
||||
|
||||
const grant = await grantPromise;
|
||||
assert.equal(grant.repo, "bingshuo/fifth-domain");
|
||||
assert.equal(grant.target, "JD-FD-PRIMARY");
|
||||
assert.ok(lines.some(line => line.startsWith("[LL-REPO-PUSH-GRANTED]")));
|
||||
assert.ok(fs.existsSync(path.join(directory, "bingshuo__fifth-domain.json")));
|
||||
} finally {
|
||||
await new Promise(resolve => app.close(resolve));
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
16
server-tools/lake-lamp-authz/guanghu-authz.nginx.conf
Normal file
16
server-tools/lake-lamp-authz/guanghu-authz.nginx.conf
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Public approval surface. The service itself remains on JD loopback and this
|
||||
# route is reached through a permitopen-restricted SSH tunnel.
|
||||
location /authz/ {
|
||||
proxy_pass http://127.0.0.1:19221/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
# Overwrite untrusted client input so application-level rate limits use the
|
||||
# address observed by this public edge, not a spoofed left-most value.
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Prefix /authz;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 30s;
|
||||
client_max_body_size 64k;
|
||||
}
|
||||
19
server-tools/lake-lamp-authz/guanghu-jd-authz-tunnel.service
Normal file
19
server-tools/lake-lamp-authz/guanghu-jd-authz-tunnel.service
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[Unit]
|
||||
Description=Guanghu BS-GZ-006 to JD Lake Lamp authorization tunnel
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/bin/ssh -NT -F /etc/guanghu/jd-authz-tunnel-ssh-config jd-authz-target
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=read-only
|
||||
ProtectSystem=strict
|
||||
ReadOnlyPaths=/etc/guanghu/jd-authz-tunnel-ssh-config /etc/guanghu/secrets/ssh/bs_gz_006_to_jd_authz_proxy /root/.ssh/known_hosts
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
25
server-tools/lake-lamp-authz/install-architecture-provisioner.sh
Executable file
25
server-tools/lake-lamp-authz/install-architecture-provisioner.sh
Executable file
|
|
@ -0,0 +1,25 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ${EUID} -ne 0 ]]; then
|
||||
echo "run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
install_root=/opt/guanghu/lake-lamp-authz
|
||||
|
||||
install -d -m 0755 "$install_root"
|
||||
for file in server.js workorder-manager.js map-gate.js smtp-mailer.js action-client.js architecture-provision-broker.js; do
|
||||
install -m 0644 "$script_dir/$file" "$install_root/$file"
|
||||
done
|
||||
install -m 0644 "$script_dir/lake-lamp-architecture-provision.service" /etc/systemd/system/lake-lamp-architecture-provision.service
|
||||
install -d -m 0700 /var/lib/guanghu/architecture-provision
|
||||
install -d -m 0755 /opt/guanghu/architecture-releases
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now lake-lamp-architecture-provision.service
|
||||
systemctl restart lake-lamp-authz.service
|
||||
systemctl is-active --quiet lake-lamp-architecture-provision.service
|
||||
systemctl is-active --quiet lake-lamp-authz.service
|
||||
echo ARCHITECTURE_PROVISIONER_INSTALLED
|
||||
24
server-tools/lake-lamp-authz/install-gz-proxy.sh
Executable file
24
server-tools/lake-lamp-authz/install-gz-proxy.sh
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
JD_HOST=${1:?JD host is required}
|
||||
|
||||
install -m 644 /tmp/guanghu-jd-authz-tunnel.service /etc/systemd/system/guanghu-jd-authz-tunnel.service
|
||||
install -m 644 /tmp/guanghu-authz.nginx.conf /etc/nginx/snippets/guanghu-authz.conf
|
||||
chmod 600 /etc/guanghu/secrets/ssh/bs_gz_006_to_jd_authz_proxy
|
||||
|
||||
install -m 600 /dev/null /etc/guanghu/jd-authz-tunnel-ssh-config
|
||||
sed \
|
||||
-e "s/JD_PUBLIC_ADDRESS/${JD_HOST}/" \
|
||||
/tmp/jd-authz-tunnel-ssh-config.example > /etc/guanghu/jd-authz-tunnel-ssh-config
|
||||
|
||||
if ! grep -q "include /etc/nginx/snippets/guanghu-authz.conf;" /etc/nginx/sites-enabled/guanghulab; then
|
||||
sed -i '0,/server_name guanghulab.com;/s##server_name guanghulab.com;\n include /etc/nginx/snippets/guanghu-authz.conf;#' /etc/nginx/sites-enabled/guanghulab
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now guanghu-jd-authz-tunnel.service
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
|
||||
rm -f /tmp/guanghu-jd-authz-tunnel.service /tmp/guanghu-authz.nginx.conf /tmp/jd-authz-tunnel-ssh-config.example
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
Host jd-authz-target
|
||||
HostName JD_PUBLIC_ADDRESS
|
||||
User root
|
||||
IdentityFile /etc/guanghu/secrets/ssh/bs_gz_006_to_jd_authz_proxy
|
||||
IdentitiesOnly yes
|
||||
LocalForward 127.0.0.1:19221 127.0.0.1:3921
|
||||
ExitOnForwardFailure yes
|
||||
ServerAliveInterval 30
|
||||
ServerAliveCountMax 3
|
||||
25
server-tools/lake-lamp-authz/lake-lamp-action-broker.service
Normal file
25
server-tools/lake-lamp-authz/lake-lamp-action-broker.service
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
[Unit]
|
||||
Description=Guanghu fixed-action broker for Lake Lamp authorization
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
EnvironmentFile=/etc/guanghu/secrets/lake-lamp/action-broker.env
|
||||
ExecStart=/usr/bin/node /opt/guanghu/lake-lamp-authz/action-broker.js
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadOnlyPaths=/etc/guanghu/action-broker-ssh-config /etc/guanghu/secrets/lake-lamp
|
||||
RuntimeDirectory=guanghu
|
||||
RuntimeDirectoryMode=0755
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
LockPersonality=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
[Unit]
|
||||
Description=Guanghu approved architecture initial-provision broker
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
EnvironmentFile=/etc/guanghu/secrets/lake-lamp/action-broker.env
|
||||
ExecStart=/usr/bin/node /opt/guanghu/lake-lamp-authz/architecture-provision-broker.js
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/guanghu/architecture-provision /opt/guanghu/architecture-releases /etc/systemd/system
|
||||
RuntimeDirectory=guanghu-architecture-provision
|
||||
RuntimeDirectoryMode=0755
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
LockPersonality=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue