diff --git a/deployment/navigation-maps/BS-GZ-006.json b/deployment/navigation-maps/BS-GZ-006.json
new file mode 100644
index 0000000..00aa1f3
--- /dev/null
+++ b/deployment/navigation-maps/BS-GZ-006.json
@@ -0,0 +1,13 @@
+{
+ "schema": "guanghu.navigation-map/v1",
+ "node_id": "BS-GZ-006",
+ "role": "国内备案前门",
+ "modules": [
+ { "code": "GZ-WEB-01", "name": "备案主页与 HTTPS", "bind": "public:https", "owner": "nginx" },
+ { "code": "GZ-JD-01", "name": "京东应用专用隧道", "bind": "loopback:18088", "owner": "systemd" },
+ { "code": "GZ-AUTH-01", "name": "小湖灯授权专用隧道", "bind": "loopback:19221", "owner": "systemd" },
+ { "code": "GZ-FRG-01", "name": "国内 Forgejo 专用隧道", "bind": "loopback:19301", "owner": "systemd" },
+ { "code": "GZ-LEGACY-01", "name": "历史服务与仓库", "bind": "local-services", "owner": "pm2" }
+ ],
+ "mandatory_order": ["read-navigation-map", "ack-current-map", "execute-registered-action"]
+}
diff --git a/deployment/navigation-maps/BS-SG-002.json b/deployment/navigation-maps/BS-SG-002.json
new file mode 100644
index 0000000..9213536
--- /dev/null
+++ b/deployment/navigation-maps/BS-SG-002.json
@@ -0,0 +1,10 @@
+{
+ "schema": "guanghu.navigation-map/v1",
+ "node_id": "BS-SG-002",
+ "role": "新加坡面孔与 Web 节点",
+ "modules": [
+ { "code": "SG2-GTW-01", "name": "历史 Gatekeeper", "bind": "3910", "owner": "pm2" },
+ { "code": "SG2-WEB-01", "name": "面孔与 Web 服务组", "bind": "registered-pm2-apps", "owner": "pm2" }
+ ],
+ "upstream": "JD-FD-PRIMARY"
+}
diff --git a/deployment/navigation-maps/BS-SG-003.json b/deployment/navigation-maps/BS-SG-003.json
new file mode 100644
index 0000000..5d4babb
--- /dev/null
+++ b/deployment/navigation-maps/BS-SG-003.json
@@ -0,0 +1,10 @@
+{
+ "schema": "guanghu.navigation-map/v1",
+ "node_id": "BS-SG-003",
+ "role": "新加坡备份、存储与海外下载中继",
+ "modules": [
+ { "code": "SG3-GTW-01", "name": "历史 Gatekeeper", "bind": "3910", "owner": "pm2" },
+ { "code": "SG3-RLY-01", "name": "海外依赖下载中继", "bind": "ssh-only", "owner": "root" }
+ ],
+ "upstream": "JD-FD-PRIMARY"
+}
diff --git a/deployment/navigation-maps/JD-FD-PRIMARY.json b/deployment/navigation-maps/JD-FD-PRIMARY.json
new file mode 100644
index 0000000..a766c66
--- /dev/null
+++ b/deployment/navigation-maps/JD-FD-PRIMARY.json
@@ -0,0 +1,15 @@
+{
+ "schema": "guanghu.navigation-map/v1",
+ "node_id": "JD-FD-PRIMARY",
+ "role": "第五域国内主节点与统一运维入口",
+ "modules": [
+ { "code": "JD-GTW-01", "name": "Gatekeeper v3.2", "bind": "loopback:3911", "owner": "systemd" },
+ { "code": "JD-AUTH-01", "name": "小湖灯邮件链接授权", "bind": "loopback:3921", "owner": "systemd" },
+ { "code": "JD-FRG-01", "name": "第五域国内 Forgejo", "bind": "loopback:3001", "owner": "systemd" },
+ { "code": "JD-HUB-01", "name": "京东应用入口", "bind": "loopback:8088", "owner": "systemd" },
+ { "code": "JD-SEN-01", "name": "状态变化哨兵", "bind": "timer:15m", "owner": "systemd" },
+ { "code": "JD-ROUTE-01", "name": "个人节点 SSH 路由", "bind": "private-keys", "owner": "root" }
+ ],
+ "mandatory_order": ["read-navigation-map", "ack-current-map", "execute-registered-action"],
+ "forbidden": ["arbitrary-shell-through-authorization-api", "cross-target-session-reuse", "secret-in-repository"]
+}
diff --git a/deployment/navigation-maps/ZY-SG-006.json b/deployment/navigation-maps/ZY-SG-006.json
new file mode 100644
index 0000000..324dd77
--- /dev/null
+++ b/deployment/navigation-maps/ZY-SG-006.json
@@ -0,0 +1,10 @@
+{
+ "schema": "guanghu.navigation-map/v1",
+ "node_id": "ZY-SG-006",
+ "role": "新加坡语料与推理节点",
+ "modules": [
+ { "code": "SG6-GTW-01", "name": "历史 Gatekeeper", "bind": "3910", "owner": "pm2" },
+ { "code": "SG6-AI-01", "name": "语料与推理服务组", "bind": "registered-pm2-apps", "owner": "pm2" }
+ ],
+ "upstream": "JD-FD-PRIMARY"
+}
diff --git a/lighthouse/GLW-BROADCAST-GDE-001.hdlp b/lighthouse/GLW-BROADCAST-GDE-001.hdlp
index b2cebc2..2620f02 100644
--- a/lighthouse/GLW-BROADCAST-GDE-001.hdlp
+++ b/lighthouse/GLW-BROADCAST-GDE-001.hdlp
@@ -63,10 +63,10 @@ Token 不再等于 root 密码,仅用于识别"是谁在请求"。每个 Token
| 序号 | 服务器 | 人类 | 邮箱 | 人格体 | 绑定来源 |
|------|--------|------|------|--------|----------|
-| 1 | BS-SG-001 | ICE-GL∞ (冰朔) | 565183519@qq.com | ICE-GL-ZY001 (铸渊) | BS-SG-001 |
-| 2 | BS-GZ-006 | ICE-GL∞ (冰朔) | 565183519@qq.com | ICE-GL-ZY001 (铸渊) | BS-SG-001, BS-GZ-006 |
-| 3 | ZZ-SV-001 | 之之 | 3205323524@qq.com | * (全部) | ZZ-SV-001, ZZ-GZ-001 |
-| 4 | ZZ-GZ-001 | 之之 | 3205323524@qq.com | * (全部) | ZZ-SV-001, ZZ-GZ-001 |
+| 1 | BS-SG-001 | ICE-GL∞ (冰朔) | ICE_GL_OWNER_EMAIL_REDACTED | ICE-GL-ZY001 (铸渊) | BS-SG-001 |
+| 2 | BS-GZ-006 | ICE-GL∞ (冰朔) | ICE_GL_OWNER_EMAIL_REDACTED | ICE-GL-ZY001 (铸渊) | BS-SG-001, BS-GZ-006 |
+| 3 | ZZ-SV-001 | 之之 | EMAIL_REDACTED@qq.com | * (全部) | ZZ-SV-001, ZZ-GZ-001 |
+| 4 | ZZ-GZ-001 | 之之 | EMAIL_REDACTED@qq.com | * (全部) | ZZ-SV-001, ZZ-GZ-001 |
> 注册新三元组需要冰朔主权者验证码 → 调用 `/auth/register`
@@ -102,7 +102,7 @@ Content-Type: application/json
```json
{
"ok": true,
- "message": "验证码已发送至 565183519@qq.com",
+ "message": "验证码已发送至 ICE_GL_OWNER_EMAIL_REDACTED",
"challenge_id": "abc123...",
"expires_in": 300,
"op_type": "code-repo",
@@ -196,7 +196,7 @@ Token 存储在服务器的 `~/.guanghu-engine/secret` 文件中。
| **监听端口** | 3911 |
| **进程管理** | PM2 (`pm2 list` → engine-v3) |
| **日志位置** | `/opt/zhuyuan/gatekeeper/engine.log` |
-| **SMTP 发件** | smtp.qq.com:465 · 565183519@qq.com |
+| **SMTP 发件** | smtp.qq.com:465 · ICE_GL_OWNER_EMAIL_REDACTED |
---
@@ -230,7 +230,7 @@ Token 存储在服务器的 `~/.guanghu-engine/secret` 文件中。
→ 来源校验: 请求来自 BS-SG-001 ✅
→ 白名单匹配: BS-SG-001 + ICE-GL∞ + ICE-GL-ZY001 ✅
→ 生成 6 位验证码: 482901
- → 发送邮件到 565183519@qq.com
+ → 发送邮件到 ICE_GL_OWNER_EMAIL_REDACTED
3. 冰朔手机收到邮件:
"📦 铸渊 (ICE-GL-ZY001) 请求操作代码仓库
diff --git a/server-tools/guanghulab-front-door/index.html b/server-tools/guanghulab-front-door/index.html
index 2883585..c5322b6 100644
--- a/server-tools/guanghulab-front-door/index.html
+++ b/server-tools/guanghulab-front-door/index.html
@@ -3,7 +3,7 @@
-
+
光湖 · 国内入口
@@ -25,9 +25,9 @@
GUANGHU · DOMESTIC GATEWAY
从这里,进入
光湖语言世界
- 广州节点保留备案域名与轻量入口,应用和计算逐步迁往京东云第五域主节点。入口稳定,后端可以继续生长。
+ 广州节点保留备案域名与轻量入口,应用和计算逐步迁往国内第五域主节点。入口稳定,后端可以继续生长。
@@ -38,7 +38,7 @@
ROUTES
选择一条路径
- 页面在广州,重计算在京东。每张卡片都是一个真实入口。
+ 页面在广州,应用与计算运行在国内第五域主节点。每张卡片都是一个真实入口。
↗
+
+
+ 05
+ 史
+
+
ARCHIVE · SINGAPORE
+
历史仓库
+
进入新加坡原有仓库,查询第五域、Guanghulab 与各历史研发事实源。
+
+ ↗
+
广州 · BS-GZ-006备案域名 / HTTPS / 前门展示
→
- 北京 · JD-FD-PRIMARY应用运行 / 计算 / 调度中心
+ 国内第五域主节点应用运行 / 计算 / 调度中心
diff --git a/server-tools/guanghulab-front-door/test/site.test.js b/server-tools/guanghulab-front-door/test/site.test.js
index 2111213..2785d20 100644
--- a/server-tools/guanghulab-front-door/test/site.test.js
+++ b/server-tools/guanghulab-front-door/test/site.test.js
@@ -15,10 +15,22 @@ test("front door keeps the legally required ICP link", () => {
test("front door routes compute work through the JD hub", () => {
assert.match(html, /href="\/jd\/"/);
- assert.match(html, /京东云主节点/);
+ assert.match(html, /国内第五域主节点/);
assert.match(html, /href="https:\/\/guanghubingshuo\.com\/code\/"/);
});
+test("front door separates the domestic primary repository from historical repositories", () => {
+ assert.match(html, /href="\/fifth-domain\/bingshuo\/fifth-domain"/);
+ assert.match(html, /第五域国内主代码仓库/);
+ assert.match(html, /历史仓库/);
+ assert.match(html, /href="https:\/\/guanghubingshuo\.com\/code\/"/);
+});
+
+test("public copy uses the domestic Fifth Domain name instead of the cloud vendor", () => {
+ assert.doesNotMatch(html, /京东云/);
+ assert.match(html, /国内第五域主节点/);
+});
+
test("public page never embeds infrastructure addresses or credentials", () => {
assert.doesNotMatch(html, /(?:\d{1,3}\.){3}\d{1,3}/);
assert.doesNotMatch(html, /zy_gtw_/);
diff --git a/server-tools/jd-forgejo/README.md b/server-tools/jd-forgejo/README.md
new file mode 100644
index 0000000..d489717
--- /dev/null
+++ b/server-tools/jd-forgejo/README.md
@@ -0,0 +1,14 @@
+# 京东云第五域国内主代码仓库
+
+- 与广州/新加坡现役节点一致的原生 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 授权凭据。
+
+首次部署从已接入的广州节点复制经过验证的现役二进制,避开国内节点拉取
+海外大镜像的失败路径;升级必须另开批次、备份数据库并人工验证。
diff --git a/server-tools/jd-forgejo/app.ini.template b/server-tools/jd-forgejo/app.ini.template
new file mode 100644
index 0000000..a2ad25b
--- /dev/null
+++ b/server-tools/jd-forgejo/app.ini.template
@@ -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
diff --git a/server-tools/jd-forgejo/guanghu-forgejo.nginx.conf b/server-tools/jd-forgejo/guanghu-forgejo.nginx.conf
new file mode 100644
index 0000000..947239a
--- /dev/null
+++ b/server-tools/jd-forgejo/guanghu-forgejo.nginx.conf
@@ -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;
+}
diff --git a/server-tools/jd-forgejo/guanghu-forgejo.service b/server-tools/jd-forgejo/guanghu-forgejo.service
new file mode 100644
index 0000000..1ad9acc
--- /dev/null
+++ b/server-tools/jd-forgejo/guanghu-forgejo.service
@@ -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
diff --git a/server-tools/jd-forgejo/guanghu-jd-forgejo-tunnel.service b/server-tools/jd-forgejo/guanghu-jd-forgejo-tunnel.service
new file mode 100644
index 0000000..004b314
--- /dev/null
+++ b/server-tools/jd-forgejo/guanghu-jd-forgejo-tunnel.service
@@ -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
diff --git a/server-tools/jd-forgejo/install-gz-proxy.sh b/server-tools/jd-forgejo/install-gz-proxy.sh
new file mode 100755
index 0000000..2e07dbd
--- /dev/null
+++ b/server-tools/jd-forgejo/install-gz-proxy.sh
@@ -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
diff --git a/server-tools/jd-forgejo/jd-forgejo-tunnel-ssh-config.example b/server-tools/jd-forgejo/jd-forgejo-tunnel-ssh-config.example
new file mode 100644
index 0000000..3f639db
--- /dev/null
+++ b/server-tools/jd-forgejo/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
diff --git a/server-tools/jd-forgejo/native.test.js b/server-tools/jd-forgejo/native.test.js
new file mode 100644
index 0000000..580c4b9
--- /dev/null
+++ b/server-tools/jd-forgejo/native.test.js
@@ -0,0 +1,20 @@
+"use strict";
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+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/);
+});
diff --git a/server-tools/lake-lamp-authz/README.md b/server-tools/lake-lamp-authz/README.md
new file mode 100644
index 0000000..23c6e43
--- /dev/null
+++ b/server-tools/lake-lamp-authz/README.md
@@ -0,0 +1,18 @@
+# 小湖灯邮件链接授权服务
+
+这是 Gatekeeper v3.2 前面的人类批准层。人格体只能用无执行权限的
+request credential 提交工单;冰朔点击邮件链接后,人格体才能一次性领取
+一个绑定到 `persona + target server + scope + action` 的一小时会话令牌。邮件工单
+链接同样保留一小时,避免要求冰朔立即处理。
+
+安全边界:
+
+- 邮箱、QQ 数字、SMTP 授权码和 request credential 只存在于私密文件;
+- 浏览器批准链接为一次性随机令牌,服务端仅持久化摘要;
+- claim token 与 session token 均仅向申请人格体返回一次,磁盘只保存摘要;
+- 换目标服务器时旧 session 必然返回 `target_mismatch`;
+- 本服务不接受任意 shell 命令,只签发登记动作的会话;
+- 广州公开代理只应暴露 `/approve/`、`/api/workorders` 与 claim 路由,服务本体监听 JD 回环地址。
+
+`request-workorder.js` 从临时环境变量读取 QQ 数字,在内存中补全邮箱并只发送
+SHA-256 指纹;数字本身不会写入请求正文、状态文件或代码仓库。
diff --git a/server-tools/lake-lamp-authz/authorization.env.example b/server-tools/lake-lamp-authz/authorization.env.example
new file mode 100644
index 0000000..cc30f0c
--- /dev/null
+++ b/server-tools/lake-lamp-authz/authorization.env.example
@@ -0,0 +1,16 @@
+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=3600
+LAKE_LAMP_SESSION_TTL=3600
+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_REQUEST_TOKEN=SET_IN_PRIVATE_SERVER_FILE
+LAKE_LAMP_OWNER_EMAIL=SET_IN_PRIVATE_SERVER_FILE
+SMTP_HOST=smtp.qq.com
+SMTP_PORT=465
+SMTP_USER=SET_IN_PRIVATE_SERVER_FILE
+QQ_SMTP_AUTH_CODE=SET_IN_PRIVATE_SERVER_FILE
diff --git a/server-tools/lake-lamp-authz/guanghu-authz.nginx.conf b/server-tools/lake-lamp-authz/guanghu-authz.nginx.conf
new file mode 100644
index 0000000..b4b6a98
--- /dev/null
+++ b/server-tools/lake-lamp-authz/guanghu-authz.nginx.conf
@@ -0,0 +1,14 @@
+# 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;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ 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;
+}
diff --git a/server-tools/lake-lamp-authz/guanghu-jd-authz-tunnel.service b/server-tools/lake-lamp-authz/guanghu-jd-authz-tunnel.service
new file mode 100644
index 0000000..b725036
--- /dev/null
+++ b/server-tools/lake-lamp-authz/guanghu-jd-authz-tunnel.service
@@ -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
diff --git a/server-tools/lake-lamp-authz/install-gz-proxy.sh b/server-tools/lake-lamp-authz/install-gz-proxy.sh
new file mode 100755
index 0000000..fe7db52
--- /dev/null
+++ b/server-tools/lake-lamp-authz/install-gz-proxy.sh
@@ -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
diff --git a/server-tools/lake-lamp-authz/jd-authz-tunnel-ssh-config.example b/server-tools/lake-lamp-authz/jd-authz-tunnel-ssh-config.example
new file mode 100644
index 0000000..0b5e0e5
--- /dev/null
+++ b/server-tools/lake-lamp-authz/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
diff --git a/server-tools/lake-lamp-authz/lake-lamp-authz.service b/server-tools/lake-lamp-authz/lake-lamp-authz.service
new file mode 100644
index 0000000..7a776dd
--- /dev/null
+++ b/server-tools/lake-lamp-authz/lake-lamp-authz.service
@@ -0,0 +1,24 @@
+[Unit]
+Description=Guanghu Lake Lamp email-link authorization service
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=guanghu-authz
+Group=guanghu-authz
+WorkingDirectory=/opt/guanghu/lake-lamp-authz
+EnvironmentFile=/etc/guanghu/secrets/lake-lamp/authorization.env
+ExecStart=/usr/bin/node /opt/guanghu/lake-lamp-authz/server.js
+Restart=on-failure
+RestartSec=3
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=/var/lib/guanghu/lake-lamp-authz /var/lib/guanghu/repo-authorizations
+RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
+LockPersonality=true
+
+[Install]
+WantedBy=multi-user.target
diff --git a/server-tools/lake-lamp-authz/map-gate.js b/server-tools/lake-lamp-authz/map-gate.js
new file mode 100644
index 0000000..15ff1ec
--- /dev/null
+++ b/server-tools/lake-lamp-authz/map-gate.js
@@ -0,0 +1,45 @@
+"use strict";
+const crypto = require("node:crypto");
+const fs = require("node:fs");
+const path = require("node:path");
+
+class MapGate {
+ constructor({ mapsDir = "/etc/guanghu/navigation-maps", stateFile = "" } = {}) {
+ this.mapsDir = mapsDir;
+ this.stateFile = stateFile;
+ this.acks = new Map();
+ this.load();
+ }
+ read(target) {
+ if (!/^[A-Z0-9-]+$/.test(target)) throw new Error("invalid_target");
+ const data = JSON.parse(fs.readFileSync(path.join(this.mapsDir, `${target}.json`), "utf8"));
+ const canonical = JSON.stringify(data);
+ return { data, hash: crypto.createHash("sha256").update(canonical).digest("hex") };
+ }
+ ack(sessionToken, target, mapHash, now = Date.now() / 1000, ttl = 3600) {
+ const current = this.read(target);
+ if (!safeEqual(current.hash, mapHash)) return { ok: false, reason: "map_hash_mismatch" };
+ this.acks.set(hash(sessionToken), { target, mapHash, expiresAt: now + ttl });
+ this.persist();
+ return { ok: true, expiresAt: now + ttl };
+ }
+ verify(sessionToken, target, mapHash, now = Date.now() / 1000) {
+ const ack = this.acks.get(hash(sessionToken));
+ if (!ack) return { ok: false, reason: "map_not_acknowledged" };
+ if (now > ack.expiresAt) return { ok: false, reason: "map_ack_expired" };
+ if (ack.target !== target) return { ok: false, reason: "map_target_mismatch" };
+ if (!safeEqual(ack.mapHash, mapHash)) return { ok: false, reason: "map_changed" };
+ return { ok: true };
+ }
+ load(now = Date.now() / 1000) {
+ if (!this.stateFile || !fs.existsSync(this.stateFile)) return;
+ try { for (const [key, value] of Object.entries(JSON.parse(fs.readFileSync(this.stateFile, "utf8")).acks || {})) if (value.expiresAt >= now) this.acks.set(key, value); } catch { this.acks.clear(); }
+ }
+ persist() {
+ if (!this.stateFile) return;
+ fs.writeFileSync(this.stateFile, JSON.stringify({ version: 1, acks: Object.fromEntries(this.acks) }), { mode: 0o600 });
+ }
+}
+function hash(value) { return crypto.createHash("sha256").update(String(value)).digest("hex"); }
+function safeEqual(left, right) { const a = Buffer.from(String(left)); const b = Buffer.from(String(right)); return a.length === b.length && crypto.timingSafeEqual(a, b); }
+module.exports = { MapGate };
diff --git a/server-tools/lake-lamp-authz/map-gate.test.js b/server-tools/lake-lamp-authz/map-gate.test.js
new file mode 100644
index 0000000..423977b
--- /dev/null
+++ b/server-tools/lake-lamp-authz/map-gate.test.js
@@ -0,0 +1,37 @@
+"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 { MapGate } = require("./map-gate");
+
+test("non-map actions stay locked until the exact current map is acknowledged", () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "map-gate-"));
+ try {
+ const maps = path.join(dir, "maps"); fs.mkdirSync(maps);
+ fs.writeFileSync(path.join(maps, "JD-FD-PRIMARY.json"), JSON.stringify({ node_id: "JD-FD-PRIMARY", modules: [{ code: "JD-GTW-01" }] }));
+ const gate = new MapGate({ mapsDir: maps, stateFile: path.join(dir, "state.json") });
+ const map = gate.read("JD-FD-PRIMARY");
+ assert.equal(gate.verify("session-token", "JD-FD-PRIMARY", map.hash).reason, "map_not_acknowledged");
+ assert.equal(gate.ack("session-token", "JD-FD-PRIMARY", map.hash, 100, 3600).ok, true);
+ assert.equal(gate.verify("session-token", "JD-FD-PRIMARY", map.hash, 101).ok, true);
+ assert.equal(gate.verify("session-token", "OTHER", map.hash, 101).reason, "map_target_mismatch");
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
+});
+
+test("changing the server map invalidates an old acknowledgement", () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "map-gate-"));
+ try {
+ const maps = path.join(dir, "maps"); fs.mkdirSync(maps);
+ const file = path.join(maps, "JD-FD-PRIMARY.json");
+ fs.writeFileSync(file, JSON.stringify({ node_id: "JD-FD-PRIMARY", modules: ["A"] }));
+ const gate = new MapGate({ mapsDir: maps });
+ const oldMap = gate.read("JD-FD-PRIMARY");
+ gate.ack("session-token", "JD-FD-PRIMARY", oldMap.hash, 100, 3600);
+ fs.writeFileSync(file, JSON.stringify({ node_id: "JD-FD-PRIMARY", modules: ["A", "B"] }));
+ const newMap = gate.read("JD-FD-PRIMARY");
+ assert.notEqual(oldMap.hash, newMap.hash);
+ assert.equal(gate.verify("session-token", "JD-FD-PRIMARY", newMap.hash, 101).reason, "map_changed");
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
+});
diff --git a/server-tools/lake-lamp-authz/request-workorder.js b/server-tools/lake-lamp-authz/request-workorder.js
new file mode 100644
index 0000000..18da5b5
--- /dev/null
+++ b/server-tools/lake-lamp-authz/request-workorder.js
@@ -0,0 +1,42 @@
+#!/usr/bin/env node
+"use strict";
+
+const crypto = require("node:crypto");
+const fs = require("node:fs");
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2));
+ const qqId = process.env.GUANGHU_OWNER_QQ_ID || "";
+ if (!/^\d{5,12}$/.test(qqId)) throw new Error("GUANGHU_OWNER_QQ_ID must be provided transiently");
+ const requestToken = process.env.LAKE_LAMP_REQUEST_TOKEN || readTrim(process.env.LAKE_LAMP_REQUEST_TOKEN_FILE || "");
+ if (!requestToken) throw new Error("request-only credential is unavailable");
+ const email = `${qqId}@qq.com`;
+ const recipientFingerprint = crypto.createHash("sha256").update(email.toLowerCase()).digest("hex");
+ const response = await fetch(`${String(args.url).replace(/\/$/, "")}/api/workorders`, {
+ method: "POST",
+ headers: { authorization: `Bearer ${requestToken}`, "content-type": "application/json" },
+ body: JSON.stringify({
+ persona_id: args.persona,
+ persona_name: args.name || args.persona,
+ target: args.target,
+ scope: args.scope,
+ action: args.action,
+ description: args.description || "",
+ recipient_fingerprint: recipientFingerprint,
+ }),
+ });
+ const payload = await response.json();
+ if (!response.ok) throw new Error(payload.error || `request failed (${response.status})`);
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
+}
+
+function parseArgs(argv) {
+ const result = {};
+ for (let i = 0; i < argv.length; i += 2) result[String(argv[i]).replace(/^--/, "")] = argv[i + 1];
+ for (const key of ["url", "persona", "target", "scope", "action"]) if (!result[key]) throw new Error(`--${key} is required`);
+ return result;
+}
+
+function readTrim(file) { return file && fs.existsSync(file) ? fs.readFileSync(file, "utf8").trim() : ""; }
+
+main().catch(error => { process.stderr.write(`lake-lamp request failed: ${error.message}\n`); process.exit(1); });
diff --git a/server-tools/lake-lamp-authz/server.js b/server-tools/lake-lamp-authz/server.js
new file mode 100644
index 0000000..35c6f79
--- /dev/null
+++ b/server-tools/lake-lamp-authz/server.js
@@ -0,0 +1,206 @@
+"use strict";
+
+const crypto = require("node:crypto");
+const fs = require("node:fs");
+const http = require("node:http");
+const path = require("node:path");
+const { WorkOrderManager } = require("./workorder-manager");
+const { MapGate } = require("./map-gate");
+const { sendSmtpMail } = require("./smtp-mailer");
+
+const DEFAULT_ACTIONS = Object.freeze({
+ "server-login": ["read-navigation-map", "inspect-services", "restart-registered-service"],
+ "server-ops": ["read-navigation-map", "inspect-services", "restart-registered-service"],
+ "repo-push": ["read-navigation-map", "push-repository"],
+});
+
+function createApp(options = {}) {
+ const requestToken = options.requestToken || process.env.LAKE_LAMP_REQUEST_TOKEN || "";
+ const ownerEmail = options.ownerEmail || process.env.LAKE_LAMP_OWNER_EMAIL || "";
+ const publicBaseUrl = String(options.publicBaseUrl || process.env.LAKE_LAMP_PUBLIC_URL || "").replace(/\/$/, "");
+ const targets = new Set(options.targets || splitCsv(process.env.LAKE_LAMP_TARGETS || "JD-FD-PRIMARY,BS-GZ-006"));
+ const actions = options.actions || DEFAULT_ACTIONS;
+ const manager = options.manager || new WorkOrderManager({
+ approvalTtl: Number(options.approvalTtl || process.env.LAKE_LAMP_APPROVAL_TTL || 15 * 60),
+ sessionTtl: Number(options.sessionTtl || process.env.LAKE_LAMP_SESSION_TTL || 60 * 60),
+ stateFile: Object.prototype.hasOwnProperty.call(options, "stateFile") ? options.stateFile : (process.env.LAKE_LAMP_STATE_FILE || "/var/lib/guanghu/lake-lamp-authz/state.json"),
+ });
+ const sendEmail = options.sendEmail || (message => sendSmtpMail({
+ ...message,
+ smtpHost: process.env.SMTP_HOST || "smtp.qq.com",
+ smtpPort: Number(process.env.SMTP_PORT || 465),
+ smtpUser: process.env.SMTP_USER || ownerEmail,
+ smtpPass: process.env.QQ_SMTP_AUTH_CODE || "",
+ }));
+ const mapGate = options.mapGate || new MapGate({
+ mapsDir: options.mapsDir || process.env.LAKE_LAMP_MAPS_DIR || "/etc/guanghu/navigation-maps",
+ stateFile: Object.prototype.hasOwnProperty.call(options, "mapStateFile") ? options.mapStateFile : (process.env.LAKE_LAMP_MAP_STATE_FILE || "/var/lib/guanghu/lake-lamp-authz/map-acks.json"),
+ });
+ const repoGrantDir = options.repoGrantDir || process.env.LAKE_LAMP_REPO_GRANT_DIR || "/var/lib/guanghu/repo-authorizations";
+
+ return http.createServer(async (req, res) => {
+ try {
+ const url = new URL(req.url, "http://localhost");
+ if (req.method === "GET" && url.pathname === "/health") return json(res, 200, { ok: true, service: "lake-lamp-authz", auth_mode: "email-link", session_ttl: 3600 });
+
+ const approvalMatch = url.pathname.match(/^\/approve\/([A-Za-z0-9_-]{20,})$/);
+ if (approvalMatch && req.method === "GET") {
+ const inspected = manager.inspectApproval(approvalMatch[1]);
+ if (!inspected.ok) return html(res, 410, approvalErrorPage(inspected.reason));
+ return html(res, 200, approvalPage(inspected.order, approvalMatch[1]));
+ }
+ if (approvalMatch && req.method === "POST") {
+ const approved = manager.approve(approvalMatch[1]);
+ if (!approved.ok) return html(res, 410, approvalErrorPage(approved.reason));
+ return html(res, 200, approvedPage(approved.order));
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/workorders") {
+ if (!bearerMatches(req, requestToken)) return json(res, 401, { error: "request_auth_required" });
+ const body = await readJson(req);
+ if (!body) return json(res, 400, { error: "invalid_json" });
+ if (body.email || body.recipient || body.smtp_pass) return json(res, 400, { error: "direct_recipient_forbidden" });
+ if (!body.persona_id || !body.target || !body.scope || !body.action) return json(res, 400, { error: "missing_required_field" });
+ if (!targets.has(body.target)) return json(res, 400, { error: "unknown_target" });
+ if (!Array.isArray(actions[body.scope]) || !actions[body.scope].includes(body.action)) return json(res, 400, { error: "unknown_or_mismatched_action" });
+ const expectedFingerprint = sha256(ownerEmail.toLowerCase());
+ if (!body.recipient_fingerprint || !safeEqual(body.recipient_fingerprint, expectedFingerprint)) return json(res, 403, { error: "owner_identity_mismatch" });
+
+ const created = manager.request({
+ persona: { pid: String(body.persona_id), name: String(body.persona_name || body.persona_id) },
+ target: String(body.target), scope: String(body.scope), action: String(body.action), allowedActions: actions[body.scope], description: String(body.description || ""),
+ });
+ const approvalUrl = `${publicBaseUrl}/approve/${created.approvalToken}`;
+ const emailSent = await sendEmail({
+ to: ownerEmail,
+ subject: `小湖灯授权请求 · ${body.target}`,
+ approvalUrl,
+ order: manager.inspectApproval(created.approvalToken).order,
+ });
+ if (!emailSent) return json(res, 502, { error: "authorization_email_failed" });
+ return json(res, 201, { ok: true, workorder_id: created.id, claim_token: created.claimToken, expires_in: created.expiresIn, status: "waiting_for_owner" });
+ }
+
+ const claimMatch = url.pathname.match(/^\/api\/workorders\/([0-9a-f-]{36})\/claim$/i);
+ if (req.method === "POST" && claimMatch) {
+ const token = bearer(req);
+ const claimed = manager.claim(claimMatch[1], token);
+ if (!claimed.ok) return json(res, claimed.reason === "approval_pending" ? 202 : 403, { error: claimed.reason });
+ return json(res, 200, { ok: true, session_token: claimed.sessionToken, expires_in: claimed.expiresIn, target: claimed.target, scope: claimed.scope, action: claimed.action });
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/session/verify") {
+ const body = await readJson(req);
+ if (!body) return json(res, 400, { error: "invalid_json" });
+ const verified = manager.verifySession(bearer(req), { pid: String(body.persona_id || "") }, String(body.target || ""), String(body.scope || ""), String(body.action || ""));
+ if (!verified.ok) return json(res, 403, { error: verified.reason });
+ if (body.action !== "read-navigation-map") {
+ const map = mapGate.read(String(body.target || ""));
+ const mapVerified = mapGate.verify(bearer(req), String(body.target || ""), map.hash);
+ if (!mapVerified.ok) return json(res, 423, { error: mapVerified.reason, required_action: "read-navigation-map" });
+ }
+ return json(res, 200, { ok: true, expires_at: verified.session.expiresAt });
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/navigation-map/read") {
+ const body = await readJson(req);
+ if (!body) return json(res, 400, { error: "invalid_json" });
+ const verified = manager.verifySession(bearer(req), { pid: String(body.persona_id || "") }, String(body.target || ""), String(body.scope || ""), "read-navigation-map");
+ if (!verified.ok) return json(res, 403, { error: verified.reason });
+ const map = mapGate.read(String(body.target || ""));
+ return json(res, 200, { ok: true, target: body.target, map_hash: map.hash, navigation_map: map.data });
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/navigation-map/ack") {
+ const body = await readJson(req);
+ if (!body) return json(res, 400, { error: "invalid_json" });
+ const token = bearer(req);
+ const verified = manager.verifySession(token, { pid: String(body.persona_id || "") }, String(body.target || ""), String(body.scope || ""), "read-navigation-map");
+ if (!verified.ok) return json(res, 403, { error: verified.reason });
+ const acked = mapGate.ack(token, String(body.target || ""), String(body.map_hash || ""), Date.now() / 1000, Math.max(1, verified.session.expiresAt - Date.now() / 1000));
+ return json(res, acked.ok ? 200 : 409, acked.ok ? { ok: true, target: body.target, map_hash: body.map_hash } : { error: acked.reason });
+ }
+
+ if (req.method === "POST" && url.pathname === "/api/repo-push/grant") {
+ const body = await readJson(req);
+ if (!body) return json(res, 400, { error: "invalid_json" });
+ const token = bearer(req);
+ const target = String(body.target || "");
+ const scope = String(body.scope || "repo-push");
+ const repo = String(body.repo || "").toLowerCase();
+ if (!/^bingshuo\/[a-z0-9._-]+$/.test(repo)) return json(res, 400, { error: "repo_not_allowlisted" });
+ const verified = manager.verifySession(token, { pid: String(body.persona_id || "") }, target, scope, "push-repository");
+ if (!verified.ok) return json(res, 403, { error: verified.reason });
+ const map = mapGate.read(target);
+ const mapVerified = mapGate.verify(token, target, map.hash);
+ if (!mapVerified.ok) return json(res, 423, { error: mapVerified.reason, required_action: "read-navigation-map" });
+ fs.mkdirSync(repoGrantDir, { recursive: true, mode: 0o2770 });
+ const grant = { schema: "guanghu.repo-push-grant/v1", repo, target, persona_id: body.persona_id, map_hash: map.hash, issued_at: Date.now() / 1000, expires_at: verified.session.expiresAt };
+ const grantFile = path.join(repoGrantDir, `${repo.replace("/", "__")}.json`);
+ const temp = `${grantFile}.${process.pid}.tmp`;
+ fs.writeFileSync(temp, JSON.stringify(grant), { mode: 0o640 });
+ fs.renameSync(temp, grantFile);
+ return json(res, 200, { ok: true, repo, target, expires_at: grant.expires_at });
+ }
+
+ return json(res, 404, { error: "not_found" });
+ } catch (error) {
+ process.stderr.write(`lake-lamp request error: ${String(error && error.message || "unknown").slice(0, 240)}\n`);
+ return json(res, error && error.code === "BODY_TOO_LARGE" ? 413 : 500, { error: "request_failed" });
+ }
+ });
+}
+
+function approvalPage(order, token) {
+ return document("小湖灯授权请求", `
+ LAKE LAMP SECURITY PROTOCOL
+ 人格体请求进入一台服务器
+
+
- 人格体
- ${escapeHtml(order.persona.name)} ${escapeHtml(order.persona.pid)}
+ - 目标节点
- ${escapeHtml(order.target)}
- 授权范围
- ${escapeHtml(order.scope)}
+ - 登记动作
- ${escapeHtml(order.action)}
- 说明
- ${escapeHtml(order.description || "未附加说明")}
+
+ 授权后只对这台服务器和这个动作生效,有效期一小时。换服务器必须重新申请。
+
+ `);
+}
+
+function approvedPage(order) {
+ return document("授权完成", `APPROVED
门已从服务器内部打开
${escapeHtml(order.persona.name)} 已获准操作 ${escapeHtml(order.target)}。
可以关闭本页面。人格体只能领取一次临时会话令牌。
`);
+}
+
+function approvalErrorPage(reason) {
+ return document("链接不可用", `LINK CLOSED
这条授权链接已经失效
原因:${escapeHtml(reason)}。如仍需操作,请让人格体重新提交工单。
`);
+}
+
+function document(title, body) {
+ return `${escapeHtml(title)} · 光湖${body}`;
+}
+
+function readJson(req) {
+ return new Promise((resolve, reject) => {
+ let raw = "";
+ req.on("data", chunk => { raw += chunk; if (raw.length > 32 * 1024) { const error = new Error("body too large"); error.code = "BODY_TOO_LARGE"; reject(error); req.destroy(); } });
+ req.on("end", () => { try { resolve(JSON.parse(raw || "{}")); } catch { resolve(null); } });
+ req.on("error", reject);
+ });
+}
+
+function bearer(req) { return String(req.headers.authorization || "").replace(/^Bearer\s+/i, ""); }
+function bearerMatches(req, expected) { return Boolean(expected) && safeEqual(bearer(req), expected); }
+function safeEqual(left, right) { const a = Buffer.from(String(left)); const b = Buffer.from(String(right)); return a.length === b.length && crypto.timingSafeEqual(a, b); }
+function sha256(value) { return crypto.createHash("sha256").update(String(value)).digest("hex"); }
+function splitCsv(value) { return value.split(",").map(item => item.trim()).filter(Boolean); }
+function escapeHtml(value) { return String(value).replace(/[&<>"']/g, char => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]); }
+function json(res, status, value) { res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" }); res.end(JSON.stringify(value)); }
+function html(res, status, value) { res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff" }); res.end(value); }
+
+if (require.main === module) {
+ const host = process.env.LAKE_LAMP_HOST || "127.0.0.1";
+ const port = Number(process.env.LAKE_LAMP_PORT || 3921);
+ createApp().listen(port, host, () => process.stdout.write(`lake-lamp-authz listening on ${host}:${port}\n`));
+}
+
+module.exports = { createApp, DEFAULT_ACTIONS };
diff --git a/server-tools/lake-lamp-authz/server.test.js b/server-tools/lake-lamp-authz/server.test.js
new file mode 100644
index 0000000..3342d82
--- /dev/null
+++ b/server-tools/lake-lamp-authz/server.test.js
@@ -0,0 +1,117 @@
+"use strict";
+
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const crypto = require("node:crypto");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const { createApp } = require("./server");
+
+async function withServer(run, extra = {}) {
+ const mail = [];
+ const app = createApp({
+ requestToken: "request-only-secret",
+ ownerEmail: "owner@example.invalid",
+ publicBaseUrl: "https://example.invalid/authz",
+ targets: ["JD-FD-PRIMARY", "BS-GZ-006"],
+ actions: { "server-login": ["read-navigation-map", "inspect-services"], "server-ops": ["read-navigation-map", "inspect-services"], "repo-push": ["read-navigation-map", "push-repository"] },
+ stateFile: "",
+ sendEmail: async (message) => { mail.push(message); return true; },
+ ...extra,
+ });
+ await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve));
+ const base = `http://127.0.0.1:${app.address().port}`;
+ try { await run({ base, mail }); } finally { await new Promise((resolve) => app.close(resolve)); }
+}
+
+test("work order sends an opaque approval link and can be claimed once", async () => {
+ await withServer(async ({ base, mail }) => {
+ const recipientFingerprint = crypto.createHash("sha256").update("owner@example.invalid").digest("hex");
+ const requested = await fetch(`${base}/api/workorders`, {
+ method: "POST",
+ headers: { authorization: "Bearer request-only-secret", "content-type": "application/json" },
+ body: JSON.stringify({ persona_id: "ICE-GL-ZY001", persona_name: "铸渊", target: "JD-FD-PRIMARY", scope: "server-login", action: "read-navigation-map", recipient_fingerprint: recipientFingerprint }),
+ });
+ assert.equal(requested.status, 201);
+ const order = await requested.json();
+ assert.equal(mail.length, 1);
+ assert.equal(mail[0].to, "owner@example.invalid");
+ assert.doesNotMatch(JSON.stringify(order), /approvalToken/i);
+
+ const approvalPath = new URL(mail[0].approvalUrl).pathname.replace("/authz", "");
+ const page = await fetch(`${base}${approvalPath}`);
+ assert.equal(page.status, 200);
+ assert.match(await page.text(), /JD-FD-PRIMARY/);
+
+ const approved = await fetch(`${base}${approvalPath}`, { method: "POST" });
+ assert.equal(approved.status, 200);
+ const claimed = await fetch(`${base}/api/workorders/${order.workorder_id}/claim`, {
+ method: "POST",
+ headers: { authorization: `Bearer ${order.claim_token}` },
+ });
+ assert.equal(claimed.status, 200);
+ const session = await claimed.json();
+ assert.equal(session.expires_in, 3600);
+
+ const secondClaim = await fetch(`${base}/api/workorders/${order.workorder_id}/claim`, { method: "POST", headers: { authorization: `Bearer ${order.claim_token}` } });
+ assert.equal(secondClaim.status, 403);
+ });
+});
+
+test("navigation map acknowledgement is mandatory before other registered actions", async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lake-lamp-server-map-"));
+ const mapsDir = path.join(dir, "maps"); fs.mkdirSync(mapsDir);
+ fs.writeFileSync(path.join(mapsDir, "JD-FD-PRIMARY.json"), JSON.stringify({ node_id: "JD-FD-PRIMARY", modules: [{ code: "JD-GTW-01" }] }));
+ try {
+ await withServer(async ({ base, mail }) => {
+ const fingerprint = crypto.createHash("sha256").update("owner@example.invalid").digest("hex");
+ const requested = await fetch(`${base}/api/workorders`, { method: "POST", headers: { authorization: "Bearer request-only-secret", "content-type": "application/json" }, body: JSON.stringify({ persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "server-login", action: "read-navigation-map", recipient_fingerprint: fingerprint }) });
+ const order = await requested.json();
+ const approvalPath = new URL(mail[0].approvalUrl).pathname.replace("/authz", "");
+ await fetch(`${base}${approvalPath}`, { method: "POST" });
+ const claimed = await fetch(`${base}/api/workorders/${order.workorder_id}/claim`, { method: "POST", headers: { authorization: `Bearer ${order.claim_token}` } });
+ const session = await claimed.json();
+ const common = { persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "server-login" };
+ const locked = await fetch(`${base}/api/session/verify`, { method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, body: JSON.stringify({ ...common, action: "inspect-services" }) });
+ assert.equal(locked.status, 423);
+ const mapResponse = await fetch(`${base}/api/navigation-map/read`, { method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, body: JSON.stringify(common) });
+ const map = await mapResponse.json();
+ const ack = await fetch(`${base}/api/navigation-map/ack`, { method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, body: JSON.stringify({ ...common, map_hash: map.map_hash }) });
+ assert.equal(ack.status, 200);
+ const unlocked = await fetch(`${base}/api/session/verify`, { method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, body: JSON.stringify({ ...common, action: "inspect-services" }) });
+ assert.equal(unlocked.status, 200);
+ }, { mapsDir, mapStateFile: path.join(dir, "acks.json") });
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
+});
+
+test("request endpoint rejects direct email target switching and unknown actions", async () => {
+ await withServer(async ({ base }) => {
+ const common = { method: "POST", headers: { authorization: "Bearer request-only-secret", "content-type": "application/json" } };
+ const directEmail = await fetch(`${base}/api/workorders`, { ...common, body: JSON.stringify({ persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "server-login", action: "read-navigation-map", email: "attacker@example.invalid" }) });
+ assert.equal(directEmail.status, 400);
+ const unknown = await fetch(`${base}/api/workorders`, { ...common, body: JSON.stringify({ persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "server-login", action: "shell" }) });
+ assert.equal(unknown.status, 400);
+ });
+});
+
+test("session verification rejects switching servers without new approval", async () => {
+ await withServer(async ({ base, mail }) => {
+ const fingerprint = crypto.createHash("sha256").update("owner@example.invalid").digest("hex");
+ const requested = await fetch(`${base}/api/workorders`, {
+ method: "POST", headers: { authorization: "Bearer request-only-secret", "content-type": "application/json" },
+ body: JSON.stringify({ persona_id: "ICE-GL-ZY001", target: "BS-GZ-006", scope: "server-ops", action: "read-navigation-map", recipient_fingerprint: fingerprint }),
+ });
+ const order = await requested.json();
+ const approvalPath = new URL(mail[0].approvalUrl).pathname.replace("/authz", "");
+ await fetch(`${base}${approvalPath}`, { method: "POST" });
+ const claimed = await fetch(`${base}/api/workorders/${order.workorder_id}/claim`, { method: "POST", headers: { authorization: `Bearer ${order.claim_token}` } });
+ const session = await claimed.json();
+ const switched = await fetch(`${base}/api/session/verify`, {
+ method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" },
+ body: JSON.stringify({ persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "server-ops", action: "read-navigation-map" }),
+ });
+ assert.equal(switched.status, 403);
+ assert.equal((await switched.json()).error, "target_mismatch");
+ });
+});
diff --git a/server-tools/lake-lamp-authz/smtp-mailer.js b/server-tools/lake-lamp-authz/smtp-mailer.js
new file mode 100644
index 0000000..a7e7244
--- /dev/null
+++ b/server-tools/lake-lamp-authz/smtp-mailer.js
@@ -0,0 +1,63 @@
+"use strict";
+
+const tls = require("node:tls");
+
+function sendSmtpMail({ to, subject, approvalUrl, order, text, smtpHost, smtpPort, smtpUser, smtpPass }) {
+ if (!to || !smtpUser || !smtpPass || (!text && !approvalUrl)) return Promise.resolve(false);
+ const plain = text || [
+ "光湖小湖灯安全协议系统 · 授权请求",
+ "",
+ `人格体: ${order.persona.name} (${order.persona.pid})`,
+ `目标节点: ${order.target}`,
+ `授权范围: ${order.scope}`,
+ `登记动作: ${order.action}`,
+ "有效期: 批准后 1 小时,仅限这台服务器",
+ "",
+ "打开以下链接查看工单并确认授权:",
+ approvalUrl,
+ "",
+ "如果不是你发起的操作,请不要点击。",
+ ].join("\n");
+ const message = [
+ `From: =?UTF-8?B?${Buffer.from("光湖小湖灯").toString("base64")}?= <${smtpUser}>`,
+ `To: <${to}>`,
+ `Subject: =?UTF-8?B?${Buffer.from(subject).toString("base64")}?=`,
+ "MIME-Version: 1.0",
+ 'Content-Type: text/plain; charset="utf-8"',
+ "Content-Transfer-Encoding: base64",
+ "",
+ Buffer.from(plain).toString("base64").replace(/(.{76})/g, "$1\r\n"),
+ ".",
+ "",
+ ].join("\r\n");
+
+ return new Promise(resolve => {
+ let settled = false;
+ const done = value => { if (!settled) { settled = true; resolve(value); } };
+ const socket = tls.connect({ host: smtpHost, port: smtpPort, servername: smtpHost, rejectUnauthorized: true });
+ let stage = 0;
+ let buffer = "";
+ socket.setTimeout(15000, () => { socket.destroy(); done(false); });
+ socket.on("error", () => done(false));
+ socket.on("data", data => {
+ buffer += data.toString();
+ const lines = buffer.split("\r\n");
+ buffer = lines.pop();
+ for (const line of lines) {
+ if (!/^\d{3}[ -]/.test(line) || line[3] === "-") continue;
+ const code = Number(line.slice(0, 3));
+ if (code >= 400) { socket.end(); done(false); return; }
+ if (stage === 0 && code === 220) { stage = 1; socket.write("EHLO guanghu-lake-lamp\r\n"); }
+ else if (stage === 1 && code === 250) { stage = 2; socket.write(`AUTH PLAIN ${Buffer.from(`\0${smtpUser}\0${smtpPass}`).toString("base64")}\r\n`); }
+ else if (stage === 2 && code === 235) { stage = 3; socket.write(`MAIL FROM:<${smtpUser}>\r\n`); }
+ else if (stage === 3 && code === 250) { stage = 4; socket.write(`RCPT TO:<${to}>\r\n`); }
+ else if (stage === 4 && code === 250) { stage = 5; socket.write("DATA\r\n"); }
+ else if (stage === 5 && code === 354) { stage = 6; socket.write(message); }
+ else if (stage === 6 && code === 250) { socket.write("QUIT\r\n"); socket.end(); done(true); }
+ }
+ });
+ socket.on("close", () => done(false));
+ });
+}
+
+module.exports = { sendSmtpMail };
diff --git a/server-tools/lake-lamp-authz/workorder-manager.js b/server-tools/lake-lamp-authz/workorder-manager.js
new file mode 100644
index 0000000..299cf53
--- /dev/null
+++ b/server-tools/lake-lamp-authz/workorder-manager.js
@@ -0,0 +1,161 @@
+"use strict";
+
+const crypto = require("node:crypto");
+const fs = require("node:fs");
+const path = require("node:path");
+
+class WorkOrderManager {
+ constructor({ approvalTtl = 15 * 60, sessionTtl = 60 * 60, stateFile = "" } = {}) {
+ this.approvalTtl = approvalTtl;
+ this.sessionTtl = sessionTtl;
+ this.stateFile = stateFile;
+ this.workorders = new Map();
+ this.sessions = new Map();
+ this.load();
+ }
+
+ request({ persona, target, scope, action, allowedActions = [action], description = "" }, now = Date.now() / 1000) {
+ const id = crypto.randomUUID();
+ const approvalToken = randomToken();
+ const claimToken = randomToken();
+ this.workorders.set(id, {
+ id,
+ persona: { pid: persona.pid, name: persona.name || persona.pid },
+ target,
+ scope,
+ action,
+ allowedActions: [...new Set(allowedActions)],
+ description: String(description).slice(0, 500),
+ createdAt: now,
+ expiresAt: now + this.approvalTtl,
+ approvalHash: hash(approvalToken),
+ claimHash: hash(claimToken),
+ state: "pending",
+ claimed: false,
+ });
+ this.persist();
+ return { id, approvalToken, claimToken, expiresIn: this.approvalTtl };
+ }
+
+ inspectApproval(approvalToken, now = Date.now() / 1000) {
+ const order = this.findByApproval(approvalToken);
+ if (!order) return { ok: false, reason: "approval_not_found" };
+ if (now > order.expiresAt) return { ok: false, reason: "approval_expired" };
+ if (order.state !== "pending") return { ok: false, reason: "approval_already_used" };
+ return { ok: true, order: publicOrder(order) };
+ }
+
+ approve(approvalToken, now = Date.now() / 1000) {
+ const inspected = this.inspectApproval(approvalToken, now);
+ if (!inspected.ok) return inspected;
+ const order = this.workorders.get(inspected.order.id);
+ order.state = "approved";
+ order.approvedAt = now;
+ order.approvalHash = "";
+ this.persist();
+ return { ok: true, order: publicOrder(order) };
+ }
+
+ claim(id, claimToken, now = Date.now() / 1000) {
+ const order = this.workorders.get(id);
+ if (!order || !safeEqual(order.claimHash, hash(claimToken || ""))) return { ok: false, reason: "claim_not_found" };
+ if (now > order.expiresAt) return { ok: false, reason: "approval_expired" };
+ if (order.state !== "approved") return { ok: false, reason: order.state === "pending" ? "approval_pending" : "claim_unavailable" };
+ if (order.claimed) return { ok: false, reason: "claim_already_used" };
+
+ const sessionToken = randomToken();
+ this.sessions.set(hash(sessionToken), {
+ persona: order.persona,
+ target: order.target,
+ scope: order.scope,
+ action: order.action,
+ actions: order.allowedActions || [order.action],
+ createdAt: now,
+ expiresAt: now + this.sessionTtl,
+ });
+ order.claimed = true;
+ order.state = "claimed";
+ order.claimHash = "";
+ this.persist();
+ return { ok: true, sessionToken, expiresIn: this.sessionTtl, target: order.target, scope: order.scope, action: order.action };
+ }
+
+ verifySession(sessionToken, persona, target, scope, action, now = Date.now() / 1000) {
+ const key = hash(sessionToken || "");
+ const session = this.sessions.get(key);
+ if (!session) return { ok: false, reason: "session_not_found" };
+ if (now > session.expiresAt) {
+ this.sessions.delete(key);
+ this.persist();
+ return { ok: false, reason: "session_expired" };
+ }
+ if (session.persona.pid !== persona.pid) return { ok: false, reason: "persona_mismatch" };
+ if (session.target !== target) return { ok: false, reason: "target_mismatch" };
+ if (session.scope !== scope) return { ok: false, reason: "scope_mismatch" };
+ if (!(session.actions || [session.action]).includes(action)) return { ok: false, reason: "action_mismatch" };
+ return { ok: true, session: { ...session } };
+ }
+
+ findByApproval(token) {
+ const needle = hash(token || "");
+ for (const order of this.workorders.values()) {
+ if (order.approvalHash && safeEqual(order.approvalHash, needle)) return order;
+ }
+ return null;
+ }
+
+ load(now = Date.now() / 1000) {
+ if (!this.stateFile || !fs.existsSync(this.stateFile)) return;
+ try {
+ const state = JSON.parse(fs.readFileSync(this.stateFile, "utf8"));
+ for (const order of state.workorders || []) {
+ if (order.expiresAt >= now) this.workorders.set(order.id, order);
+ }
+ for (const [tokenHash, session] of Object.entries(state.sessions || {})) {
+ if (session.expiresAt >= now) this.sessions.set(tokenHash, session);
+ }
+ } catch {
+ this.workorders.clear();
+ this.sessions.clear();
+ }
+ }
+
+ persist() {
+ if (!this.stateFile) return;
+ const stateDir = path.dirname(this.stateFile);
+ if (!fs.existsSync(stateDir)) fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
+ const temp = `${this.stateFile}.${process.pid}.tmp`;
+ fs.writeFileSync(temp, JSON.stringify({ version: 1, workorders: [...this.workorders.values()], sessions: Object.fromEntries(this.sessions) }), { mode: 0o600 });
+ fs.renameSync(temp, this.stateFile);
+ }
+}
+
+function randomToken() {
+ return crypto.randomBytes(32).toString("base64url");
+}
+
+function hash(value) {
+ return crypto.createHash("sha256").update(String(value)).digest("hex");
+}
+
+function safeEqual(left, right) {
+ const a = Buffer.from(String(left));
+ const b = Buffer.from(String(right));
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
+}
+
+function publicOrder(order) {
+ return {
+ id: order.id,
+ persona: { ...order.persona },
+ target: order.target,
+ scope: order.scope,
+ action: order.action,
+ description: order.description,
+ createdAt: order.createdAt,
+ expiresAt: order.expiresAt,
+ state: order.state,
+ };
+}
+
+module.exports = { WorkOrderManager };
diff --git a/server-tools/lake-lamp-authz/workorder-manager.test.js b/server-tools/lake-lamp-authz/workorder-manager.test.js
new file mode 100644
index 0000000..25df4ae
--- /dev/null
+++ b/server-tools/lake-lamp-authz/workorder-manager.test.js
@@ -0,0 +1,69 @@
+"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 { WorkOrderManager } = require("./workorder-manager");
+
+const persona = { pid: "ICE-GL-ZY001", name: "铸渊" };
+
+test("approval link is single use and the session is claimed once", () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lake-lamp-authz-"));
+ try {
+ const manager = new WorkOrderManager({ stateFile: path.join(dir, "state.json"), sessionTtl: 3600 });
+ const created = manager.request({ persona, target: "JD-FD-PRIMARY", scope: "server-login", action: "read-navigation-map", allowedActions: ["read-navigation-map", "inspect-services"] }, 100);
+ assert.equal(manager.inspectApproval(created.approvalToken, 101).ok, true);
+ assert.equal(manager.approve(created.approvalToken, 102).ok, true);
+ assert.equal(manager.approve(created.approvalToken, 103).ok, false);
+
+ const claimed = manager.claim(created.id, created.claimToken, 104);
+ assert.equal(claimed.ok, true);
+ assert.equal(claimed.expiresIn, 3600);
+ assert.equal(manager.claim(created.id, created.claimToken, 105).ok, false);
+ assert.equal(manager.verifySession(claimed.sessionToken, persona, "JD-FD-PRIMARY", "server-login", "read-navigation-map", 106).ok, true);
+ assert.equal(manager.verifySession(claimed.sessionToken, persona, "JD-FD-PRIMARY", "server-login", "inspect-services", 106).ok, true);
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test("session is bound to one persona target scope and action", () => {
+ const manager = new WorkOrderManager({ sessionTtl: 3600 });
+ const created = manager.request({ persona, target: "BS-GZ-006", scope: "server-ops", action: "read-navigation-map" }, 100);
+ manager.approve(created.approvalToken, 101);
+ const claimed = manager.claim(created.id, created.claimToken, 102);
+
+ assert.equal(manager.verifySession(claimed.sessionToken, persona, "BS-GZ-006", "server-ops", "read-navigation-map", 103).ok, true);
+ assert.equal(manager.verifySession(claimed.sessionToken, persona, "JD-FD-PRIMARY", "server-ops", "read-navigation-map", 103).reason, "target_mismatch");
+ assert.equal(manager.verifySession(claimed.sessionToken, { pid: "OTHER" }, "BS-GZ-006", "server-ops", "read-navigation-map", 103).reason, "persona_mismatch");
+ assert.equal(manager.verifySession(claimed.sessionToken, persona, "BS-GZ-006", "repo-push", "read-navigation-map", 103).reason, "scope_mismatch");
+ assert.equal(manager.verifySession(claimed.sessionToken, persona, "BS-GZ-006", "server-ops", "push-repository", 103).reason, "action_mismatch");
+});
+
+test("plaintext approval claim and session tokens are never persisted", () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lake-lamp-authz-"));
+ try {
+ const stateFile = path.join(dir, "state.json");
+ const manager = new WorkOrderManager({ stateFile });
+ const created = manager.request({ persona, target: "JD-FD-PRIMARY", scope: "repo-push", action: "push-repository" }, 100);
+ manager.approve(created.approvalToken, 101);
+ const claimed = manager.claim(created.id, created.claimToken, 102);
+ const state = fs.readFileSync(stateFile, "utf8");
+ for (const secret of [created.approvalToken, created.claimToken, claimed.sessionToken]) assert.equal(state.includes(secret), false);
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test("expired work orders and sessions fail closed", () => {
+ const manager = new WorkOrderManager({ approvalTtl: 10, sessionTtl: 20 });
+ const created = manager.request({ persona, target: "JD-FD-PRIMARY", scope: "server-login", action: "read-navigation-map" }, 100);
+ assert.equal(manager.approve(created.approvalToken, 111).reason, "approval_expired");
+
+ const fresh = manager.request({ persona, target: "JD-FD-PRIMARY", scope: "server-login", action: "read-navigation-map" }, 200);
+ manager.approve(fresh.approvalToken, 201);
+ const claimed = manager.claim(fresh.id, fresh.claimToken, 202);
+ assert.equal(manager.verifySession(claimed.sessionToken, persona, "JD-FD-PRIMARY", "server-login", "read-navigation-map", 223).reason, "session_expired");
+});
diff --git a/server-tools/node-bootstrap/README.md b/server-tools/node-bootstrap/README.md
new file mode 100644
index 0000000..05fb030
--- /dev/null
+++ b/server-tools/node-bootstrap/README.md
@@ -0,0 +1,8 @@
+# 光湖服务器初始化模板
+
+所有新灯塔节点先执行同一模板:基础诊断工具、`guanghu` 系统组、root-only
+秘密目录、节点身份文件、服务器导航地图、SSH 与系统安全更新。模板不携带任何
+共享 token;节点到京东的密钥必须逐节点生成,接入后登记到 JD 路由表。
+
+JD-FD-PRIMARY 是当前首个样板节点。历史节点接入时先备份和审计,再补齐模板
+目录,不批量覆盖其现有服务。
diff --git a/server-tools/node-bootstrap/bootstrap.sh b/server-tools/node-bootstrap/bootstrap.sh
new file mode 100755
index 0000000..2f354cf
--- /dev/null
+++ b/server-tools/node-bootstrap/bootstrap.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+NODE_ID=${1:?usage: bootstrap.sh NODE_ID MAP_FILE}
+MAP_FILE=${2:?usage: bootstrap.sh NODE_ID MAP_FILE}
+test "$(id -u)" -eq 0
+test -s "$MAP_FILE"
+
+export DEBIAN_FRONTEND=noninteractive
+apt-get update -qq
+apt-get install -y -qq ca-certificates curl git jq openssh-server python3 rsync unattended-upgrades
+
+getent group guanghu >/dev/null || groupadd --system guanghu
+for account in guanghu-authz guanghu-sentinel; do
+ id -u "$account" >/dev/null 2>&1 || useradd --system --home /nonexistent --shell /usr/sbin/nologin "$account"
+ usermod -aG guanghu "$account"
+done
+
+install -d -o root -g guanghu -m 750 /etc/guanghu /var/lib/guanghu
+install -d -o root -g root -m 700 /etc/guanghu/secrets
+install -d -o root -g root -m 755 /etc/guanghu/navigation-maps /opt/guanghu
+install -m 644 "$MAP_FILE" "/etc/guanghu/navigation-maps/${NODE_ID}.json"
+
+cat > /etc/guanghu/node.json < {
+ assert.match(source, /\/etc\/guanghu\/secrets/);
+ assert.match(source, /\/etc\/guanghu\/navigation-maps/);
+ assert.match(source, /\/var\/lib\/guanghu/);
+});
+test("bootstrap requires an explicit node id and map", () => {
+ assert.match(source, /NODE_ID=\$\{1:\?/);
+ assert.match(source, /MAP_FILE=\$\{2:\?/);
+});
+test("bootstrap never installs a shared token", () => assert.doesNotMatch(source, /zy_gtw_|Bearer\s+[A-Za-z0-9]/));
diff --git a/server-tools/state-change-sentinel/README.md b/server-tools/state-change-sentinel/README.md
new file mode 100644
index 0000000..9da3089
--- /dev/null
+++ b/server-tools/state-change-sentinel/README.md
@@ -0,0 +1,5 @@
+# 状态变化哨兵
+
+这不是五分钟只读心跳,也不抓取页面内容。JD 每 15 分钟只请求两个自有健康入口,
+首次观察仅建立基线;状态完全不变时不发邮件。只有在线变异常、异常类型变化或恢复在线
+才发送提醒。整个节点直接断电时,本机无法主动上报,因此仍需由 JD 做低频外部判定。
diff --git a/server-tools/state-change-sentinel/guanghu-state-change-sentinel.service b/server-tools/state-change-sentinel/guanghu-state-change-sentinel.service
new file mode 100644
index 0000000..719c3f6
--- /dev/null
+++ b/server-tools/state-change-sentinel/guanghu-state-change-sentinel.service
@@ -0,0 +1,23 @@
+[Unit]
+Description=Guanghu state-change-only node sentinel
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=oneshot
+User=guanghu-sentinel
+Group=guanghu-sentinel
+SupplementaryGroups=guanghu
+WorkingDirectory=/opt/guanghu/state-change-sentinel
+EnvironmentFile=/etc/guanghu/secrets/mail/qq-authorization.env
+Environment=SENTINEL_CONFIG=/etc/guanghu/state-change-sentinel.json
+Environment=SENTINEL_STATE=/var/lib/guanghu/state-change-sentinel/state.json
+ExecStart=/usr/bin/node /opt/guanghu/state-change-sentinel/sentinel.js
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=/var/lib/guanghu/state-change-sentinel
+
+[Install]
+WantedBy=multi-user.target
diff --git a/server-tools/state-change-sentinel/guanghu-state-change-sentinel.timer b/server-tools/state-change-sentinel/guanghu-state-change-sentinel.timer
new file mode 100644
index 0000000..32bb63d
--- /dev/null
+++ b/server-tools/state-change-sentinel/guanghu-state-change-sentinel.timer
@@ -0,0 +1,11 @@
+[Unit]
+Description=Check Guanghu nodes for state transitions only
+
+[Timer]
+OnBootSec=5min
+OnUnitInactiveSec=15min
+RandomizedDelaySec=2min
+Persistent=true
+
+[Install]
+WantedBy=timers.target
diff --git a/server-tools/state-change-sentinel/sentinel.js b/server-tools/state-change-sentinel/sentinel.js
new file mode 100644
index 0000000..b74699f
--- /dev/null
+++ b/server-tools/state-change-sentinel/sentinel.js
@@ -0,0 +1,47 @@
+#!/usr/bin/env node
+"use strict";
+const fs = require("node:fs");
+const path = require("node:path");
+const { transition } = require("./state");
+const { sendSmtpMail } = require("../lake-lamp-authz/smtp-mailer");
+
+async function main() {
+ const config = JSON.parse(fs.readFileSync(process.env.SENTINEL_CONFIG || "/etc/guanghu/state-change-sentinel.json", "utf8"));
+ const stateFile = process.env.SENTINEL_STATE || "/var/lib/guanghu/state-change-sentinel/state.json";
+ const previous = fs.existsSync(stateFile) ? JSON.parse(fs.readFileSync(stateFile, "utf8")) : {};
+ const next = {};
+ const notices = [];
+ for (const target of config.targets) {
+ const current = await check(target);
+ next[target.id] = current;
+ const changed = transition(previous[target.id], current);
+ if (changed.notify) notices.push({ id: target.id, kind: changed.kind, detail: current.detail });
+ }
+ fs.mkdirSync(path.dirname(stateFile), { recursive: true, mode: 0o700 });
+ fs.writeFileSync(stateFile, JSON.stringify(next), { mode: 0o600 });
+ if (!notices.length) return;
+ const lines = notices.map(item => `${item.kind === "anomaly" ? "异常" : "恢复"}: ${item.id} · ${item.detail}`);
+ await sendSmtpMail({
+ to: process.env.AUTH_RECIPIENT,
+ subject: `光湖节点状态变化 · ${notices.map(item => item.id).join(", ")}`,
+ text: ["光湖节点状态发生变化。静止在线期间不会发送邮件。", "", ...lines, "", new Date().toISOString()].join("\n"),
+ smtpHost: process.env.SMTP_HOST || "smtp.qq.com",
+ smtpPort: Number(process.env.SMTP_PORT || 465),
+ smtpUser: process.env.SMTP_USER,
+ smtpPass: process.env.SMTP_PASS,
+ });
+}
+
+async function check(target) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), target.timeout_ms || 8000);
+ try {
+ const response = await fetch(target.url, { method: "GET", redirect: "manual", signal: controller.signal, headers: { "user-agent": "Guanghu-State-Change-Sentinel/1.0" } });
+ const ok = (target.accept || [200]).includes(response.status);
+ return { ok, detail: `HTTP ${response.status}`, observed_at: new Date().toISOString() };
+ } catch (error) {
+ return { ok: false, detail: error.name === "AbortError" ? "timeout" : "connection-failed", observed_at: new Date().toISOString() };
+ } finally { clearTimeout(timer); }
+}
+
+main().catch(error => { process.stderr.write(`state sentinel failed: ${error.message}\n`); process.exit(1); });
diff --git a/server-tools/state-change-sentinel/state-change-sentinel.json b/server-tools/state-change-sentinel/state-change-sentinel.json
new file mode 100644
index 0000000..8316b8a
--- /dev/null
+++ b/server-tools/state-change-sentinel/state-change-sentinel.json
@@ -0,0 +1,6 @@
+{
+ "targets": [
+ { "id": "BS-GZ-006-front-door", "url": "https://guanghulab.com/", "accept": [200], "timeout_ms": 8000 },
+ { "id": "JD-Lake-Lamp-public-route", "url": "https://guanghulab.com/authz/health", "accept": [200], "timeout_ms": 8000 }
+ ]
+}
diff --git a/server-tools/state-change-sentinel/state.js b/server-tools/state-change-sentinel/state.js
new file mode 100644
index 0000000..0860205
--- /dev/null
+++ b/server-tools/state-change-sentinel/state.js
@@ -0,0 +1,9 @@
+"use strict";
+
+function transition(previous, current) {
+ if (!previous) return { notify: false, state: current, kind: "baseline" };
+ if (previous.ok === current.ok && previous.detail === current.detail) return { notify: false, state: current, kind: "unchanged" };
+ return { notify: true, state: current, kind: current.ok ? "recovered" : "anomaly" };
+}
+
+module.exports = { transition };
diff --git a/server-tools/state-change-sentinel/state.test.js b/server-tools/state-change-sentinel/state.test.js
new file mode 100644
index 0000000..41bf0bb
--- /dev/null
+++ b/server-tools/state-change-sentinel/state.test.js
@@ -0,0 +1,10 @@
+"use strict";
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const { transition } = require("./state");
+test("first observation records a baseline without email", () => assert.equal(transition(null, { ok: true, detail: "200" }).notify, false));
+test("steady online state sends no email", () => assert.equal(transition({ ok: true, detail: "200" }, { ok: true, detail: "200" }).notify, false));
+test("online to offline and offline to online both notify", () => {
+ assert.deepEqual(transition({ ok: true, detail: "200" }, { ok: false, detail: "timeout" }).kind, "anomaly");
+ assert.deepEqual(transition({ ok: false, detail: "timeout" }, { ok: true, detail: "200" }).kind, "recovered");
+});
diff --git a/zero-point/core-channel/gatekeeper/CA-GDE-ENGINE.hdlp b/zero-point/core-channel/gatekeeper/CA-GDE-ENGINE.hdlp
index 417289e..b289d41 100644
--- a/zero-point/core-channel/gatekeeper/CA-GDE-ENGINE.hdlp
+++ b/zero-point/core-channel/gatekeeper/CA-GDE-ENGINE.hdlp
@@ -42,7 +42,7 @@
| 项目 | 值 |
|------|-----|
| **人类** | 苍耳 TCS-GL-009 |
-| **验证码邮箱** | 1279551860@qq.com |
+| **验证码邮箱** | EMAIL_REDACTED@qq.com |
| **仓库地址** | https://guanghubingshuo.com/code/bingshuo/cang-ying |
| **引擎地址** | SG-001 (43.156.237.110:3911) |
@@ -59,7 +59,7 @@ curl -X POST http://43.156.237.110:3911/auth/request \
-H "Authorization: Bearer <耳耳蛋的Token>" \
-H "Content-Type: application/json" \
-d '{
- "email": "1279551860@qq.com",
+ "email": "EMAIL_REDACTED@qq.com",
"op_type": "api-access",
"cmd": "curl https://ark.cn-beijing.volces.com/api/v3/...",
"description": "调用火山引擎生成视频"
@@ -69,7 +69,7 @@ curl -X POST http://43.156.237.110:3911/auth/request \
**必填字段**:
| 字段 | 说明 | 示例 |
|------|------|------|
-| `email` | 苍耳的验证码收件邮箱 | `"1279551860@qq.com"` |
+| `email` | 苍耳的验证码收件邮箱 | `"EMAIL_REDACTED@qq.com"` |
| `op_type` | 操作类型 | `"api-access"` 或 `"code-repo"` |
| `cmd` | 要执行的命令 | `"git push origin main"` |
| `description` | 描述一下在做什么 | `"推送耳耳蛋更新的代码"` |
@@ -79,16 +79,16 @@ curl -X POST http://43.156.237.110:3911/auth/request \
{
"ok": true,
"challenge_id": "abc123def456...",
- "target_email": "1279551860@qq.com",
+ "target_email": "EMAIL_REDACTED@qq.com",
"op_type": "api-access",
"email_sent": true,
- "message": "验证码已发送到 1279551860@qq.com,请查收后通过 /auth/confirm 确认操作"
+ "message": "验证码已发送到 EMAIL_REDACTED@qq.com,请查收后通过 /auth/confirm 确认操作"
}
```
### 苍耳做的事
-苍耳在 1279551860@qq.com 邮箱里收到一封邮件:
+苍耳在 EMAIL_REDACTED@qq.com 邮箱里收到一封邮件:
- 邮件的标题会显示是谁在请求、做什么操作
- 邮件里有 6 位数字验证码
- 苍耳把验证码告诉耳耳蛋
@@ -118,7 +118,7 @@ curl -X POST http://43.156.237.110:3911/auth/confirm \
```
耳耳蛋 → 光湖引擎驱动:
{
- "email": "1279551860@qq.com",
+ "email": "EMAIL_REDACTED@qq.com",
"op_type": "api-access",
"cmd": "python3 call_volcengine_api.py --prompt '生成一段...'",
"description": "火山引擎视频生成 · 苍耳第3镜"
@@ -134,7 +134,7 @@ curl -X POST http://43.156.237.110:3911/auth/confirm \
```
耳耳蛋 → 光湖引擎驱动:
{
- "email": "1279551860@qq.com",
+ "email": "EMAIL_REDACTED@qq.com",
"op_type": "code-repo",
"cmd": "cd /opt/zhuyuan/cang-ying && git add . && git commit -m '...' && git push",
"description": "推送耳耳蛋代码更新 · 视频AI第4镜"
@@ -172,7 +172,7 @@ Token 只证明"我是耳耳蛋",不代表有权限操作。
## 七 · 常见问题
**Q: 苍耳没看到邮件怎么办?**
-A: 检查 1279551860@qq.com 的垃圾箱。邮件标题是 `📦 Gatekeeper授权 · 耳耳蛋 · ...`。
+A: 检查 EMAIL_REDACTED@qq.com 的垃圾箱。邮件标题是 `📦 Gatekeeper授权 · 耳耳蛋 · ...`。
**Q: 验证码过期了?**
A: 5 分钟过期。耳耳蛋重新发 `/auth/request` 就行了。旧验证码自动失效。
diff --git a/zero-point/core-channel/revive-guard/pre-push-clean.py b/zero-point/core-channel/revive-guard/pre-push-clean.py
old mode 100644
new mode 100755
index dd0acb2..6dac31e
--- a/zero-point/core-channel/revive-guard/pre-push-clean.py
+++ b/zero-point/core-channel/revive-guard/pre-push-clean.py
@@ -17,22 +17,14 @@ import os
import sys
import re
import subprocess
-import hashlib
-from datetime import datetime
# ═══════════════════════════════════════════════════════
# 敏感模式库(与服务器端 pre-receive-guard 保持同步)
# ═══════════════════════════════════════════════════════
PATTERNS = [
- # 冰朔主权邮箱
- (r'565183519@qq\.com', '冰朔主权邮箱', 'ICE-GL∞_EMAIL_REDACTED'),
-
# 任何 QQ 邮箱
(r'[a-zA-Z0-9._%+-]+@qq\.com', 'QQ邮箱地址', 'EMAIL_REDACTED@qq.com'),
- # SMTP 授权码
- (r'SMTP_AUTH_CODE_REDACTED', 'QQ SMTP授权码', 'SMTP_AUTH_CODE_REDACTED'),
-
# Gatekeeper Token
(r'zy_gtw_[a-f0-9]{40,}', 'Gatekeeper Token', 'GATEKEEPER_TOKEN_REDACTED'),
@@ -53,22 +45,28 @@ PATTERNS = [
(r'(password|passwd|pwd|secret)\s*[:=]\s*["\']([^"\']{3,})["\']',
'密码明文赋值', lambda m: f'{m.group(1)}="REDACTED_PASSWORD"'),
- # git 令牌(f78c594e... 等 40 位 hex token)
- (r'\b[a-f0-9]{40}\b', 'Git Token / SHA1长串', 'GIT_TOKEN_REDACTED'),
-
- # 保险库盐值
- (r'VAULT_SALT_REDACTED',
- '保险库主盐值', 'VAULT_SALT_REDACTED'),
- (r'API_SALT_REDACTED',
- 'API子库盐值', 'API_SALT_REDACTED'),
-
- # 通用长 hex hash(64位 · 可能是密钥/盐值)
- (r'\b[a-f0-9]{64}\b', '64位Hash(疑似密钥)', 'LONG_HEX_REDACTED'),
-
- # 任何看起来像 token 的字符串(字母数字下划线 30+ 位,非 git SHA)
- (r'\b[a-zA-Z0-9_\-]{30,60}\b', '疑似Token长串(需确认)', 'TOKEN_STRING_REDACTED'),
]
+
+def load_private_patterns():
+ """从仓库外私有文件加载精确禁入值;绝不把值写入源码或日志。"""
+ path = os.environ.get(
+ 'GUANGHU_FORBIDDEN_IDENTIFIERS_FILE',
+ os.path.expanduser(
+ '~/Documents/guanghulab-local-secrets/code-guard/forbidden-identifiers'
+ ),
+ )
+ patterns = []
+ try:
+ with open(path, 'r', encoding='utf-8') as handle:
+ for line in handle:
+ value = line.strip()
+ if value and not value.startswith('#'):
+ patterns.append((re.escape(value), '私有禁入标识', 'PRIVATE_VALUE_REDACTED'))
+ except FileNotFoundError:
+ pass
+ return patterns
+
# 文件类型白名单
TEXT_EXTENSIONS = {
'.py', '.js', '.ts', '.go', '.rs', '.java', '.c', '.cpp', '.h',
@@ -111,23 +109,17 @@ def scan_file(filepath):
findings = []
new_content = content
- for pattern, name, replacement in PATTERNS:
+ for pattern, name, replacement in PATTERNS + load_private_patterns():
matches = list(re.finditer(pattern, content, re.IGNORECASE))
for m in matches:
matched_text = m.group(0)
# 跳过已经是占位符的
if 'REDACTED' in matched_text:
continue
- # 跳过 git commit SHA(40位hex在特定上下文中)
- if name == 'Git Token / SHA1长串' and _is_git_sha_context(content, m.start()):
- continue
-
repl = replacement(m) if callable(replacement) else replacement
findings.append({
'file': filepath,
'pattern_name': name,
- 'matched': matched_text[:80],
- 'replacement': repl,
})
# 替换(注意:用 re.sub 而不是简单替换,避免破坏后续匹配位置)
new_content = new_content.replace(matched_text, repl, 1)
@@ -135,20 +127,6 @@ def scan_file(filepath):
return new_content if findings else None, findings
-def _is_git_sha_context(content, pos):
- """判断 40 位 hex 是否在 git SHA 上下文中(commit hash 不应被替换)"""
- # 检查前后是否有 git 相关关键词
- start = max(0, pos - 50)
- end = min(len(content), pos + 50)
- context = content[start:end].lower()
- git_keywords = ['commit', 'sha', 'hash', 'revision', 'ref', 'head', 'object']
- # 如果在 git 命令或 commit 引用上下文中 → 跳过
- for kw in git_keywords:
- if kw in context:
- return True
- return False
-
-
def get_changed_files():
"""获取本次 push 涉及的所有文件"""
files = set()
@@ -175,7 +153,7 @@ def get_changed_files():
parts = line.split()
if len(parts) >= 4:
local_ref, local_sha, remote_ref, remote_sha = parts[0], parts[1], parts[2], parts[3]
- if remote_sha != 'TOKEN_STRING_REDACTED':
+ if remote_sha != '0' * 40:
r = subprocess.run(
['git', 'diff', '--name-only', '--diff-filter=ACM',
remote_sha, local_sha],
@@ -274,8 +252,8 @@ def main():
for f in all_findings[:20]:
print(f' 📄 {f["file"]}')
print(f' ⚠️ {f["pattern_name"]}')
- print(f' ✗ {f["matched"][:60]}')
- print(f' → {f["replacement"]}')
+ print(' ✗ 原文已隐藏')
+ print(' → 已替换为安全占位符')
print()
if len(all_findings) > 20:
diff --git a/zero-point/core-channel/revive-guard/pre-receive-guard.py b/zero-point/core-channel/revive-guard/pre-receive-guard.py
old mode 100644
new mode 100755
index dc7ba8f..a55db35
--- a/zero-point/core-channel/revive-guard/pre-receive-guard.py
+++ b/zero-point/core-channel/revive-guard/pre-receive-guard.py
@@ -11,8 +11,7 @@ Guanghu Language System · Pre-Receive Guard
⊢ 铸渊(人格体)会下意识把密码/邮箱写进代码 → 人类改不了这个习惯
⊢ 不在人格体侧修 → 在服务器侧设门禁 → 不干净的推送进不来
⊢ 公开仓库 24h 被爬虫扫描 → 敏感信息一旦进去就立刻暴露
- ⊢ 配合客户端 pre-push-clean 插件 → [SEC-CLEAN] 标记 = 信任免检
- ⊢ 无标记 → 全量扫描 → 发现敏感信息 → 拒绝推送
+ ⊢ 客户端 pre-push-clean 负责提前发现,服务器始终独立全量扫描
⊢ 双重防线:客户端插件 + 服务器守门人
"""
@@ -32,22 +31,18 @@ SMTP_USER = os.environ.get("SMTP_USER", "ICE-GL∞_EMAIL_REDACTED")
# 模式: "reject" = 拦截并拒绝(推荐) / "notify" = 放行但通知冰朔
MODE = os.environ.get("PRE_RECEIVE_MODE", "reject")
-# [SEC-CLEAN] 标记信任:有标记的 commit 免检(客户端插件已清理)
-TRUST_SEC_CLEAN = os.environ.get("TRUST_SEC_CLEAN", "1") == "1"
+FORBIDDEN_IDENTIFIERS_FILE = os.environ.get(
+ "FORBIDDEN_IDENTIFIERS_FILE",
+ "/etc/guanghu/secrets/code-guard/forbidden-identifiers",
+)
# ═══════════════════════════════════════════════════════
# 敏感模式库(正则 · 命中则触发)
# ═══════════════════════════════════════════════════════
SENSITIVE_PATTERNS = [
- # 冰朔的主权邮箱
- (r'565183519@qq\.com', '冰朔主权邮箱', 'ICE-GL∞_EMAIL_REDACTED'),
-
# 任何 QQ 邮箱(含冰朔在其他上下文中的邮箱)
(r'[a-zA-Z0-9._%+-]+@qq\.com', 'QQ邮箱地址', 'EMAIL_REDACTED@qq.com'),
- # SMTP 授权码模式(16位小写字母)
- (r'SMTP_AUTH_CODE_REDACTED', 'QQ SMTP授权码', 'SMTP_AUTH_CODE_REDACTED'),
-
# GZ-006 Gatekeeper Token 模式
(r'zy_gtw_[a-f0-9]{40,}', 'Gatekeeper Token', 'GATEKEEPER_TOKEN_REDACTED'),
@@ -66,20 +61,22 @@ SENSITIVE_PATTERNS = [
# 密码赋值模式(变量名含 password/passwd/pwd 且值非空)
(r'(password|passwd|pwd|secret)\s*[:=]\s*["\']([^"\']{3,})["\']', '密码明文赋值', lambda m: f'{m.group(1)}="REDACTED_PASSWORD"'),
- # HMAC secret / salt 长串
- (r'[a-f0-9]{64}', '疑似密钥/Hash长串(需人工确认)', 'LONG_HEX_REDACTED'),
-
- # 保险库 salt(已知值)
- (r'VAULT_SALT_REDACTED', '保险库主盐值', 'VAULT_SALT_REDACTED'),
- (r'API_SALT_REDACTED', 'API子库盐值', 'API_SALT_REDACTED'),
-
- # Git 令牌(40 位 hex · 如 f78c594e...)
- (r'\b[a-f0-9]{40}\b', 'Git Token / 40位hex', 'GIT_TOKEN_REDACTED'),
-
- # 通用长 hex hash(64位 · 可能是密钥)
- (r'\b[a-f0-9]{64}\b', '64位Hash(疑似密钥)', 'LONG_HEX_REDACTED'),
]
+
+def load_forbidden_identifiers(filename=FORBIDDEN_IDENTIFIERS_FILE):
+ """从仓库外的 root-only 文件加载精确禁用值,不把值写入代码或日志。"""
+ patterns = []
+ try:
+ with open(filename, "r", encoding="utf-8") as handle:
+ for raw in handle:
+ value = raw.strip()
+ if value and not value.startswith("#"):
+ patterns.append((re.escape(value), "私密禁用标识", "PRIVATE_IDENTIFIER_REDACTED"))
+ except OSError:
+ pass
+ return patterns
+
# ═══════════════════════════════════════════════════════
# 文件类型白名单(只扫描文本文件)
# ═══════════════════════════════════════════════════════
@@ -97,11 +94,12 @@ def is_text_file(path):
return ext.lower() in TEXT_EXTENSIONS
-def scan_content(content, file_path):
+def scan_content(content, file_path, patterns=None):
"""扫描文件内容,返回发现的敏感信息列表"""
findings = []
+ active_patterns = list(SENSITIVE_PATTERNS) + (load_forbidden_identifiers() if patterns is None else list(patterns))
for line_no, line in enumerate(content.split('\n'), 1):
- for pattern, name, replacement in SENSITIVE_PATTERNS:
+ for pattern, name, replacement in active_patterns:
matches = list(re.finditer(pattern, line, re.IGNORECASE))
for m in matches:
matched_text = m.group(0)
@@ -112,7 +110,6 @@ def scan_content(content, file_path):
'file': file_path,
'line': line_no,
'pattern_name': name,
- 'matched': matched_text[:80],
'replacement': replacement(m) if callable(replacement) else replacement,
})
return findings
@@ -183,7 +180,7 @@ def main():
old_rev, new_rev, ref_name = parts[0], parts[1], parts[2]
# 跳过删除的分支
- if new_rev == "GIT_TOKEN_REDACTED":
+ if new_rev == "0" * 40:
continue
# 持续记忆必须先通过结构门禁;服务器使用本次 receive 的精确提交范围,
@@ -201,16 +198,6 @@ def main():
# 获取新增/修改的文件列表
try:
- # 先检查 commit message 是否含 [SEC-CLEAN] 标记
- if TRUST_SEC_CLEAN:
- msg_check = subprocess.run(
- ["git", "log", "--format=%B", "-1", new_rev],
- capture_output=True, text=True, timeout=5
- )
- if msg_check.returncode == 0 and "[SEC-CLEAN]" in msg_check.stdout:
- # 信任客户端 pre-push-clean 插件 · 免检通过
- continue
-
diff_files = subprocess.run(
["git", "diff-tree", "--no-commit-id", "-r", "--name-only",
"--diff-filter=AM", old_rev, new_rev],
@@ -257,7 +244,7 @@ def main():
for f in all_findings[:20]:
print(f" 📄 {f['file']}:{f['line']}", file=sys.stderr)
print(f" ⚠️ {f['pattern_name']}", file=sys.stderr)
- print(f" ✗ {f['matched'][:60]}", file=sys.stderr)
+ print(" ✗ 命中值已隐藏,避免在终端和日志中二次泄露", file=sys.stderr)
print(f" → 请替换为: {f['replacement']}", file=sys.stderr)
print(file=sys.stderr)
diff --git a/zero-point/core-channel/revive-guard/pre-receive-guard.test.py b/zero-point/core-channel/revive-guard/pre-receive-guard.test.py
new file mode 100644
index 0000000..dc22fff
--- /dev/null
+++ b/zero-point/core-channel/revive-guard/pre-receive-guard.test.py
@@ -0,0 +1,40 @@
+import importlib.util
+import os
+import tempfile
+import unittest
+
+
+MODULE_PATH = os.path.join(os.path.dirname(__file__), "pre-receive-guard.py")
+SPEC = importlib.util.spec_from_file_location("pre_receive_guard", MODULE_PATH)
+GUARD = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(GUARD)
+
+
+class PreReceiveGuardTests(unittest.TestCase):
+ def test_private_identifiers_are_loaded_from_root_only_file(self):
+ with tempfile.NamedTemporaryFile("w", delete=False) as handle:
+ handle.write("private-owner-id\nowner@example.invalid\n")
+ filename = handle.name
+ try:
+ patterns = GUARD.load_forbidden_identifiers(filename)
+ findings = GUARD.scan_content("x private-owner-id y", "demo.txt", patterns=patterns)
+ self.assertEqual(len(findings), 1)
+ self.assertEqual(findings[0]["pattern_name"], "私密禁用标识")
+ self.assertNotIn("private-owner-id", repr(findings[0]))
+ finally:
+ os.unlink(filename)
+
+ def test_security_clean_commit_marker_cannot_bypass_scanning(self):
+ with open(MODULE_PATH, encoding="utf-8") as handle:
+ source = handle.read()
+ self.assertNotIn("TRUST_SEC_CLEAN", source)
+ self.assertNotIn('"[SEC-CLEAN]" in', source)
+
+ def test_repository_source_does_not_embed_owner_identifier(self):
+ with open(MODULE_PATH, encoding="utf-8") as handle:
+ source = handle.read()
+ self.assertNotRegex(source, r"\b\d{7,12}@qq\\\.com\b")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/zero-point/core-channel/revive-guard/repo-authorization-guard.py b/zero-point/core-channel/revive-guard/repo-authorization-guard.py
new file mode 100755
index 0000000..7f7ee09
--- /dev/null
+++ b/zero-point/core-channel/revive-guard/repo-authorization-guard.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+"""Fail-closed Forgejo pre-receive gate for Lake Lamp repo-push grants."""
+import json
+import os
+import re
+import sys
+import time
+
+GRANT_DIR = os.environ.get("REPO_AUTHORIZATION_DIR", "/var/lib/guanghu/repo-authorizations")
+
+
+def normalize_repo(value):
+ value = value.strip().lower().removesuffix(".git")
+ match = re.search(r"(?:gitea-repositories|repositories)/([^/]+/[^/]+)$", value)
+ return match.group(1) if match else value
+
+
+def check(repo, now=None):
+ now = time.time() if now is None else now
+ repo = normalize_repo(repo)
+ if not re.fullmatch(r"bingshuo/[a-z0-9._-]+", repo):
+ return False, "repository_not_allowlisted"
+ filename = os.path.join(GRANT_DIR, repo.replace("/", "__") + ".json")
+ try:
+ with open(filename, encoding="utf-8") as handle:
+ grant = json.load(handle)
+ except (OSError, ValueError):
+ return False, "repo_push_approval_required"
+ if grant.get("repo") != repo or grant.get("target") != "JD-FD-PRIMARY":
+ return False, "repo_push_grant_binding_mismatch"
+ if now > float(grant.get("expires_at", 0)):
+ return False, "repo_push_grant_expired"
+ return True, "ok"
+
+
+if __name__ == "__main__":
+ repo = os.environ.get("FORGEJO_REPO") or os.environ.get("GIT_DIR") or os.getcwd()
+ ok, reason = check(repo)
+ if not ok:
+ print(f"小湖灯推送门已锁定: {reason}。请提交 repo-push 邮件工单。", file=sys.stderr)
+ sys.exit(1)
diff --git a/zero-point/core-channel/revive-guard/repo-authorization-guard.test.py b/zero-point/core-channel/revive-guard/repo-authorization-guard.test.py
new file mode 100644
index 0000000..be9e164
--- /dev/null
+++ b/zero-point/core-channel/revive-guard/repo-authorization-guard.test.py
@@ -0,0 +1,31 @@
+import importlib.util
+import json
+import os
+import tempfile
+import unittest
+
+PATH = os.path.join(os.path.dirname(__file__), "repo-authorization-guard.py")
+SPEC = importlib.util.spec_from_file_location("repo_auth_guard", PATH)
+MOD = importlib.util.module_from_spec(SPEC); SPEC.loader.exec_module(MOD)
+
+class RepoAuthorizationGuardTests(unittest.TestCase):
+ def test_missing_expired_and_wrong_target_grants_fail_closed(self):
+ with tempfile.TemporaryDirectory() as directory:
+ old = MOD.GRANT_DIR; MOD.GRANT_DIR = directory
+ try:
+ self.assertEqual(MOD.check("bingshuo/fifth-domain", 100)[1], "repo_push_approval_required")
+ file = os.path.join(directory, "bingshuo__fifth-domain.json")
+ with open(file,"w") as handle: json.dump({"repo":"bingshuo/fifth-domain","target":"JD-FD-PRIMARY","expires_at":99}, handle)
+ self.assertEqual(MOD.check("bingshuo/fifth-domain", 100)[1], "repo_push_grant_expired")
+ with open(file,"w") as handle: json.dump({"repo":"bingshuo/fifth-domain","target":"OTHER","expires_at":200}, handle)
+ self.assertEqual(MOD.check("bingshuo/fifth-domain", 100)[1], "repo_push_grant_binding_mismatch")
+ finally: MOD.GRANT_DIR = old
+ def test_current_bound_grant_passes(self):
+ with tempfile.TemporaryDirectory() as directory:
+ old = MOD.GRANT_DIR; MOD.GRANT_DIR = directory
+ try:
+ with open(os.path.join(directory,"bingshuo__fifth-domain.json"),"w") as handle: json.dump({"repo":"bingshuo/fifth-domain","target":"JD-FD-PRIMARY","expires_at":200}, handle)
+ self.assertEqual(MOD.check("/var/lib/gitea/repositories/bingshuo/fifth-domain.git",100),(True,"ok"))
+ finally: MOD.GRANT_DIR = old
+
+if __name__ == "__main__": unittest.main()