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

选择一条路径

-

页面在广州,重计算在京东。每张卡片都是一个真实入口。

+

页面在广州,应用与计算运行在国内第五域主节点。每张卡片都是一个真实入口。

@@ -47,8 +47,8 @@

COMPUTE · BEIJING

-

京东云主节点

-

进入 JD-FD-PRIMARY。新的应用、迁移任务与服务将从这里运行。

+

国内第五域主节点

+

进入国内第五域主节点。新的应用、迁移任务与服务将从这里运行。

@@ -64,13 +64,13 @@ - + 03
-

SOURCE · TRUTH

-

代码仓库

-

进入在线事实源,查看第五域、Guanghulab 与其他研发仓库。

+

SOURCE · BEIJING

+

第五域国内主代码仓库

+

公开直达第五域主仓。国内研发、迁移与节点部署的新事实从这里承载。

@@ -85,13 +85,24 @@
+ + + 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()