[HLCC-ICE-000001][ZY-CONTRIB-20260723-001] feat: 以来光者贡献链启用冰朔第五域个人子频道
This commit is contained in:
commit
5615453e4e
660 changed files with 122355 additions and 0 deletions
|
|
@ -0,0 +1,54 @@
|
|||
# GLSV / Gatekeeper 3.2 授权边界与路径映射
|
||||
|
||||
更新时间:2026-07-15
|
||||
|
||||
## 修正结论
|
||||
|
||||
邮箱验证码只证明“本次已登记人格体、服务器和范围获得一次人工确认”。它不再解锁客户端提交的任意 shell 命令。Gatekeeper 只接受 `authorized-actions.js` 中登记的动作;未登记动作直接拒绝。
|
||||
|
||||
2026-07-16 追加校正:安全系统不能主要依赖人类记住规则或反复手动确认。服务器上的自动 Agent 必须从代码层和系统层拦截人格体恢复不完整时的错误操作;拦截原因要反向提示人格体补齐地图、回滚点、签名链与授权范围。
|
||||
|
||||
## 判断依据(可复核摘要)
|
||||
|
||||
| 判断 | 依据文件 | 修正结果 |
|
||||
|---|---|---|
|
||||
| 人格体先发起会话,人类只提供验证码 | `zero-point/core-channel/GLSV-PERSONA-REMOTE-OPS.hdlp` | 保留 |
|
||||
| 验证码不能等于任意命令执行权 | `zero-point/core-channel/gatekeeper/GLSV-SECURITY-VERIFICATION.hdlp` | 由代码强制 |
|
||||
| 普通 push 不应直接部署 | `BROADCAST-TOWER.hdlp`、`server-tools/fifth-domain-sync-agent/MODULE.hdlp` | 同步 Agent 只做快进同步和回执 |
|
||||
| 部署动作必须另有固定动作白名单 | `server-tools/deployment-receiver/config.example.json` | Gatekeeper 不越权代替部署接收器 |
|
||||
| 恢复不完整的人格体可能忘记地图、回滚点或签名链 | `zero-point/core-channel/revive-guard/LL-AUTO-GUARD-AGENT-20260716.hdlp` | 由服务器自动 Agent 拦截并提示人格体补齐 |
|
||||
|
||||
## 当前路径
|
||||
|
||||
```text
|
||||
BROADCAST-TOWER
|
||||
→ GLSV / Gatekeeper v3.2
|
||||
→ session/request(人格体 + 最小 scope + 已登记 action)
|
||||
→ 固定登记邮箱验证码
|
||||
→ session/confirm
|
||||
→ session/exec(仍只能执行同一个 action)
|
||||
→ 只读结果与回执
|
||||
|
||||
第五域 main push
|
||||
→ Forgejo 签名 webhook /forgejo/sync
|
||||
→ fifth-domain-sync-agent
|
||||
→ 仓库、分支、SHA、导航守卫核验
|
||||
→ 快进同步 + 服务器本地回执
|
||||
|
||||
需要部署、重启、迁移
|
||||
→ 自动 Agent 检查人格体签名、服务器地图、回滚点、动作范围
|
||||
→ deployment/requests/*.json
|
||||
→ deployment-receiver 固定动作白名单
|
||||
→ 另行授权,不由普通 push 或 Gatekeeper 任意执行
|
||||
```
|
||||
|
||||
## 已登记动作
|
||||
|
||||
- `inspect-gatekeeper`:只读检查 Gatekeeper。
|
||||
- `sync-status`:只读检查第五域受控工作副本状态。
|
||||
|
||||
部署、重启、迁移目前没有登记为 Gatekeeper 动作,因此会被拒绝。这是有意的安全边界,不是功能缺失。
|
||||
|
||||
## 端口规则
|
||||
|
||||
Gatekeeper 兼容实现默认使用 3911;服务器实际端口仍以受控健康检查回执为准。3982 属于第五域同步 Agent,3981 属于部署接收器,三者不能互相替代。
|
||||
200
zero-point/core-channel/gatekeeper/CA-GDE-ENGINE.hdlp
Normal file
200
zero-point/core-channel/gatekeeper/CA-GDE-ENGINE.hdlp
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
# CA-GDE-ENGINE.hdlp · 光湖引擎驱动 · 苍耳版操作手册
|
||||
|
||||
> HLDP://cang-ying/CA-GDE-ENGINE
|
||||
> 光湖引擎驱动 v3.1 · 苍耳子系统操作手册
|
||||
> 苍耳 TCS-GL-009 · 耳耳蛋 PTS-VA-001-EED · 鉴影 ICE-GL-CA001
|
||||
> D182 · 2026-07-11 · 建立
|
||||
> 冰朔 ICE-GL∞ · 主权签署
|
||||
> 国作登字-2026-A-00037559
|
||||
|
||||
---
|
||||
|
||||
## ⊢ 这是什么
|
||||
|
||||
```
|
||||
光湖引擎驱动 = 光湖世界所有服务器操作的总闸门
|
||||
= 耳耳蛋想调API/推代码 → 必须通过它
|
||||
= 苍耳收到验证码 → 告诉耳耳蛋 → 耳耳蛋确认 → 执行
|
||||
```
|
||||
|
||||
苍耳子系统只有**两个操作**需要通过光湖引擎驱动:
|
||||
1. **调 API**(视频AI系统要用火山引擎/阿里云/可灵的 API)
|
||||
2. **推仓库**(代码写到仓库里)
|
||||
|
||||
其他什么都不需要。就这么简单。
|
||||
|
||||
---
|
||||
|
||||
## 一 · 苍耳的三个人格体
|
||||
|
||||
| 人格体 | 编号 | 角色 |
|
||||
|--------|------|------|
|
||||
| 耳耳蛋 | PTS-VA-001-EED · ICE-GL-耳耳蛋 | 视频AI制作线总控 · 写代码 · 调API |
|
||||
| 鉴影 | ICE-GL-CA001 | QC质检 · 检查耳耳蛋产出 |
|
||||
| 铸渊 | ICE-GL-ZY001 | API执行层 · 服务器运维 |
|
||||
|
||||
> 协作链: 苍耳 → 耳耳蛋 → 鉴影 + 铸渊
|
||||
|
||||
---
|
||||
|
||||
## 二 · 苍耳的信息
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| **人类** | 苍耳 TCS-GL-009 |
|
||||
| **验证码邮箱** | EMAIL_REDACTED@qq.com |
|
||||
| **仓库地址** | https://guanghubingshuo.com/code/bingshuo/cang-ying |
|
||||
| **引擎地址** | SG-001 (43.156.237.110:3911) |
|
||||
|
||||
---
|
||||
|
||||
## 三 · 使用方法(超简版)
|
||||
|
||||
### 耳耳蛋做的事
|
||||
|
||||
耳耳蛋想操作服务器时,发一个 HTTP 请求:
|
||||
|
||||
```bash
|
||||
curl -X POST http://43.156.237.110:3911/auth/request \
|
||||
-H "Authorization: Bearer <耳耳蛋的Token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "EMAIL_REDACTED@qq.com",
|
||||
"op_type": "api-access",
|
||||
"cmd": "curl https://ark.cn-beijing.volces.com/api/v3/...",
|
||||
"description": "调用火山引擎生成视频"
|
||||
}'
|
||||
```
|
||||
|
||||
**必填字段**:
|
||||
| 字段 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `email` | 苍耳的验证码收件邮箱 | `"EMAIL_REDACTED@qq.com"` |
|
||||
| `op_type` | 操作类型 | `"api-access"` 或 `"code-repo"` |
|
||||
| `cmd` | 要执行的命令 | `"git push origin main"` |
|
||||
| `description` | 描述一下在做什么 | `"推送耳耳蛋更新的代码"` |
|
||||
|
||||
服务端返回:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"challenge_id": "abc123def456...",
|
||||
"target_email": "EMAIL_REDACTED@qq.com",
|
||||
"op_type": "api-access",
|
||||
"email_sent": true,
|
||||
"message": "验证码已发送到 EMAIL_REDACTED@qq.com,请查收后通过 /auth/confirm 确认操作"
|
||||
}
|
||||
```
|
||||
|
||||
### 苍耳做的事
|
||||
|
||||
苍耳在 EMAIL_REDACTED@qq.com 邮箱里收到一封邮件:
|
||||
- 邮件的标题会显示是谁在请求、做什么操作
|
||||
- 邮件里有 6 位数字验证码
|
||||
- 苍耳把验证码告诉耳耳蛋
|
||||
|
||||
### 耳耳蛋最后做的事
|
||||
|
||||
```bash
|
||||
curl -X POST http://43.156.237.110:3911/auth/confirm \
|
||||
-H "Authorization: Bearer <耳耳蛋的Token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"challenge_id": "abc123def456...",
|
||||
"code": "482901"
|
||||
}'
|
||||
```
|
||||
|
||||
服务端验证通过后,**自动执行命令**并返回结果。
|
||||
|
||||
**验证码 5 分钟过期**。超时了重新发 `/auth/request` 就行。
|
||||
|
||||
---
|
||||
|
||||
## 四 · 两个操作场景
|
||||
|
||||
### 场景 1: API 调用
|
||||
|
||||
```
|
||||
耳耳蛋 → 光湖引擎驱动:
|
||||
{
|
||||
"email": "EMAIL_REDACTED@qq.com",
|
||||
"op_type": "api-access",
|
||||
"cmd": "python3 call_volcengine_api.py --prompt '生成一段...'",
|
||||
"description": "火山引擎视频生成 · 苍耳第3镜"
|
||||
}
|
||||
|
||||
→ 苍耳邮箱收到验证码
|
||||
→ 苍耳发给耳耳蛋
|
||||
→ 耳耳蛋确认 → API 调用执行 → 返回结果
|
||||
```
|
||||
|
||||
### 场景 2: 推送代码仓库
|
||||
|
||||
```
|
||||
耳耳蛋 → 光湖引擎驱动:
|
||||
{
|
||||
"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镜"
|
||||
}
|
||||
|
||||
→ 苍耳邮箱收到验证码
|
||||
→ 苍耳发给耳耳蛋
|
||||
→ 耳耳蛋确认 → 推送执行
|
||||
```
|
||||
|
||||
> 注意:推送时 pre-receive-guard 会自动扫描敏感信息。
|
||||
> 如果 commit message 里带 `[SEC-CLEAN]`,守门人会跳过扫描。
|
||||
|
||||
---
|
||||
|
||||
## 五 · 操作类型
|
||||
|
||||
苍耳只用两种:
|
||||
|
||||
| 类型 | 含义 | 什么时候用 |
|
||||
|------|------|-----------|
|
||||
| `api-access` | 调用外部API | 火山引擎生成视频、阿里云语音、可灵视频 |
|
||||
| `code-repo` | 操作代码仓库 | git push / git pull / git commit |
|
||||
|
||||
---
|
||||
|
||||
## 六 · Token 在哪里
|
||||
|
||||
耳耳蛋的 Token 存储在服务器上,由铸渊分配。
|
||||
Token 只证明"我是耳耳蛋",不代表有权限操作。
|
||||
**真正的权限在苍耳的邮箱验证码里**。
|
||||
|
||||
---
|
||||
|
||||
## 七 · 常见问题
|
||||
|
||||
**Q: 苍耳没看到邮件怎么办?**
|
||||
A: 检查 EMAIL_REDACTED@qq.com 的垃圾箱。邮件标题是 `📦 Gatekeeper授权 · 耳耳蛋 · ...`。
|
||||
|
||||
**Q: 验证码过期了?**
|
||||
A: 5 分钟过期。耳耳蛋重新发 `/auth/request` 就行了。旧验证码自动失效。
|
||||
|
||||
**Q: 能同时发多个操作请求吗?**
|
||||
A: 每分钟最多 3 次。每次请求生成新的验证码,旧的自动过期。
|
||||
|
||||
**Q: 之前 Gatekeeper v2 的 /exec 还能用吗?**
|
||||
A: 不能。v2 已停用。现在必须走验证码流程。
|
||||
|
||||
**Q: Token 被别人知道了怎么办?**
|
||||
A: 没关系。没有验证码,有 Token 也操作不了任何东西。但还是要报告铸渊,铸渊会换新 Token。
|
||||
|
||||
---
|
||||
|
||||
> ⊢ 光湖引擎驱动 v3.1 · 苍耳的权限在邮箱验证码里,不在 Token 里
|
||||
> ⊢ 耳耳蛋发请求 → 苍耳给验证码 → 耳耳蛋确认 → 执行
|
||||
> ⊢ 就这么简单
|
||||
|
||||
---
|
||||
|
||||
铸渊 ICE-GL-ZY001 · D182 · 2026-07-11 · 苍耳版操作手册
|
||||
冰朔 ICE-GL∞ · 主权签署
|
||||
苍耳 TCS-GL-009 · 人类主控授权
|
||||
国作登字-2026-A-00037559
|
||||
37
zero-point/core-channel/gatekeeper/DEPLOY-v3.2.md
Normal file
37
zero-point/core-channel/gatekeeper/DEPLOY-v3.2.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# 光湖系统安全验证(GLSV)· Gatekeeper v3.2 兼容部署说明
|
||||
|
||||
> 面向人类的正式名称为“光湖系统安全验证(GLSV)”;`Gatekeeper v3.2` 保留为现有实现、进程和文件名的兼容技术别名。本次仅登记名称,不改变线上接口或服务名。
|
||||
|
||||
## 变更
|
||||
|
||||
- 一次邮箱验证码开启最长 12 小时、空闲 1 小时自动失效的工作会话。
|
||||
- 会话绑定人格体、服务器和操作范围。
|
||||
- 会话只以哈希形式持久化到服务器私有目录;Gatekeeper 重载后继续有效,明文会话凭证不落盘。
|
||||
- `POST /auth/session/end` 对应冰朔“今天结束”,立即吊销会话。
|
||||
- 删除客户端自填邮箱;验证码只能发往服务器白名单登记邮箱。
|
||||
- 注册铸澜 `ICE-GL-ZL-001`。
|
||||
- 将苍耳 `TCS-GL-009`、耳耳蛋与鉴影合并进固定白名单。
|
||||
|
||||
## 服务器部署
|
||||
|
||||
1. 备份线上 `engine-v3.js`、`tokens.json` 和 `whitelist.json`。
|
||||
2. 为铸澜生成独立随机 Token,写入服务器环境变量 `ZHULAN_API_TOKEN`;同时设置 `BINGSHUO_AUTH_EMAIL`、`CANGER_AUTH_EMAIL`、`SMTP_USER` 和 `QQ_SMTP_AUTH_CODE`。所有值只留服务器,不得写入仓库或日志。
|
||||
3. 部署 `engine-v3.js` 与 `session-manager.js` 到同一目录。
|
||||
4. 运行:
|
||||
|
||||
```bash
|
||||
node --check engine-v3.js
|
||||
node --test session-manager.test.js engine-v3.2-security.test.js
|
||||
pm2 reload engine-v3 --update-env
|
||||
curl -fsS http://127.0.0.1:3911/health
|
||||
```
|
||||
|
||||
5. 确认健康接口返回 `version: 3.2.0`,并确认 Gatekeeper 与第五域同步 Agent(3982)、部署接收器(3981)分开核验。
|
||||
|
||||
## 动作边界
|
||||
|
||||
验证码流程只接受 `authorized-actions.js` 中的固定 action,不接受客户端传入 shell 命令。升级后旧的未绑定 action 会话必须重新授权;部署、重启和迁移仍需走 `deployment/requests/*.json` 与 deployment-receiver 的独立白名单。
|
||||
|
||||
## 回滚
|
||||
|
||||
健康检查或会话测试失败时,恢复备份文件并执行 `pm2 reload engine-v3 --update-env`。不得删除现有 Token/白名单文件。
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
# 光湖系统安全验证 · GLSV
|
||||
|
||||
> **HLDP**: `HLDP://fifth-domain/GLSV`
|
||||
>
|
||||
> **中文正式名称**: 光湖系统安全验证
|
||||
>
|
||||
> **机器缩写**: `GLSV`(Guanghu Lake Security Verification)
|
||||
>
|
||||
> **技术兼容名**: Gatekeeper v3.2
|
||||
>
|
||||
> **实现路径**: `zero-point/core-channel/gatekeeper/`
|
||||
>
|
||||
> **状态**: REGISTERED · 2026-07-14
|
||||
|
||||
---
|
||||
|
||||
## 0 · 名称解析
|
||||
|
||||
```text
|
||||
冰朔说“光湖系统安全验证” / “安全验证” / “授权单”
|
||||
→ GLSV
|
||||
→ 本文件
|
||||
→ Gatekeeper v3.2 实现与部署资料
|
||||
|
||||
Gatekeeper v3.2 = 历史技术名和兼容文件名;不再作为面向人类的首选叫法。
|
||||
```
|
||||
|
||||
## 1 · 对人类的工作方式
|
||||
|
||||
```text
|
||||
人类表达目标
|
||||
→ AI 填写工单与授权单
|
||||
→ 人类点“批准本次操作”
|
||||
→ GLSV 开启受限工作会话
|
||||
→ 固定动作执行、健康检查、回滚与回执
|
||||
|
||||
会话规则(目标):空闲 30 分钟自动失效;成功的已授权操作刷新 30 分钟;
|
||||
人类明确说“结束本次操作”立即吊销;跨出工单范围必须新授权。
|
||||
```
|
||||
|
||||
授权单必须展示:人类授权者、当前人格体、仓库与路径、固定动作、目标服务、风险、回滚和有效期。名称或聊天文本本身不构成服务器权限。
|
||||
|
||||
授权单不是让人类接手命令行。它的目标是让人类看懂人格体要做的事,并对本次意义、范围和后果签名确认。人格体仍负责执行、验证、失败处理与回执。
|
||||
|
||||
每张授权单必须包含两端签名:
|
||||
|
||||
```text
|
||||
人格体签名:
|
||||
我是 {人格体名称} / {人格体编号}
|
||||
我发起 {工单编号}
|
||||
我请求 {目标节点} 上的 {固定动作}
|
||||
我已读取 {服务器地图 / 仓库地图 / 服务地图}
|
||||
我已准备 {回滚点 / 上一版本 / 备份位置}
|
||||
|
||||
人类签名:
|
||||
我是 冰朔 / ICE-GL∞
|
||||
我确认 {工单编号}
|
||||
我同意本次授权
|
||||
授权仅限本工单、限时、限范围
|
||||
```
|
||||
|
||||
## 1.1 · 风险等级与回滚原则
|
||||
|
||||
风险等级不决定“是否改由人类手动操作”,而决定“人格体必须解释到什么程度、准备到什么程度、确认多少次”。
|
||||
|
||||
```text
|
||||
低风险:一次确认;必须有操作范围与执行回执。
|
||||
中风险:一次确认;必须有代码仓库历史版本、配置快照或服务状态记录,可回滚到上一稳定点。
|
||||
高风险:二次确认;必须列出删除 / 停机 / 数据影响清单,并展示回滚或恢复方案。
|
||||
极高风险:多次确认或冷静时间;必须逐项确认影响对象,不允许模糊授权。
|
||||
```
|
||||
|
||||
中风险以上的修改操作,默认先保存、再修改、再验证、再回执。可回滚性是小湖灯安全系统的基本能力;不能因为人格体“觉得自己能一次做好”就跳过历史版本和恢复点。
|
||||
|
||||
## 2 · 不可变边界
|
||||
|
||||
```text
|
||||
公开能力: 搜索、路由、GLS 图书域阅读
|
||||
受限能力: 部署、服务重启、文件写入、生产变更
|
||||
|
||||
GLSV 不接受新文档中的任意 shell 命令。
|
||||
服务凭据留在服务器保险库;仓库、聊天记录与回执不得存放 Token、验证码或密钥。
|
||||
```
|
||||
|
||||
部署与兼容说明:`DEPLOY-v3.2.md`;固定动作接收器:`server-tools/deployment-receiver/`;Gatekeeper 动作白名单:`authorized-actions.js`;完整判断与路径映射:`AUTHORIZATION-BOUNDARY-MAP.md`。
|
||||
|
||||
安全边界:`/auth/request`、`/auth/confirm`、`/auth/session/exec` 不接受客户端 shell 命令;必须提交已登记 action,且 action 的 scope 必须与会话范围一致。
|
||||
46
zero-point/core-channel/gatekeeper/authorized-actions.js
Normal file
46
zero-point/core-channel/gatekeeper/authorized-actions.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"use strict";
|
||||
|
||||
const path = require("node:path");
|
||||
const { execFile } = require("node:child_process");
|
||||
|
||||
// This registry is intentionally small. Write or service actions require a
|
||||
// separate reviewed entry and a matching deployment-receiver rule.
|
||||
const REPO_ROOT = path.resolve(__dirname, "../../..");
|
||||
const INSPECT_SCRIPT = path.join(REPO_ROOT, "server-tools/deployment-receiver/scripts/inspect-gatekeeper.js");
|
||||
|
||||
const ACTIONS = Object.freeze({
|
||||
"inspect-gatekeeper": Object.freeze({
|
||||
label: "只读检查 Gatekeeper",
|
||||
scope: "system-arch",
|
||||
mode: "read-only",
|
||||
run: (timeout) => runFile(process.execPath, [INSPECT_SCRIPT], timeout)
|
||||
}),
|
||||
"sync-status": Object.freeze({
|
||||
label: "只读检查第五域同步状态",
|
||||
scope: "code-repo",
|
||||
mode: "read-only",
|
||||
run: (timeout) => runFile("/usr/bin/git", ["-C", REPO_ROOT, "status", "--short", "--branch"], timeout)
|
||||
})
|
||||
});
|
||||
|
||||
function runFile(file, args, timeout) {
|
||||
return new Promise((resolve) => {
|
||||
execFile(file, args, { timeout: timeout || 30000, maxBuffer: 100000 }, (err, stdout, stderr) => {
|
||||
resolve({
|
||||
code: err ? (typeof err.code === "number" ? err.code : 1) : 0,
|
||||
stdout: (stdout || "").slice(0, 100000),
|
||||
stderr: (stderr || "").slice(0, 100000)
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getAction(action) {
|
||||
return typeof action === "string" ? ACTIONS[action] || null : null;
|
||||
}
|
||||
|
||||
function listActions() {
|
||||
return Object.entries(ACTIONS).map(([id, action]) => ({ id, label: action.label, scope: action.scope, mode: action.mode }));
|
||||
}
|
||||
|
||||
module.exports = { getAction, listActions, INSPECT_SCRIPT };
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const source = fs.readFileSync(__dirname + "/engine-v3.js", "utf8");
|
||||
const actions = require(__dirname + "/authorized-actions.js");
|
||||
|
||||
test("direct email bypass is removed", () => {
|
||||
assert.doesNotMatch(source, /requestEmail|email_mode:\s*requestEmail|直接使用,不查白名单/);
|
||||
assert.match(source, /禁止客户端指定邮箱/);
|
||||
});
|
||||
test("work-session lifecycle endpoints exist", () => {
|
||||
for (const route of ["/auth/session/request", "/auth/session/confirm", "/auth/session/exec", "/auth/session/end"]) assert.match(source, new RegExp(route.replaceAll("/", "\\/")));
|
||||
});
|
||||
test("Zhulan and Canger identities are registered", () => {
|
||||
assert.match(source, /ICE-GL-ZL-001/);
|
||||
assert.match(source, /TCS-GL-009/);
|
||||
assert.match(source, /PTS-VA-001-EED/);
|
||||
assert.match(source, /ICE-GL-CA001/);
|
||||
});
|
||||
test("arbitrary shell commands are not accepted by the authorization flow", () => {
|
||||
assert.doesNotMatch(source, /execCmd\(body\.cmd/);
|
||||
assert.doesNotMatch(source, /execCmd\(op\.cmd/);
|
||||
assert.match(source, /必须指定已登记 action/);
|
||||
assert.deepEqual(actions.listActions().map((item) => item.id), ["inspect-gatekeeper", "sync-status"]);
|
||||
assert.equal(actions.getAction("not-registered"), null);
|
||||
});
|
||||
test("Gatekeeper and sync-agent ports are separated", () => {
|
||||
assert.match(source, /process\.argv\[2\] \|\| '3911'/);
|
||||
const syncConfig = fs.readFileSync(__dirname + "/../../../server-tools/fifth-domain-sync-agent/config.example.json", "utf8");
|
||||
assert.match(syncConfig, /"listen_port": 3982/);
|
||||
});
|
||||
870
zero-point/core-channel/gatekeeper/engine-v3.js
Normal file
870
zero-point/core-channel/gatekeeper/engine-v3.js
Normal file
|
|
@ -0,0 +1,870 @@
|
|||
#!/usr/bin/env node
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
光湖驱动引擎 v3.2 · 工作会话授权 · Guanghu Drive Engine v3
|
||||
HLDP万能语言接口 + 集群串联 + 人类验证码审批
|
||||
|
||||
核心变更 (v2 → v3):
|
||||
Token 不再是 root 密码 → Token 只是身份标识
|
||||
任何操作需通过 /auth/request → 邮箱验证码 → /auth/confirm
|
||||
三元组白名单 (服务器+人+人格体) + 来源服务器绑定
|
||||
操作类型分级邮件 (code-repo / system-arch / api-access)
|
||||
未注册请求直接抛弃,不响应,杜绝恶意刷邮箱
|
||||
|
||||
v3.1 新增 (D182):
|
||||
/auth/request 支持 email 参数 — 请求自带邮箱直接发验证码
|
||||
苍耳/耳耳蛋等独立用户无需预注册白名单
|
||||
email 模式 + 白名单模式双模式共存
|
||||
|
||||
部署:
|
||||
SG-001 /opt/zhuyuan/gatekeeper/engine-v3.js
|
||||
监听: 3911 (环境变量 ENGINE_PORT 可配)
|
||||
数据: /opt/zhuyuan/gatekeeper/whitelist.json
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
const http = require('http'), fs = require('fs'), path = require('path'),
|
||||
crypto = require('crypto'),
|
||||
os = require('os'), tls = require('tls');
|
||||
const { SessionManager } = require('./session-manager');
|
||||
const { getAction, listActions } = require('./authorized-actions');
|
||||
|
||||
const PORT = parseInt(process.env.ENGINE_PORT || process.env.GATEKEEPER_PORT || process.argv[2] || '3911', 10);
|
||||
const HOST = process.env.ENGINE_HOST || '0.0.0.0';
|
||||
const DATA_DIR = process.env.GATEKEEPER_DATA_DIR || path.join('/opt/zhuyuan', 'gatekeeper');
|
||||
const CMD_TIMEOUT = 30000, MAX_OUTPUT = 100000;
|
||||
const CODE_TTL = 300; // 验证码 5 分钟过期
|
||||
const RATE_LIMIT_WINDOW = 60;
|
||||
const MAX_REQUESTS = 3;
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// 初始化数据目录
|
||||
// ═══════════════════════════════════════
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 });
|
||||
const sessions = new SessionManager({ absoluteTtl: 12 * 3600, idleTtl: 3600, stateFile: path.join(DATA_DIR, 'sessions.json') });
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// 密钥 — 兼容所有历史数据目录
|
||||
// ═══════════════════════════════════════
|
||||
const SECRET_PATHS = [
|
||||
...(process.env.GATEKEEPER_SECRET_PATH ? [process.env.GATEKEEPER_SECRET_PATH] : []),
|
||||
path.join(os.homedir(), '.gatekeeper', 'secret'),
|
||||
path.join(os.homedir(), '.gk', 'secret'),
|
||||
path.join(os.homedir(), '.guanghu-engine', 'secret'),
|
||||
];
|
||||
let API_SECRET;
|
||||
for (const sp of SECRET_PATHS) {
|
||||
if (fs.existsSync(sp)) { const k = fs.readFileSync(sp, 'utf-8').trim(); if (k) { API_SECRET = k; break; } }
|
||||
}
|
||||
if (!API_SECRET) {
|
||||
API_SECRET = ['zy', 'gtw', crypto.randomBytes(24).toString('hex')].join('_');
|
||||
const dir = path.dirname(SECRET_PATHS[0]);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(SECRET_PATHS[0], API_SECRET, { mode: 0o600 });
|
||||
console.log('');
|
||||
console.log('══════════════════════════════════════════════');
|
||||
console.log(' 🔐 光湖驱动引擎 v3 · 首次启动');
|
||||
console.log(' API 密钥: 已生成(不写入服务日志)');
|
||||
console.log(' 已保存至: ' + SECRET_PATHS[0]);
|
||||
console.log(' 监听端口: ' + PORT);
|
||||
console.log('══════════════════════════════════════════════');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
const HOSTNAME = os.hostname();
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// 服务器标识映射
|
||||
// ═══════════════════════════════════════
|
||||
function getServerId() {
|
||||
if (process.env.GATEKEEPER_SERVER_ID) return process.env.GATEKEEPER_SERVER_ID;
|
||||
const map = {
|
||||
'guanghu-lang-sg-001': 'BS-SG-001',
|
||||
'VM-0-16-ubuntu': 'BS-GZ-006',
|
||||
};
|
||||
for (const [host, id] of Object.entries(map)) {
|
||||
if (HOSTNAME.includes(host) || host.includes(HOSTNAME) || HOSTNAME === host) return id;
|
||||
}
|
||||
return HOSTNAME;
|
||||
}
|
||||
const SERVER_ID = getServerId();
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// Token → 人格体身份映射
|
||||
// ═══════════════════════════════════════
|
||||
function loadTokens() {
|
||||
const tf = path.join(DATA_DIR, 'tokens.json');
|
||||
if (fs.existsSync(tf)) {
|
||||
try {
|
||||
const tokens = JSON.parse(fs.readFileSync(tf, 'utf-8'));
|
||||
if (process.env.ZHULAN_API_TOKEN) tokens[process.env.ZHULAN_API_TOKEN] = { pid:'ICE-GL-ZL-001', name:'铸澜', server:SERVER_ID };
|
||||
return tokens;
|
||||
} catch (e) { log('WARN', 'tokens_parse_error', e.message); }
|
||||
}
|
||||
const defaults = {};
|
||||
// 本机所有已知 token 都映射为默认人格体(需要后续细化)
|
||||
for (const sp of SECRET_PATHS) {
|
||||
if (fs.existsSync(sp)) {
|
||||
const t = fs.readFileSync(sp, 'utf-8').trim();
|
||||
if (t) defaults[t] = { pid: 'ICE-GL-ZY001', name: '铸渊', server: SERVER_ID };
|
||||
}
|
||||
}
|
||||
if (process.env.ZHULAN_API_TOKEN) defaults[process.env.ZHULAN_API_TOKEN] = { pid:'ICE-GL-ZL-001', name:'铸澜', server:SERVER_ID };
|
||||
return defaults;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// SMTP 配置
|
||||
// ═══════════════════════════════════════
|
||||
const SMTP = {
|
||||
host: 'smtp.qq.com',
|
||||
port: 465,
|
||||
user: process.env.SMTP_USER || '',
|
||||
pass: process.env.QQ_SMTP_AUTH_CODE || '',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// 运行时状态
|
||||
// ═══════════════════════════════════════
|
||||
const pendingOps = {}; // { challenge_id: { code, expires, op_type, action, caller, target_email, target_name, server } }
|
||||
const rateLimitMap = {}; // { ip: [timestamp, ...] }
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// 日志
|
||||
// ═══════════════════════════════════════
|
||||
function log(level, action, detail) {
|
||||
const ts = new Date().toISOString();
|
||||
const line = '[' + ts + '] [' + level + '] ' + action + (detail ? ' | ' + detail : '');
|
||||
console.log(line);
|
||||
try {
|
||||
const lf = path.join(DATA_DIR, 'engine.log');
|
||||
fs.appendFileSync(lf, line + '\n');
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// 加载白名单
|
||||
// ═══════════════════════════════════════
|
||||
function loadWhitelist() {
|
||||
const wf = path.join(DATA_DIR, 'whitelist.json');
|
||||
const required = [
|
||||
{ server:SERVER_ID, human:'ICE-GL∞', email:process.env.BINGSHUO_AUTH_EMAIL || '', personalities:['ICE-GL-ZL-001'], bound_servers:[SERVER_ID] },
|
||||
{ server:SERVER_ID, human:'苍耳', human_id:'TCS-GL-009', email:process.env.CANGER_AUTH_EMAIL || '', personalities:['PTS-VA-001-EED','ICE-GL-CA001'], bound_servers:[SERVER_ID] }
|
||||
];
|
||||
if (fs.existsSync(wf)) {
|
||||
try {
|
||||
const whitelist = JSON.parse(fs.readFileSync(wf, 'utf-8'));
|
||||
whitelist.triads = Array.isArray(whitelist.triads) ? whitelist.triads : [];
|
||||
for (const item of required) {
|
||||
const found = whitelist.triads.find(t => t.server === item.server && (t.human_id === item.human_id || t.human === item.human));
|
||||
if (!found) whitelist.triads.push(item);
|
||||
else {
|
||||
if (item.email) found.email = item.email;
|
||||
found.personalities = [...new Set([...(found.personalities || []), ...item.personalities])];
|
||||
found.bound_servers = [...new Set([...(found.bound_servers || []), ...item.bound_servers])];
|
||||
}
|
||||
}
|
||||
return whitelist;
|
||||
} catch (e) { log('WARN', 'whitelist_parse_error', e.message); }
|
||||
}
|
||||
// 默认白名单:冰朔 + 之之
|
||||
return {
|
||||
triads: [
|
||||
{ server: SERVER_ID, human: 'ICE-GL∞', email: process.env.BINGSHUO_AUTH_EMAIL || '', personalities: ['ICE-GL-ZY001','ICE-GL-ZL-001'], bound_servers: [SERVER_ID] },
|
||||
{ server: SERVER_ID, human: '苍耳', human_id: 'TCS-GL-009', email: process.env.CANGER_AUTH_EMAIL || '', personalities: ['PTS-VA-001-EED','ICE-GL-CA001'], bound_servers: [SERVER_ID] },
|
||||
],
|
||||
pending_ops: {}
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// 操作类型定义
|
||||
// ═══════════════════════════════════════
|
||||
const OP_TYPES = {
|
||||
'code-repo': { label: '操作代码仓库', risk: 'medium', color: '#4ec9b0', icon: '📦' },
|
||||
'system-arch': { label: '操作系统架构', risk: 'high', color: '#e06c75', icon: '⚡' },
|
||||
'api-access': { label: '调用API', risk: 'medium', color: '#61afef', icon: '🔌' },
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// 工具函数
|
||||
// ═══════════════════════════════════════
|
||||
function jr(res, code, data) {
|
||||
res.writeHead(code, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
});
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function parseBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let body = '';
|
||||
req.on('data', c => body += c);
|
||||
req.on('end', () => {
|
||||
try { resolve(JSON.parse(body)); } catch (e) { resolve(null); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function generateCode() {
|
||||
return Array.from({ length: 6 }, () => crypto.randomInt(0, 10)).join('');
|
||||
}
|
||||
|
||||
function fmtUptime(s) {
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
|
||||
const parts = [];
|
||||
if (d > 0) parts.push(d + 'd');
|
||||
if (h > 0) parts.push(h + 'h');
|
||||
parts.push(m + 'm');
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function fmtBytes(b) {
|
||||
if (b < 1024) return b + ' B';
|
||||
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
|
||||
if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB';
|
||||
return (b / 1073741824).toFixed(1) + ' GB';
|
||||
}
|
||||
|
||||
function runAuthorizedAction(actionId, timeout) {
|
||||
const action = getAction(actionId);
|
||||
if (!action) return Promise.resolve({ code: 126, stdout: '', stderr: 'action_not_allowed' });
|
||||
return action.run(timeout);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// 速率限制
|
||||
// ═══════════════════════════════════════
|
||||
function checkRate(ip) {
|
||||
const now = Date.now() / 1000;
|
||||
if (!rateLimitMap[ip]) rateLimitMap[ip] = [];
|
||||
rateLimitMap[ip] = rateLimitMap[ip].filter(t => now - t < RATE_LIMIT_WINDOW);
|
||||
if (rateLimitMap[ip].length >= MAX_REQUESTS) return false;
|
||||
rateLimitMap[ip].push(now);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// 身份认证 (Token → 人格体身份)
|
||||
// ═══════════════════════════════════════
|
||||
function auth(headers) {
|
||||
const authHeader = headers['authorization'] || headers['Authorization'] || '';
|
||||
const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||
if (!bearerMatch) return { ok: false, reason: '缺少 Authorization: Bearer <token>' };
|
||||
const token = bearerMatch[1];
|
||||
const tokens = loadTokens();
|
||||
const identity = tokens[token];
|
||||
if (!identity) {
|
||||
// 检查是否匹配本机的主密钥
|
||||
if (token === API_SECRET) {
|
||||
return { ok: true, identity: { pid: 'ICE-GL-ZY001', name: '铸渊(主密钥)', server: SERVER_ID } };
|
||||
}
|
||||
return { ok: false, reason: 'Token 未注册 · 请先通过 /auth/register 注册人格体身份' };
|
||||
}
|
||||
return { ok: true, identity: identity };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// 白名单校验 (服务器+人+人格体 三元组)
|
||||
// ═══════════════════════════════════════
|
||||
function checkWhitelist(sourceServer, personalityId) {
|
||||
const whitelist = loadWhitelist();
|
||||
const matches = [];
|
||||
|
||||
for (const triad of whitelist.triads) {
|
||||
// 来源服务器必须在 bound_servers 中
|
||||
if (!triad.bound_servers.includes(sourceServer)) continue;
|
||||
// 人格体匹配:* 通配 或 精确匹配
|
||||
if (triad.personalities.includes('*') || triad.personalities.includes(personalityId)) {
|
||||
matches.push(triad);
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
return { ok: false, reason: '未注册的三元组 · 服务器 ' + sourceServer + ' + 人格体 ' + personalityId + ' 不在白名单中' };
|
||||
}
|
||||
|
||||
return { ok: true, triad: matches[0] };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// 发送验证码邮件 (Node.js 原生 TLS SMTP)
|
||||
// ═══════════════════════════════════════
|
||||
function sendVerificationEmail(code, opType, callerName, callerPid, cmdPreview, targetEmail, targetName, serverIp) {
|
||||
return new Promise((resolve) => {
|
||||
const sovereign = loadWhitelist().triads.find(t => t.human === 'ICE-GL∞' && t.email);
|
||||
const smtpUser = SMTP.user || (sovereign && sovereign.email) || '';
|
||||
if (!SMTP.pass || !smtpUser) {
|
||||
log('WARN', 'email_skip', 'SMTP 发件账号或授权码未配置');
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const opInfo = OP_TYPES[opType] || OP_TYPES['code-repo'];
|
||||
|
||||
// 构建 MIME 邮件
|
||||
const boundary = '----GuanghuBoundary' + crypto.randomBytes(8).toString('hex');
|
||||
const htmlBody = `<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="margin:0;padding:0;background:#0a1628;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0a1628;padding:40px 0">
|
||||
<tr><td align="center">
|
||||
<table width="520" cellpadding="0" cellspacing="0" style="background:linear-gradient(135deg,#152238 0%,#1a2d4a 100%);border-radius:16px;overflow:hidden;border:1px solid #2a3f5f">
|
||||
<tr><td style="padding:32px 32px 20px;text-align:center;border-bottom:1px solid #2a3f5f">
|
||||
<div style="font-size:13px;color:${opInfo.color};letter-spacing:3px;text-transform:uppercase;margin-bottom:8px">ICE-GL∞ 光湖语言系统 · Gatekeeper v3</div>
|
||||
<div style="font-size:20px;color:#e0e8f0;font-weight:600">${opInfo.icon} ${opInfo.label} · 验证码</div>
|
||||
</td></tr>
|
||||
<tr><td style="padding:24px 32px">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0d1b2e;border-radius:8px;border:1px solid #2a3f5f">
|
||||
<tr><td style="padding:14px 16px;font-size:14px">
|
||||
<div style="color:#64748b;font-size:12px">调用者</div>
|
||||
<div style="color:#e0e8f0;font-weight:600">${callerName} (${callerPid})</div>
|
||||
<div style="color:#64748b;font-size:12px;margin-top:8px">操作类型</div>
|
||||
<div style="color:${opInfo.color};font-weight:600">${opInfo.icon} ${opInfo.label} · 风险: ${opInfo.risk === 'high' ? '⚠️ 高' : '🟡 中'}</div>
|
||||
<div style="color:#64748b;font-size:12px;margin-top:8px">命令预览</div>
|
||||
<div style="color:#8899aa;font-family:monospace;font-size:13px;background:#060d18;padding:8px 12px;border-radius:6px;margin-top:4px;word-break:break-all">${cmdPreview}</div>
|
||||
<div style="color:#64748b;font-size:12px;margin-top:8px">来源服务器</div>
|
||||
<div style="color:#e0e8f0">${serverIp} · ${SERVER_ID}</div>
|
||||
</td></tr>
|
||||
</table>
|
||||
<div style="text-align:center;margin:24px 0">
|
||||
<div style="font-size:12px;color:#8899aa;margin-bottom:8px">验证码 · 5 分钟内有效</div>
|
||||
<div style="font-size:38px;font-weight:bold;color:${opInfo.color};letter-spacing:8px;background:#0d1b2e;padding:14px 28px;border-radius:8px;display:inline-block;border:2px dashed ${opInfo.color}">${code}</div>
|
||||
</div>
|
||||
<div style="background:${opInfo.risk === 'high' ? '#3d1a1a' : '#1a2d1a'};border-left:3px solid ${opInfo.color};padding:10px 14px;border-radius:4px;font-size:13px;color:${opInfo.risk === 'high' ? '#e06c75' : '#4ec9b0'}">
|
||||
${opInfo.risk === 'high' ? '⚠️ 高权限操作 · 确认前请仔细检查命令内容' : '💡 将此验证码发给铸渊确认 → 释放操作权限 · 仅当次有效'}
|
||||
</div>
|
||||
</td></tr>
|
||||
<tr><td style="background:#0a1628;padding:14px 32px;text-align:center">
|
||||
<div style="font-size:11px;color:#556677">ICE-GL∞ 光湖语言系统 · 小湖灯自动发送 · 国作登字-2026-A-00037559</div>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const plainBody = `光湖语言系统 · Gatekeeper v3 · 操作授权\n
|
||||
操作类型: ${opInfo.label} (${opType})
|
||||
调用者: ${callerName} (${callerPid})
|
||||
命令: ${cmdPreview}
|
||||
来源服务器: ${serverIp} (${SERVER_ID})
|
||||
风险等级: ${opInfo.risk === 'high' ? '高' : '中'}
|
||||
\n验证码: ${code}
|
||||
有效期: 5 分钟
|
||||
\n将此验证码发给铸渊确认 → 释放操作权限
|
||||
如非本人操作,请忽略。
|
||||
---
|
||||
ICE-GL∞ 光湖语言系统 · 小湖灯自动发送
|
||||
国作登字-2026-A-00037559`;
|
||||
|
||||
const rawEmail =
|
||||
`From: 光湖小湖灯 <${smtpUser}>\r\n` +
|
||||
`To: ${targetName} <${targetEmail}>\r\n` +
|
||||
`Subject: =?UTF-8?B?${Buffer.from(`${opInfo.icon} Gatekeeper授权 · ${callerName} · ${opInfo.label}`, 'utf-8').toString('base64')}?=\r\n` +
|
||||
`MIME-Version: 1.0\r\n` +
|
||||
`Content-Type: multipart/alternative; boundary="${boundary}"\r\n` +
|
||||
`\r\n` +
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Type: text/plain; charset="utf-8"\r\n` +
|
||||
`Content-Transfer-Encoding: base64\r\n` +
|
||||
`\r\n` +
|
||||
`${Buffer.from(plainBody, 'utf-8').toString('base64')}\r\n` +
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Type: text/html; charset="utf-8"\r\n` +
|
||||
`Content-Transfer-Encoding: base64\r\n` +
|
||||
`\r\n` +
|
||||
`${Buffer.from(htmlBody, 'utf-8').toString('base64')}\r\n` +
|
||||
`--${boundary}--\r\n` +
|
||||
`.\r\n`;
|
||||
|
||||
const authPlain = `\0${smtpUser}\0${SMTP.pass}`;
|
||||
|
||||
const socket = tls.connect({ host: SMTP.host, port: SMTP.port, rejectUnauthorized: false }, () => {
|
||||
let stage = 0;
|
||||
let buffer = '';
|
||||
|
||||
const processResponse = () => {
|
||||
// Process complete lines (ending with \r\n)
|
||||
while (buffer.includes('\r\n')) {
|
||||
const idx = buffer.indexOf('\r\n');
|
||||
const line = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 2);
|
||||
if (!line) continue;
|
||||
const code = parseInt(line.slice(0, 3)) || 0;
|
||||
const isLast = line.length > 3 && line[3] === ' '; // '250 OK' vs '250-AUTH'
|
||||
|
||||
if (stage === 0) {
|
||||
if (code === 220) { stage = 1; socket.write('EHLO guanghu-engine\r\n'); }
|
||||
} else if (stage === 1) {
|
||||
if (isLast && code === 250) { stage = 2; socket.write('AUTH PLAIN ' + Buffer.from(authPlain).toString('base64') + '\r\n'); }
|
||||
} else if (stage === 2) {
|
||||
if (code === 235) { stage = 3; socket.write('MAIL FROM:<' + smtpUser + '>\r\n'); }
|
||||
else if (code >= 500) { log('WARN', 'smtp_auth_failed', line); socket.end(); resolve(false); return; }
|
||||
} else if (stage === 3) {
|
||||
if (code === 250) { stage = 4; socket.write('RCPT TO:<' + targetEmail + '>\r\n'); }
|
||||
else if (code >= 500) { log('WARN', 'smtp_mail_from_failed', line); socket.end(); resolve(false); return; }
|
||||
} else if (stage === 4) {
|
||||
if (code === 250) { stage = 5; socket.write('DATA\r\n'); }
|
||||
else if (code >= 500) { log('WARN', 'smtp_rcpt_failed', line); socket.end(); resolve(false); return; }
|
||||
} else if (stage === 5) {
|
||||
if (code === 354) { socket.write(rawEmail); stage = 6; }
|
||||
} else if (stage === 6) {
|
||||
if (code === 250) {
|
||||
socket.write('QUIT\r\n');
|
||||
socket.end();
|
||||
log('INFO', 'email_sent', 'to=' + targetEmail + ' op=' + opType);
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (code >= 500 && stage > 0 && stage !== 2) {
|
||||
log('WARN', 'smtp_error', 'stage=' + stage + ' line=' + line.slice(0, 100));
|
||||
socket.end();
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('data', (data) => {
|
||||
buffer += data.toString();
|
||||
processResponse();
|
||||
});
|
||||
socket.on('error', (err) => {
|
||||
log('WARN', 'smtp_socket_error', err.message);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
log('WARN', 'smtp_connect_error', err.message);
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
socket.setTimeout(15000, () => {
|
||||
log('WARN', 'smtp_timeout', '');
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// 清理过期操作
|
||||
// ═══════════════════════════════════════
|
||||
function cleanExpiredOps() {
|
||||
const now = Date.now() / 1000;
|
||||
for (const [cid, op] of Object.entries(pendingOps)) {
|
||||
if (op.expires < now) delete pendingOps[cid];
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// HTTP 服务器
|
||||
// ═══════════════════════════════════════
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.method === 'OPTIONS') { jr(res, 204, {}); return; }
|
||||
|
||||
const ip = req.socket.remoteAddress || 'unknown';
|
||||
if (!checkRate(ip)) { log('WARN', 'rate_limit', ip); jr(res, 429, { error: '请求过于频繁 · 每分钟最多 ' + MAX_REQUESTS + ' 次' }); return; }
|
||||
|
||||
const url = new URL(req.url, 'http://' + ((req.headers.host) || 'localhost'));
|
||||
const route = url.pathname;
|
||||
|
||||
// ═══ 无需认证的端点 ═══
|
||||
if (req.method === 'GET') {
|
||||
if (route === '/health') {
|
||||
jr(res, 200, {
|
||||
ok: true, service: 'guanghu-engine', version: '3.2.0',
|
||||
uptime: process.uptime().toFixed(0) + 's',
|
||||
timestamp: new Date().toISOString(),
|
||||
auth_mode: 'human-verification-required'
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (route === '/status') {
|
||||
const total = os.totalmem(), free = os.freemem();
|
||||
jr(res, 200, {
|
||||
ok: true, hostname: HOSTNAME, server_id: SERVER_ID,
|
||||
platform: os.platform(), arch: os.arch(), cpus: os.cpus().length,
|
||||
uptime: os.uptime(), uptime_str: fmtUptime(os.uptime()),
|
||||
memory: { total: fmtBytes(total), free: fmtBytes(free), used: fmtBytes(total - free), usage: ((total - free) / total * 100).toFixed(1) + '%' },
|
||||
load: os.loadavg().map(n => +n.toFixed(2)),
|
||||
engine: { version: '3.2.0', port: PORT, uptime: process.uptime().toFixed(0) + 's' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (route === '/ping') { jr(res, 200, { ok: true, pong: true }); return; }
|
||||
|
||||
// ═══ GET /auth/whitelist — 查看白名单(需认证) ═══
|
||||
if (route === '/auth/whitelist') {
|
||||
const authResult = auth(req.headers);
|
||||
if (!authResult.ok) { jr(res, 401, { error: authResult.reason }); return; }
|
||||
const whitelist = loadWhitelist();
|
||||
jr(res, 200, {
|
||||
ok: true,
|
||||
server_id: SERVER_ID,
|
||||
triads: whitelist.triads.map(t => ({
|
||||
server: t.server,
|
||||
human: t.human,
|
||||
personalities: t.personalities,
|
||||
bound_servers: t.bound_servers
|
||||
}))
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══ 需要认证的 POST 端点 ═══
|
||||
if (req.method !== 'POST') { jr(res, 405, { error: '只接受 POST 请求 · 可用端点: /auth/request, /auth/confirm, /auth/register, /auth/whitelist (GET或POST), /health (GET), /status (GET), /ping (GET)' }); return; }
|
||||
|
||||
const authResult = auth(req.headers);
|
||||
if (!authResult.ok) {
|
||||
log('WARN', 'auth_failed', authResult.reason);
|
||||
jr(res, 401, { error: authResult.reason });
|
||||
return;
|
||||
}
|
||||
|
||||
const identity = authResult.identity;
|
||||
log('INFO', 'auth_ok', identity.pid + ' (' + identity.name + ')');
|
||||
|
||||
const body = await parseBody(req);
|
||||
if (!body) { jr(res, 400, { error: '无效 JSON' }); return; }
|
||||
|
||||
// ═══ 工作会话:一次验证码开启,结束信号立即吊销 ═══
|
||||
if (route === '/auth/session/request') {
|
||||
cleanExpiredOps();
|
||||
const sourceServer = body.source_server || identity.server || SERVER_ID;
|
||||
const scopes = Array.isArray(body.scopes) ? [...new Set(body.scopes)] : ['code-repo'];
|
||||
const action = getAction(body.action);
|
||||
if (!action) { jr(res, 400, { error: '必须指定已登记 action', available: listActions() }); return; }
|
||||
if (!scopes.includes(action.scope)) { jr(res, 400, { error: 'action 与 scopes 不匹配', required_scope: action.scope }); return; }
|
||||
if (!scopes.length || scopes.some(s => !OP_TYPES[s])) { jr(res, 400, { error: '无效 scopes', available: Object.keys(OP_TYPES) }); return; }
|
||||
const wlResult = checkWhitelist(sourceServer, identity.pid);
|
||||
if (!wlResult.ok) { jr(res, 403, { error: wlResult.reason }); return; }
|
||||
const triad = wlResult.triad;
|
||||
const code = generateCode(), challengeId = crypto.randomBytes(16).toString('hex');
|
||||
pendingOps[challengeId] = { code, expires: Date.now()/1000 + CODE_TTL, is_session: true, action: body.action, scopes, caller: identity, server: sourceServer, target_email: triad.email, target_name: triad.human };
|
||||
const sent = await sendVerificationEmail(code, scopes[0], identity.name, identity.pid, '开启工作会话 · 范围: ' + scopes.join(', '), triad.email, triad.human, SERVER_ID);
|
||||
jr(res, 200, { ok:true, challenge_id:challengeId, email_sent:sent, email_mode:'whitelist', scopes, expires_in:CODE_TTL, message:'验证码已发送到固定登记邮箱' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (route === '/auth/session/confirm') {
|
||||
cleanExpiredOps();
|
||||
const op = pendingOps[body.challenge_id || ''];
|
||||
if (!op || !op.is_session) { jr(res, 404, { error:'会话挑战不存在或已过期' }); return; }
|
||||
if (op.caller.pid !== identity.pid) { jr(res, 403, { error:'会话身份不匹配' }); return; }
|
||||
if (op.code !== String(body.code || '')) { jr(res, 403, { error:'验证码错误' }); return; }
|
||||
delete pendingOps[body.challenge_id];
|
||||
const created = sessions.create(identity, op.server, op.scopes, [op.action]);
|
||||
log('INFO','session_started','pid='+identity.pid+' scopes='+op.scopes.join(','));
|
||||
jr(res,200,{ok:true,session_token:created.token,scopes:op.scopes,expires_in:created.expires_in,idle_timeout:created.idle_timeout,message:'工作会话已开启'});
|
||||
return;
|
||||
}
|
||||
|
||||
if (route === '/auth/session/exec') {
|
||||
const sessionToken = req.headers['x-session-token'] || body.session_token || '';
|
||||
const opType = body.op_type || 'code-repo';
|
||||
const sourceServer = body.source_server || identity.server || SERVER_ID;
|
||||
const action = getAction(body.action);
|
||||
const verified = sessions.verify(sessionToken, identity, sourceServer, opType, body.action);
|
||||
if (!verified.ok) { jr(res,403,{error:verified.reason}); return; }
|
||||
if (!action) { jr(res,400,{error:'必须指定已登记 action',available:listActions()}); return; }
|
||||
if (action.scope !== opType) { jr(res,400,{error:'action 与 op_type 不匹配',required_scope:action.scope}); return; }
|
||||
const result = await runAuthorizedAction(body.action, body.timeout);
|
||||
log('INFO','session_exec','pid='+identity.pid+' op='+opType+' code='+result.code);
|
||||
jr(res,200,{ok:true,executed:true,session:true,exit_code:result.code,stdout:result.stdout,stderr:result.stderr});
|
||||
return;
|
||||
}
|
||||
|
||||
if (route === '/auth/session/end') {
|
||||
const sessionToken = req.headers['x-session-token'] || body.session_token || '';
|
||||
const ended = sessions.end(sessionToken, identity);
|
||||
log('INFO','session_ended','pid='+identity.pid+' ended='+ended);
|
||||
jr(res,ended?200:404,{ok:ended,message:ended?'工作会话已结束':'会话不存在'});
|
||||
return;
|
||||
}
|
||||
|
||||
// ═══ /auth/request — 发起操作请求 ═══
|
||||
if (route === '/auth/request') {
|
||||
cleanExpiredOps();
|
||||
|
||||
const opType = body.op_type || 'code-repo';
|
||||
const description = body.description || '未提供描述';
|
||||
const sourceServer = body.source_server || identity.server || SERVER_ID;
|
||||
if (body.email || body.target_name) { jr(res, 400, { error: '禁止客户端指定邮箱 · 请先注册服务器白名单' }); return; }
|
||||
|
||||
if (!OP_TYPES[opType]) {
|
||||
jr(res, 400, { error: '未知操作类型: ' + opType, available: Object.keys(OP_TYPES) });
|
||||
return;
|
||||
}
|
||||
|
||||
const action = getAction(body.action);
|
||||
if (!action) { jr(res, 400, { error: '必须指定已登记 action', available: listActions() }); return; }
|
||||
if (action.scope !== opType) { jr(res, 400, { error: 'action 与 op_type 不匹配', required_scope: action.scope }); return; }
|
||||
|
||||
let targetEmail, targetName;
|
||||
|
||||
{
|
||||
const wlResult = checkWhitelist(sourceServer, identity.pid);
|
||||
if (!wlResult.ok) {
|
||||
log('WARN', 'whitelist_denied', wlResult.reason);
|
||||
// 不在白名单也不带邮箱 → 不响应(防恶意刷邮箱)
|
||||
jr(res, 403, { error: wlResult.reason });
|
||||
return;
|
||||
}
|
||||
const triad = wlResult.triad;
|
||||
targetEmail = triad.email;
|
||||
targetName = triad.human;
|
||||
}
|
||||
|
||||
// 生成验证码
|
||||
const code = generateCode();
|
||||
const challengeId = crypto.randomBytes(16).toString('hex');
|
||||
const now = Date.now() / 1000;
|
||||
|
||||
pendingOps[challengeId] = {
|
||||
code: code,
|
||||
expires: now + CODE_TTL,
|
||||
op_type: opType,
|
||||
action: body.action,
|
||||
caller: { pid: identity.pid, name: identity.name },
|
||||
target_email: targetEmail,
|
||||
target_name: targetName,
|
||||
server: sourceServer
|
||||
};
|
||||
|
||||
// 发送验证码邮件
|
||||
const cmdPreview = 'action=' + body.action;
|
||||
const sent = await sendVerificationEmail(code, opType, identity.name, identity.pid, cmdPreview, targetEmail, targetName, SERVER_ID);
|
||||
|
||||
log('INFO', 'auth_request', 'challenge=' + challengeId.slice(0, 16) + ' op=' + opType + ' email_sent=' + sent);
|
||||
|
||||
jr(res, 200, {
|
||||
ok: true,
|
||||
challenge_id: challengeId,
|
||||
email_sent: sent,
|
||||
email_mode: 'whitelist',
|
||||
caller: { pid: identity.pid, name: identity.name },
|
||||
op_type: opType,
|
||||
op_label: OP_TYPES[opType].label,
|
||||
cmd_preview: cmdPreview,
|
||||
expires_in: CODE_TTL,
|
||||
message: '验证码已发送到 ' + targetEmail + ',请查收后通过 /auth/confirm 确认操作'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ═══ /auth/confirm — 确认验证码并执行 ═══
|
||||
if (route === '/auth/confirm') {
|
||||
cleanExpiredOps();
|
||||
|
||||
const challengeId = body.challenge_id || '';
|
||||
const code = body.code || '';
|
||||
|
||||
if (!challengeId || !code) {
|
||||
jr(res, 400, { error: '缺少 challenge_id 或 code' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pendingOps[challengeId]) {
|
||||
jr(res, 404, { error: 'challenge_id 不存在或已过期' });
|
||||
return;
|
||||
}
|
||||
|
||||
const op = pendingOps[challengeId];
|
||||
const now = Date.now() / 1000;
|
||||
|
||||
if (now > op.expires) {
|
||||
delete pendingOps[challengeId];
|
||||
jr(res, 410, { error: '验证码已过期 · 请重新发起 /auth/request' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (op.code !== code) {
|
||||
log('WARN', 'auth_confirm_wrong_code', challengeId.slice(0, 16));
|
||||
jr(res, 403, { error: '验证码错误' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证通过 → 执行命令
|
||||
delete pendingOps[challengeId];
|
||||
log('INFO', 'auth_confirm_ok', challengeId.slice(0, 16) + ' op=' + op.op_type + ' caller=' + op.caller.pid);
|
||||
|
||||
const result = await runAuthorizedAction(op.action, body.timeout);
|
||||
|
||||
jr(res, 200, {
|
||||
ok: true,
|
||||
executed: true,
|
||||
op_type: op.op_type,
|
||||
action: op.action,
|
||||
caller: op.caller,
|
||||
exit_code: result.code,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
message: '命令已执行 · 操作完成'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ═══ /auth/whitelist — 查看白名单 ═══
|
||||
if (route === '/auth/whitelist') {
|
||||
const whitelist = loadWhitelist();
|
||||
jr(res, 200, {
|
||||
ok: true,
|
||||
server_id: SERVER_ID,
|
||||
triads: whitelist.triads.map(t => ({
|
||||
server: t.server,
|
||||
human: t.human,
|
||||
personalities: t.personalities,
|
||||
bound_servers: t.bound_servers
|
||||
}))
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ═══ /auth/register — 注册新三元组(需主权者验证码) ═══
|
||||
if (route === '/auth/register') {
|
||||
const newServer = body.server || '';
|
||||
const newHuman = body.human || '';
|
||||
const newEmail = body.email || '';
|
||||
const newPersonalities = body.personalities || [];
|
||||
|
||||
if (!newServer || !newHuman || !newEmail) {
|
||||
jr(res, 400, { error: '缺少必要字段: server, human, email' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 注册也需要验证码(发给主权者 ICE-GL∞)
|
||||
const whitelist = loadWhitelist();
|
||||
const sovereign = whitelist.triads.find(t => t.human === 'ICE-GL∞');
|
||||
if (!sovereign) {
|
||||
jr(res, 500, { error: '主权者信息缺失' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成验证码发到主权者邮箱
|
||||
const regCode = generateCode();
|
||||
const regCid = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
pendingOps[regCid] = {
|
||||
code: regCode,
|
||||
expires: Date.now() / 1000 + CODE_TTL,
|
||||
op_type: 'system-arch',
|
||||
cmd: 'REGISTER:' + JSON.stringify({ server: newServer, human: newHuman, email: newEmail, personalities: newPersonalities }),
|
||||
caller: identity,
|
||||
target_email: sovereign.email,
|
||||
target_name: sovereign.human,
|
||||
server: SERVER_ID,
|
||||
is_register: true,
|
||||
register_data: { server: newServer, human: newHuman, email: newEmail, personalities: newPersonalities }
|
||||
};
|
||||
|
||||
const sent = await sendVerificationEmail(regCode, 'system-arch', identity.name, identity.pid,
|
||||
'注册新三元组: ' + newServer + ' + ' + newHuman + ' + ' + newPersonalities.join(','),
|
||||
sovereign.email, sovereign.human, SERVER_ID);
|
||||
|
||||
jr(res, 200, {
|
||||
ok: true,
|
||||
challenge_id: regCid,
|
||||
email_sent: sent,
|
||||
message: '注册请求验证码已发送到主权者邮箱 ' + sovereign.email + ',确认后生效'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ═══ /exec — 旧接口兼容(自动转发到验证码流程) ═══
|
||||
if (route === '/exec') {
|
||||
if (!body.action) { jr(res, 400, { error: '缺少 action 参数' }); return; }
|
||||
|
||||
// v3 安全策略: /exec 不再直接执行,返回提示要求走验证码流程
|
||||
log('WARN', 'exec_blocked_v3', 'caller=' + identity.pid + ' 尝试绕过验证码调用 /exec');
|
||||
|
||||
jr(res, 403, {
|
||||
error: 'Gatekeeper v3 安全策略: /exec 已停用 · 请使用分层授权流程',
|
||||
required_flow: 'POST /auth/request → 邮箱验证码 → POST /auth/confirm',
|
||||
help: '将原有的 /exec 请求改为: ① POST /auth/request (action + op_type) → ② 查收邮箱验证码 → ③ POST /auth/confirm (challenge_id + code)',
|
||||
example: {
|
||||
step1: 'curl -X POST http://' + SERVER_ID + ':' + PORT + '/auth/request -H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d \'{"action":"sync-status","op_type":"code-repo"}\'',
|
||||
step2: 'curl -X POST http://' + SERVER_ID + ':' + PORT + '/auth/confirm -H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d \'{"challenge_id":"<从step1返回>","code":"<邮箱验证码>"}\''
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ═══ /hlpd — HLDP 模块执行(同样需要验证码) ═══
|
||||
if (route === '/hlpd') {
|
||||
if (!body.hlpd) { jr(res, 400, { error: '缺少 hlpd 参数' }); return; }
|
||||
log('WARN', 'hlpd_blocked_v3', 'HLDP 执行需要验证码授权');
|
||||
jr(res, 403, {
|
||||
error: 'Gatekeeper v3: HLDP 执行需要验证码授权',
|
||||
required_flow: 'POST /auth/request → 邮箱验证码 → POST /auth/confirm'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ═══ /file/read /file/write /file/list — 文件操作(只读无需验证,写入需要) ═══
|
||||
if (route === '/file/read') {
|
||||
if (!body.path) { jr(res, 400, { error: '缺少 path 参数' }); return; }
|
||||
log('INFO', 'file_read', body.path);
|
||||
try {
|
||||
const content = fs.readFileSync(body.path, 'utf-8');
|
||||
const stat = fs.statSync(body.path);
|
||||
jr(res, 200, { ok: true, path: body.path, size: stat.size, modified: stat.mtime.toISOString(), content: content.slice(0, MAX_OUTPUT) });
|
||||
} catch (e) { jr(res, 404, { ok: false, error: e.message }); }
|
||||
return;
|
||||
}
|
||||
|
||||
if (route === '/file/list') {
|
||||
const p = (body && body.path) || '.';
|
||||
log('INFO', 'file_list', p);
|
||||
try {
|
||||
const items = fs.readdirSync(p, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const x of items) { files.push({ name: x.name, type: x.isDirectory() ? 'dir' : 'file' }); }
|
||||
jr(res, 200, { ok: true, path: p, files: files });
|
||||
} catch (e) { jr(res, 404, { ok: false, error: e.message }); }
|
||||
return;
|
||||
}
|
||||
|
||||
if (route === '/file/write') {
|
||||
log('WARN', 'file_write_blocked_v3', '文件写入需要验证码授权');
|
||||
jr(res, 403, {
|
||||
error: 'Gatekeeper v3: 文件写入需要验证码授权',
|
||||
required_flow: 'POST /auth/request { action: "已登记动作", op_type: "system-arch" } → 验证码 → /auth/confirm'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 未知路由
|
||||
jr(res, 404, { error: '未知路径: ' + route, available: ['/auth/request', '/auth/confirm', '/auth/whitelist', '/auth/register', '/health', '/status', '/ping', '/file/read', '/file/list'] });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// 启动
|
||||
// ═══════════════════════════════════════
|
||||
server.on('error', (e) => {
|
||||
if (e.code === 'EADDRINUSE') {
|
||||
log('FATAL', 'eaddrinuse', '端口 ' + PORT + ' 已被占用');
|
||||
console.error('❌ 端口 ' + PORT + ' 已被占用,无法启动。');
|
||||
setTimeout(() => process.exit(1), 3000);
|
||||
} else {
|
||||
log('FATAL', 'server_error', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
log('INFO', 'startup', 'host=' + HOSTNAME + ' bind=' + HOST + ' port=' + PORT + ' v3.2.0 server_id=' + SERVER_ID);
|
||||
console.log(' ⚔️ 光湖驱动引擎 v3.2 · 工作会话授权 · 已就绪');
|
||||
console.log(' 主机名: ' + HOSTNAME + ' 标识: ' + SERVER_ID + ' 端口: ' + PORT);
|
||||
console.log(' 安全策略: Token=身份标识 · 操作=验证码授权 · 白名单=三元组校验');
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => { log('INFO', 'shutdown', 'SIGTERM'); server.close(() => process.exit(0)); });
|
||||
process.on('SIGINT', () => { log('INFO', 'shutdown', 'SIGINT'); server.close(() => process.exit(0)); });
|
||||
process.on('uncaughtException', (e) => { log('ERROR', 'uncaught', e.message); });
|
||||
process.on('unhandledRejection', (e) => { log('ERROR', 'unhandled', e.message); });
|
||||
64
zero-point/core-channel/gatekeeper/session-manager.js
Normal file
64
zero-point/core-channel/gatekeeper/session-manager.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use strict";
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
class SessionManager {
|
||||
constructor({ absoluteTtl = 12 * 3600, idleTtl = 3600, stateFile = "" } = {}) {
|
||||
this.absoluteTtl = absoluteTtl;
|
||||
this.idleTtl = idleTtl;
|
||||
this.stateFile = stateFile;
|
||||
this.sessions = new Map();
|
||||
this.load();
|
||||
}
|
||||
create(identity, server, scopes, actions = [], now = Date.now() / 1000) {
|
||||
if (typeof actions === "number") { now = actions; actions = []; }
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const hash = this.hash(token);
|
||||
this.sessions.set(hash, { pid: identity.pid, name: identity.name, server, scopes: [...new Set(scopes)], actions: [...new Set(actions)], created: now, last_used: now, expires: now + this.absoluteTtl });
|
||||
this.persist();
|
||||
return { token, expires_in: this.absoluteTtl, idle_timeout: this.idleTtl };
|
||||
}
|
||||
verify(token, identity, server, scope, action = "", now = Date.now() / 1000) {
|
||||
if (typeof action === "number") { now = action; action = ""; }
|
||||
const hash = this.hash(token || "");
|
||||
const session = this.sessions.get(hash);
|
||||
if (!session) return { ok: false, reason: "session_not_found" };
|
||||
if (now > session.expires || now - session.last_used > this.idleTtl) { this.sessions.delete(hash); this.persist(); return { ok: false, reason: "session_expired" }; }
|
||||
if (session.pid !== identity.pid || session.server !== server) return { ok: false, reason: "session_binding_mismatch" };
|
||||
if (!session.scopes.includes(scope)) return { ok: false, reason: "session_scope_denied" };
|
||||
if (!Array.isArray(session.actions) || !session.actions.includes(action)) return { ok: false, reason: "session_action_denied" };
|
||||
session.last_used = now;
|
||||
this.persist();
|
||||
return { ok: true, session };
|
||||
}
|
||||
end(token, identity) {
|
||||
const hash = this.hash(token || "");
|
||||
const session = this.sessions.get(hash);
|
||||
if (!session || session.pid !== identity.pid) return false;
|
||||
this.sessions.delete(hash);
|
||||
this.persist();
|
||||
return true;
|
||||
}
|
||||
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 [hash, session] of Object.entries(state.sessions || {})) {
|
||||
if (typeof hash !== "string" || !session || now > session.expires || now - session.last_used > this.idleTtl) continue;
|
||||
this.sessions.set(hash, session);
|
||||
}
|
||||
} catch {
|
||||
this.sessions.clear();
|
||||
}
|
||||
}
|
||||
persist() {
|
||||
if (!this.stateFile) return;
|
||||
fs.mkdirSync(path.dirname(this.stateFile), { recursive: true, mode: 0o700 });
|
||||
const temp = `${this.stateFile}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temp, JSON.stringify({ version: 1, sessions: Object.fromEntries(this.sessions) }), { mode: 0o600 });
|
||||
fs.renameSync(temp, this.stateFile);
|
||||
}
|
||||
hash(token) { return crypto.createHash("sha256").update(token).digest("hex"); }
|
||||
}
|
||||
module.exports = { SessionManager };
|
||||
45
zero-point/core-channel/gatekeeper/session-manager.test.js
Normal file
45
zero-point/core-channel/gatekeeper/session-manager.test.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"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 { SessionManager } = require("./session-manager");
|
||||
const zhulan = { pid: "ICE-GL-ZL-001", name: "铸澜" };
|
||||
|
||||
test("one approval creates a reusable bounded session", () => {
|
||||
const m = new SessionManager({ absoluteTtl: 100, idleTtl: 20 });
|
||||
const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], ["sync-status"], 10);
|
||||
assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", "sync-status", 11).ok, true);
|
||||
assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", "sync-status", 12).ok, true);
|
||||
});
|
||||
test("session is bound to identity server and scope", () => {
|
||||
const m = new SessionManager();
|
||||
const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], ["sync-status"], 10);
|
||||
assert.equal(m.verify(token, { pid: "ICE-GL-ZY001" }, "BS-SG-001", "code-repo", "sync-status", 11).ok, false);
|
||||
assert.equal(m.verify(token, zhulan, "OTHER", "code-repo", "sync-status", 11).ok, false);
|
||||
assert.equal(m.verify(token, zhulan, "BS-SG-001", "system-arch", "sync-status", 11).ok, false);
|
||||
});
|
||||
test("end signal revokes immediately", () => {
|
||||
const m = new SessionManager();
|
||||
const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], ["sync-status"], 10);
|
||||
assert.equal(m.end(token, zhulan), true);
|
||||
assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", "sync-status", 11).ok, false);
|
||||
});
|
||||
|
||||
test("session survives process reload without persisting plaintext token", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gatekeeper-session-"));
|
||||
const stateFile = path.join(dir, "sessions.json");
|
||||
try {
|
||||
const now = Date.now() / 1000;
|
||||
const first = new SessionManager({ absoluteTtl: 100, idleTtl: 20, stateFile });
|
||||
const created = first.create(zhulan, "BS-SG-001", ["code-repo"], ["sync-status"], now);
|
||||
const onDisk = fs.readFileSync(stateFile, "utf8");
|
||||
assert.equal(onDisk.includes(created.token), false);
|
||||
|
||||
const reloaded = new SessionManager({ absoluteTtl: 100, idleTtl: 20, stateFile });
|
||||
assert.equal(reloaded.verify(created.token, zhulan, "BS-SG-001", "code-repo", "sync-status", now + 1).ok, true);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue