[HLCC-ICE-000001][ZY-CONTRIB-20260723-001] feat: 以来光者贡献链启用冰朔第五域个人子频道
This commit is contained in:
commit
5615453e4e
660 changed files with 122355 additions and 0 deletions
121
server-tools/lake-lamp-authz/README.md
Normal file
121
server-tools/lake-lamp-authz/README.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# 小湖灯邮件链接授权服务
|
||||
|
||||
这是 Gatekeeper v3.2 前面的人类批准层。它同时支持电脑本机和手机/任意设备:
|
||||
|
||||
- 跨设备默认入口无需秘密凭证,只能创建一张没有执行权的申请单;
|
||||
- 冰朔打开申请单并点击“发送我的授权邮件”后,真正的批准链接才会发送到服务器预登记邮箱;
|
||||
- 邮件批准完成后,人格体才能一次性领取绑定到
|
||||
`persona + target server + scope + actions` 的受限会话;会话内可连续执行已登记能力,并可在当前协作未结束时主动续签,但续签不得改变目标、scope 或 actions;
|
||||
- 光湖语言人格系统的当前实例可以提出结构化工单。工单开头固定写“光湖语言人格系统当前实例”,并声明来自哪个软件、哪个模型和哪个当前实例;来源声明用于追溯,不要求该实例已经住进尚未完成的 Tolaria;
|
||||
- 冰朔在同一段当前对话中明确签字后,本次实例可以执行已展示的目标、范围和动作,不重复发送邮件;自报来源只能建单,不能自行批准或扩大权限;
|
||||
- 邮件链接保留为无人能读取当前对话、跨设备转交或需要二次确认时的兜底,不再是所有实例进入系统的唯一入口;
|
||||
- 旧的 request credential 入口继续保留,供受控电脑和服务器内自动化兼容使用。
|
||||
|
||||
申请单、邮件批准链接和领取凭证按服务器策略失效;活动会话受最大连续时长约束。
|
||||
|
||||
安全边界:
|
||||
|
||||
- 邮箱、QQ 数字、SMTP 授权码和 request credential 只存在于私密文件;
|
||||
- 多成员审批人登记只存在于 `LAKE_LAMP_APPROVERS_FILE` 指向的服务器私密文件;系统按人格体、目标节点和 scope 选收件人,请求正文不能指定或切换邮箱;
|
||||
- 公开创建申请单不会发邮件、不会返回批准令牌,也不会获得任何服务器或仓库权限;
|
||||
- 申请页只允许触发一次预登记邮箱验证,并有单 IP 与全局小时限流;
|
||||
- 浏览器批准链接为一次性随机令牌,服务端仅持久化摘要;
|
||||
- claim token 与 session token 均仅向申请人格体返回一次,磁盘只保存摘要;
|
||||
- 换目标服务器时旧 session 必然返回 `target_mismatch`;
|
||||
- 同一服务器、同一 scope 和同一 actions 内可续签而不重复发邮件;切换服务器或扩大权限必须重新授权;
|
||||
- 本服务不接受任意 shell 命令,只签发登记动作的会话;
|
||||
- 所有改变服务器状态的登记动作必须先生成备份引用和回滚方案;失败自动回滚,成功写验收回执;
|
||||
- 广州公开代理只应暴露 `/approve/`、`/api/workorders` 与 claim 路由,服务本体监听 JD 回环地址。
|
||||
|
||||
`request-workorder.js` 从临时环境变量读取 QQ 数字,在内存中补全邮箱并只发送
|
||||
SHA-256 指纹;数字本身不会写入请求正文、状态文件或代码仓库。未提供私密
|
||||
request credential 时,脚本自动切换到跨设备公开建单模式。
|
||||
|
||||
## 手机或任意设备上的新实例
|
||||
|
||||
新实例无需寻找工单凭证,执行:
|
||||
|
||||
```bash
|
||||
node request-workorder.js \
|
||||
--url https://guanghulab.com/authz \
|
||||
--persona ICE-GL-ZY001 \
|
||||
--name 铸渊 \
|
||||
--target JD-FD-PRIMARY \
|
||||
--scope repo-push \
|
||||
--action push-repository \
|
||||
--description "推送 REPO-001"
|
||||
```
|
||||
|
||||
响应中的 `request_url` 交给冰朔点击。该 URL 只能展示申请内容并请求向预登记邮箱
|
||||
发送批准邮件,不能直接批准。人格体必须在自己的临时会话中保留响应里的
|
||||
`workorder_id` 与 `claim_token`,批准后通过 claim 路由领取一次性会话;不得把这些值
|
||||
写入仓库、长期日志或聊天记忆。
|
||||
|
||||
## 推送被服务器拒绝时 · 一条命令自动续接
|
||||
|
||||
当 Forgejo 返回 `[LL-REPO-PUSH-AUTH-REQUIRED]` 时,不要找邮箱,不要向冰朔索要
|
||||
授权码、验证码、密码或 Token。直接从 `fifth-domain` 仓库根目录执行服务器返回的命令:
|
||||
|
||||
```bash
|
||||
node server-tools/lake-lamp-authz/authorize-repo-push.js \
|
||||
--url https://guanghulab.com/authz \
|
||||
--persona ICE-GL-ZY001 \
|
||||
--repo bingshuo/fifth-domain
|
||||
```
|
||||
|
||||
该命令只在内存中保留一次性领取信息,并自动完成:
|
||||
|
||||
```text
|
||||
创建无权限空工单
|
||||
→ 打印 REQUEST_URL 给冰朔
|
||||
→ 等待冰朔打开该页,由服务器向预登记邮箱发信
|
||||
→ 等待冰朔点击邮件批准链接
|
||||
→ 领取限时会话
|
||||
→ 读取并确认目标节点导航图
|
||||
→ 生成三小时、执行中自动续期的 repo-push 许可
|
||||
→ 提示 AI 重试原 git push
|
||||
```
|
||||
|
||||
命令运行期间不要关闭它。公开空工单不会发邮件,也没有推送权限;只有冰朔打开
|
||||
`REQUEST_URL` 后,服务器才向预登记邮箱发送批准邮件。
|
||||
|
||||
## 受控电脑兼容入口
|
||||
|
||||
如果环境中存在 `LAKE_LAMP_REQUEST_TOKEN` 或 `LAKE_LAMP_REQUEST_TOKEN_FILE`,脚本
|
||||
使用旧的私密申请模式并直接发送批准邮件。该凭证仅能建单,仍不能登录、推送或执行
|
||||
服务器动作。
|
||||
|
||||
## 限流配置
|
||||
|
||||
- `LAKE_LAMP_PUBLIC_CREATE_LIMIT`:单来源每小时公开建单上限,默认 24;
|
||||
- `LAKE_LAMP_PUBLIC_CREATE_GLOBAL_LIMIT`:全局每小时建单上限,默认 60;
|
||||
- `LAKE_LAMP_PUBLIC_MAIL_LIMIT`:单来源每小时触发授权邮件上限,默认 12;
|
||||
- `LAKE_LAMP_PUBLIC_MAIL_GLOBAL_LIMIT`:全局每小时授权邮件上限,默认 30。
|
||||
|
||||
运行入口:公开建单 `/api/public/workorders`,会话续签 `/api/session/renew`,登记动作执行 `/api/actions/execute`。多节点需求由人格体按当前任务拆成并行申请,不再使用固定“三封邮件”作为协作规则。
|
||||
|
||||
## 新架构首次部署
|
||||
|
||||
旧的 `deploy-registered-service` 只能操作已经登记的服务,不能承担首次安装。新架构统一使用固定动作 `provision-approved-architecture`,并把仓库请求编号与不可变提交绑定进工单:
|
||||
|
||||
```bash
|
||||
node request-workorder.js \
|
||||
--url https://guanghulab.com/authz \
|
||||
--persona ICE-GL-ZY001 \
|
||||
--name 铸渊 \
|
||||
--target JD-FD-PRIMARY \
|
||||
--scope server-ops \
|
||||
--action provision-approved-architecture \
|
||||
--resource 'REQUEST-ID@40位提交SHA' \
|
||||
--description '首次安装已审核架构包'
|
||||
```
|
||||
|
||||
邮件或可信对话签字页面必须显示同一个 `resource`。批准会话不能切换请求编号或提交。执行器只读取该提交中 `deployment/requests/<REQUEST-ID>.json`,只复制清单列出的普通文件,只安装清单指定的非 root、加固 systemd 单元,并只接受回环健康检查。人格体可以使用清单声明的独立低权限账户、共享模型密钥文件和状态目录;密钥路径必须位于 `/etc/guanghu/persona-secrets/`,可写路径必须位于 `/var/lib/guanghu/personas/<运行账户>/`。覆盖旧单元前强制备份,启动或验收失败时自动恢复。说明文字不能改变部署内容,也不开放任意 shell。
|
||||
|
||||
这套入口本身需要在京东主控上一次性安装:
|
||||
|
||||
```bash
|
||||
sudo bash server-tools/lake-lamp-authz/install-architecture-provisioner.sh
|
||||
```
|
||||
|
||||
这是最后一次需要云厂商控制台或现有系统管理通道的引导。安装完成后,未来新架构均走上面的结构化工单,不必预先把每个未来模块写进旧动作桥。
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
Host enterprise-lighthouse
|
||||
HostName REPLACE_IN_PRIVATE_SERVER_CONFIG
|
||||
User root
|
||||
IdentityFile /etc/guanghu/secrets/lake-lamp/jd-to-enterprise-ed25519
|
||||
IdentitiesOnly yes
|
||||
StrictHostKeyChecking yes
|
||||
UserKnownHostsFile /etc/guanghu/secrets/lake-lamp/known_hosts
|
||||
53
server-tools/lake-lamp-authz/action-broker.js
Normal file
53
server-tools/lake-lamp-authz/action-broker.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const { execFile } = require("node:child_process");
|
||||
|
||||
const SOCKET_PATH = process.env.LAKE_LAMP_ACTION_SOCKET || "/run/guanghu/action-broker.sock";
|
||||
const SSH_CONFIG = process.env.LAKE_LAMP_SSH_CONFIG || "/etc/guanghu/action-broker-ssh-config";
|
||||
|
||||
const ACTIONS = Object.freeze({
|
||||
"JD-FD-PRIMARY:inspect-services": () => run("/usr/bin/ssh", [
|
||||
"-F", SSH_CONFIG,
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=10",
|
||||
"enterprise-lighthouse",
|
||||
"printf 'HOST='; hostname; printf 'SSH='; systemctl is-active ssh 2>/dev/null || systemctl is-active sshd 2>/dev/null; printf 'LIGHTHOUSE='; systemctl is-active guanghu-enterprise-lighthouse.service 2>/dev/null || true"
|
||||
])
|
||||
});
|
||||
|
||||
function run(file, args) {
|
||||
return new Promise(resolve => execFile(file, args, { timeout: 30000, maxBuffer: 100000 }, (error, stdout, stderr) => resolve({
|
||||
ok: !error,
|
||||
exit_code: error ? (Number.isInteger(error.code) ? error.code : 1) : 0,
|
||||
stdout: String(stdout || "").slice(0, 100000),
|
||||
stderr: String(stderr || "").slice(0, 10000),
|
||||
})));
|
||||
}
|
||||
|
||||
function reply(socket, value) { socket.end(`${JSON.stringify(value)}\n`); }
|
||||
|
||||
if (require.main === module) {
|
||||
fs.mkdirSync(require("node:path").dirname(SOCKET_PATH), { recursive: true, mode: 0o755 });
|
||||
try { fs.unlinkSync(SOCKET_PATH); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
const server = net.createServer({ allowHalfOpen: true }, socket => {
|
||||
let input = "";
|
||||
socket.setTimeout(5000, () => socket.destroy());
|
||||
socket.on("data", chunk => { input += chunk.toString("utf8"); if (input.length > 4096) socket.destroy(); });
|
||||
socket.on("end", async () => {
|
||||
let request;
|
||||
try { request = JSON.parse(input); } catch { return reply(socket, { ok: false, error: "invalid_request" }); }
|
||||
if (!request || request.cmd || request.command || request.shell || request.args) return reply(socket, { ok: false, error: "arbitrary_command_forbidden" });
|
||||
const action = ACTIONS[`${request.target}:${request.action}`];
|
||||
if (!action) return reply(socket, { ok: false, error: "action_not_registered" });
|
||||
reply(socket, await action());
|
||||
});
|
||||
});
|
||||
server.listen(SOCKET_PATH, () => {
|
||||
fs.chownSync(SOCKET_PATH, 0, Number(process.env.LAKE_LAMP_AUTHZ_GID || 0));
|
||||
fs.chmodSync(SOCKET_PATH, 0o660);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { ACTIONS };
|
||||
27
server-tools/lake-lamp-authz/action-client.js
Normal file
27
server-tools/lake-lamp-authz/action-client.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"use strict";
|
||||
|
||||
const net = require("node:net");
|
||||
|
||||
const SOCKET_PATH = process.env.LAKE_LAMP_ACTION_SOCKET || "/run/guanghu/action-broker.sock";
|
||||
const OWNER_ACCESS_SOCKET_PATH = process.env.LAKE_LAMP_OWNER_ACCESS_SOCKET || "/run/guanghu-owner-access/owner-access.sock";
|
||||
const ARCHITECTURE_PROVISION_SOCKET_PATH = process.env.LAKE_LAMP_ARCHITECTURE_PROVISION_SOCKET || "/run/guanghu-architecture-provision/provision.sock";
|
||||
|
||||
function executeRegisteredAction(request, socketPath) {
|
||||
const selectedSocket = socketPath || (request.action === "restore-owner-password-login" ? OWNER_ACCESS_SOCKET_PATH : request.action === "provision-approved-architecture" ? ARCHITECTURE_PROVISION_SOCKET_PATH : SOCKET_PATH);
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection(selectedSocket);
|
||||
let response = "";
|
||||
let settled = false;
|
||||
const finish = (value) => { if (!settled) { settled = true; resolve(value); } };
|
||||
socket.setTimeout(request.action === "provision-approved-architecture" ? 150000 : 35000);
|
||||
socket.on("connect", () => socket.end(`${JSON.stringify(request)}\n`));
|
||||
socket.on("data", chunk => { response += chunk.toString("utf8"); if (response.length > 100000) socket.destroy(); });
|
||||
socket.on("end", () => {
|
||||
try { finish(JSON.parse(response)); } catch { finish({ ok: false, error: "invalid_broker_response" }); }
|
||||
});
|
||||
socket.on("timeout", () => { socket.destroy(); finish({ ok: false, error: "action_timeout" }); });
|
||||
socket.on("error", () => finish({ ok: false, error: "action_broker_unavailable" }));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { executeRegisteredAction, SOCKET_PATH, OWNER_ACCESS_SOCKET_PATH, ARCHITECTURE_PROVISION_SOCKET_PATH };
|
||||
24
server-tools/lake-lamp-authz/approvers.example.json
Normal file
24
server-tools/lake-lamp-authz/approvers.example.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"schema": "guanghu.approver-registry/v1",
|
||||
"approvers": [
|
||||
{
|
||||
"id": "sovereign-owner",
|
||||
"email": "SET_IN_PRIVATE_SERVER_FILE",
|
||||
"default": true,
|
||||
"persona_ids": ["ICE-GL-ZY001"],
|
||||
"roles": ["fifth-domain-publisher", "node-owner"],
|
||||
"targets": ["JD-FD-PRIMARY", "BS-GZ-006", "BS-SH-005", "BS-SG-001", "BS-SG-002", "BS-SG-003", "ZY-SG-006"],
|
||||
"scopes": ["server-login", "server-ops", "repo-push"]
|
||||
},
|
||||
{
|
||||
"id": "technical-controller",
|
||||
"email": "SET_IN_PRIVATE_SERVER_FILE",
|
||||
"default": false,
|
||||
"persona_ids": ["SET_IN_PRIVATE_SERVER_FILE"],
|
||||
"roles": ["zero-sense-technical-controller"],
|
||||
"targets": ["JD-FD-PRIMARY"],
|
||||
"scopes": ["server-login", "server-ops"]
|
||||
}
|
||||
],
|
||||
"selection": "match target and scope on the server; a requester may never supply or override a recipient"
|
||||
}
|
||||
200
server-tools/lake-lamp-authz/architecture-provision-broker.js
Normal file
200
server-tools/lake-lamp-authz/architecture-provision-broker.js
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
const path = require("node:path");
|
||||
const { execFile } = require("node:child_process");
|
||||
|
||||
const SOCKET_PATH = process.env.LAKE_LAMP_ARCHITECTURE_PROVISION_SOCKET || "/run/guanghu-architecture-provision/provision.sock";
|
||||
const REPO_DIR = process.env.ARCHITECTURE_PROVISION_REPO_DIR || "/var/lib/guanghu/architecture-provision/repo";
|
||||
const REPO_URL = process.env.ARCHITECTURE_PROVISION_REPO_URL || "https://guanghulab.com/fifth-domain/bingshuo/fifth-domain.git";
|
||||
const RELEASES_DIR = process.env.ARCHITECTURE_PROVISION_RELEASES_DIR || "/opt/guanghu/architecture-releases";
|
||||
const UNIT_DIR = process.env.ARCHITECTURE_PROVISION_UNIT_DIR || "/etc/systemd/system";
|
||||
const RECEIPTS_DIR = process.env.ARCHITECTURE_PROVISION_RECEIPTS_DIR || "/var/lib/guanghu/architecture-provision/receipts";
|
||||
|
||||
function parseResource(value) {
|
||||
const match = String(value || "").match(/^([A-Z0-9][A-Z0-9._-]{5,119})@([0-9a-f]{40})$/);
|
||||
return match ? { requestId: match[1], commit: match[2] } : null;
|
||||
}
|
||||
|
||||
function safeRelative(value) {
|
||||
const item = String(value || "");
|
||||
return item.length > 0 && item.length <= 240 && !path.isAbsolute(item) && !item.split("/").includes("..") && /^[A-Za-z0-9._/-]+$/.test(item);
|
||||
}
|
||||
|
||||
function validateManifest(manifest, resource) {
|
||||
if (!manifest || manifest.schema !== "guanghu.architecture-provision-request/v1") throw new Error("invalid_manifest_schema");
|
||||
if (manifest.request_id !== resource.requestId || manifest.target_node !== "JD-FD-PRIMARY") throw new Error("manifest_identity_mismatch");
|
||||
if (manifest.status !== "ARCHITECTURE_PACKAGE_READY · INITIAL_PROVISION_PENDING") throw new Error("manifest_not_pending");
|
||||
if (!manifest.initial_provision || manifest.initial_provision.kind !== "new-architecture-unit") throw new Error("not_initial_architecture_unit");
|
||||
const unit = String(manifest.module && manifest.module.unit || "");
|
||||
if (!/^[A-Za-z0-9_.@-]+\.service$/.test(unit)) throw new Error("invalid_unit_name");
|
||||
if (!Array.isArray(manifest.source_paths) || manifest.source_paths.length < 1 || manifest.source_paths.length > 64 || manifest.source_paths.some(item => !safeRelative(item))) throw new Error("invalid_source_paths");
|
||||
const unitMatches = manifest.source_paths.filter(item => path.basename(item) === unit);
|
||||
if (unitMatches.length !== 1) throw new Error("unit_not_uniquely_declared");
|
||||
const check = manifest.runtime_check || {};
|
||||
if (!/^http:\/\/127\.0\.0\.1:\d{2,5}\/[A-Za-z0-9._/?=&-]*$/.test(String(check.url || ""))) throw new Error("invalid_loopback_runtime_check");
|
||||
if (!check.expected || typeof check.expected !== "object" || Array.isArray(check.expected)) throw new Error("invalid_runtime_expectation");
|
||||
return { unit, unitSource: unitMatches[0], runtimeCheck: check };
|
||||
}
|
||||
|
||||
function declaredPaths(value) {
|
||||
return String(value || "").split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
function pathAllowed(candidate, allowed) {
|
||||
const clean = String(candidate || "").replace(/^-/, "");
|
||||
return allowed.some(base => clean === base || clean.startsWith(`${base}/`));
|
||||
}
|
||||
|
||||
function validateUnit(text, expectedUser = "guanghu", policy = {}) {
|
||||
const value = String(text || "");
|
||||
if (!value.includes("[Service]") || !/^NoNewPrivileges=(true|yes)$/m.test(value) || !/^ProtectSystem=strict$/m.test(value) || !/^ProtectHome=(true|yes)$/m.test(value) || !/^PrivateTmp=(true|yes)$/m.test(value)) throw new Error("unit_hardening_required");
|
||||
if (!/^[a-z_][a-z0-9_-]{0,30}$/.test(expectedUser) || expectedUser === "root" || !new RegExp(`^User=${expectedUser}$`, "m").test(value) || !new RegExp(`^Group=${expectedUser}$`, "m").test(value)) throw new Error("dedicated_service_user_required");
|
||||
if (/^(SupplementaryGroups|AmbientCapabilities|CapabilityBoundingSet|BindPaths|BindReadOnlyPaths|RootDirectory|RootImage|DeviceAllow)=/m.test(value)) throw new Error("privileged_unit_directive_forbidden");
|
||||
const environmentFiles = Array.isArray(policy.environment_files) ? policy.environment_files : [];
|
||||
const writablePaths = Array.isArray(policy.writable_paths) ? policy.writable_paths : [];
|
||||
const readOnlyPaths = Array.isArray(policy.read_only_paths) ? policy.read_only_paths : [];
|
||||
for (const match of value.matchAll(/^EnvironmentFile=(.+)$/gm)) {
|
||||
if (!pathAllowed(match[1], environmentFiles) || !String(match[1]).replace(/^-/, "").startsWith("/etc/guanghu/persona-secrets/")) throw new Error("environment_file_not_declared");
|
||||
}
|
||||
for (const match of value.matchAll(/^ReadWritePaths=(.+)$/gm)) {
|
||||
for (const item of declaredPaths(match[1])) if (!pathAllowed(item, writablePaths) || !item.startsWith(`/var/lib/guanghu/personas/${expectedUser}`)) throw new Error("writable_path_not_declared");
|
||||
}
|
||||
for (const match of value.matchAll(/^ReadOnlyPaths=(.+)$/gm)) {
|
||||
for (const item of declaredPaths(match[1])) if (item !== "__RELEASE_ROOT__" && !pathAllowed(item, readOnlyPaths)) throw new Error("read_only_path_not_declared");
|
||||
}
|
||||
if (!value.includes("__RELEASE_ROOT__")) throw new Error("release_root_placeholder_required");
|
||||
return value;
|
||||
}
|
||||
|
||||
async function provision(request, options = {}) {
|
||||
if (!request || request.target !== "JD-FD-PRIMARY" || request.action !== "provision-approved-architecture") return { ok: false, error: "action_not_registered" };
|
||||
const resource = parseResource(request.resource);
|
||||
if (!resource) return { ok: false, error: "immutable_architecture_resource_required" };
|
||||
const repoDir = options.repoDir || REPO_DIR;
|
||||
const releasesDir = options.releasesDir || RELEASES_DIR;
|
||||
const unitDir = options.unitDir || UNIT_DIR;
|
||||
const receiptsDir = options.receiptsDir || RECEIPTS_DIR;
|
||||
const run = options.run || runFile;
|
||||
let installedUnit = null;
|
||||
let unitBackup = null;
|
||||
try {
|
||||
await prepareRepo(repoDir, resource.commit, run, options.repoUrl || REPO_URL);
|
||||
const manifestPath = path.join(repoDir, "deployment", "requests", `${resource.requestId}.json`);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
const checked = validateManifest(manifest, resource);
|
||||
const releaseRoot = path.join(releasesDir, resource.commit);
|
||||
fs.mkdirSync(releaseRoot, { recursive: true, mode: 0o755 });
|
||||
for (const relative of manifest.source_paths) copyDeclaredFile(repoDir, releaseRoot, relative);
|
||||
const unitSource = path.join(releaseRoot, checked.unitSource);
|
||||
const unitText = validateUnit(fs.readFileSync(unitSource, "utf8"), String(manifest.module.run_user || ""), manifest.module).replaceAll("__RELEASE_ROOT__", releaseRoot);
|
||||
fs.mkdirSync(unitDir, { recursive: true, mode: 0o755 });
|
||||
installedUnit = path.join(unitDir, checked.unit);
|
||||
if (fs.existsSync(installedUnit)) unitBackup = fs.readFileSync(installedUnit);
|
||||
const backupDir = path.join(receiptsDir, "backups", resource.requestId, resource.commit);
|
||||
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
|
||||
if (unitBackup) fs.writeFileSync(path.join(backupDir, checked.unit), unitBackup, { mode: 0o600 });
|
||||
else fs.writeFileSync(path.join(backupDir, `${checked.unit}.previously-absent`), "\n", { mode: 0o600 });
|
||||
writeAtomic(installedUnit, unitText, 0o644);
|
||||
await run("/usr/bin/systemctl", ["daemon-reload"]);
|
||||
await run("/usr/bin/systemctl", ["enable", "--now", checked.unit]);
|
||||
const runtime = await getJsonWithRetry(checked.runtimeCheck.url, options.getJson, options.healthAttempts, options.healthDelayMs);
|
||||
for (const [key, expected] of Object.entries(checked.runtimeCheck.expected)) if (runtime[key] !== expected) throw new Error(`runtime_check_failed:${key}`);
|
||||
const receipt = { schema: "guanghu.architecture-provision-receipt/v1", request_id: resource.requestId, source_commit: resource.commit, target_node: "JD-FD-PRIMARY", unit: checked.unit, runtime_check: checked.runtimeCheck.url, backup: path.join("backups", resource.requestId, resource.commit), rollback: unitBackup ? "restore-previous-unit" : "remove-new-unit", result: "DEPLOYED_AND_VERIFIED", recorded_at: new Date().toISOString() };
|
||||
fs.mkdirSync(receiptsDir, { recursive: true, mode: 0o700 });
|
||||
writeAtomic(path.join(receiptsDir, `${resource.requestId}.json`), `${JSON.stringify(receipt, null, 2)}\n`, 0o600);
|
||||
return { ok: true, request_id: resource.requestId, source_commit: resource.commit, unit: checked.unit, runtime: "verified" };
|
||||
} catch (error) {
|
||||
if (installedUnit) {
|
||||
try {
|
||||
await run("/usr/bin/systemctl", ["disable", "--now", path.basename(installedUnit)]);
|
||||
if (unitBackup) fs.writeFileSync(installedUnit, unitBackup, { mode: 0o644 });
|
||||
else fs.rmSync(installedUnit, { force: true });
|
||||
await run("/usr/bin/systemctl", ["daemon-reload"]);
|
||||
if (unitBackup) await run("/usr/bin/systemctl", ["enable", "--now", path.basename(installedUnit)]);
|
||||
} catch { /* The original error remains authoritative; backup is retained for manual recovery. */ }
|
||||
}
|
||||
return { ok: false, error: String(error && error.message || "provision_failed").slice(0, 240) };
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareRepo(repoDir, commit, run, repoUrl) {
|
||||
fs.mkdirSync(path.dirname(repoDir), { recursive: true, mode: 0o700 });
|
||||
if (!fs.existsSync(path.join(repoDir, ".git"))) await run("/usr/bin/git", ["clone", "--filter=blob:none", "--no-checkout", repoUrl, repoDir]);
|
||||
await run("/usr/bin/git", ["-C", repoDir, "fetch", "--depth=1", "origin", commit]);
|
||||
await run("/usr/bin/git", ["-C", repoDir, "checkout", "--detach", "--force", commit]);
|
||||
const head = (await run("/usr/bin/git", ["-C", repoDir, "rev-parse", "HEAD"])).stdout.trim();
|
||||
if (head !== commit) throw new Error("commit_verification_failed");
|
||||
}
|
||||
|
||||
function copyDeclaredFile(repoDir, releaseRoot, relative) {
|
||||
const source = path.join(repoDir, relative);
|
||||
const stat = fs.lstatSync(source);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("declared_source_not_regular_file");
|
||||
const destination = path.join(releaseRoot, relative);
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o755 });
|
||||
if (fs.existsSync(destination)) {
|
||||
if (!fs.readFileSync(source).equals(fs.readFileSync(destination))) throw new Error("immutable_release_collision");
|
||||
return;
|
||||
}
|
||||
fs.copyFileSync(source, destination, fs.constants.COPYFILE_EXCL);
|
||||
fs.chmodSync(destination, stat.mode & 0o755);
|
||||
}
|
||||
|
||||
function writeAtomic(file, content, mode) {
|
||||
const temp = `${file}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temp, content, { mode });
|
||||
fs.renameSync(temp, file);
|
||||
}
|
||||
|
||||
function runFile(file, args) {
|
||||
return new Promise((resolve, reject) => execFile(file, args, { timeout: 120000, maxBuffer: 200000 }, (error, stdout, stderr) => error ? reject(new Error(`command_failed:${path.basename(file)}:${String(stderr || error.message).slice(0, 120)}`)) : resolve({ stdout: String(stdout || ""), stderr: String(stderr || "") })));
|
||||
}
|
||||
|
||||
function getJson(url, override) {
|
||||
if (override) return override(url);
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.get(url, { timeout: 5000 }, response => {
|
||||
let body = "";
|
||||
response.on("data", chunk => { body += chunk; if (body.length > 100000) req.destroy(); });
|
||||
response.on("end", () => { try { resolve(JSON.parse(body)); } catch { reject(new Error("invalid_runtime_response")); } });
|
||||
});
|
||||
req.on("timeout", () => req.destroy(new Error("runtime_check_timeout")));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function getJsonWithRetry(url, override, attempts = 15, delayMs = 1000) {
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try { return await getJson(url, override); }
|
||||
catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < attempts) await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function reply(socket, value) { socket.end(`${JSON.stringify(value)}\n`); }
|
||||
|
||||
if (require.main === module) {
|
||||
fs.mkdirSync(path.dirname(SOCKET_PATH), { recursive: true, mode: 0o755 });
|
||||
try { fs.unlinkSync(SOCKET_PATH); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
const server = net.createServer({ allowHalfOpen: true }, socket => {
|
||||
let input = "";
|
||||
socket.setTimeout(140000, () => socket.destroy());
|
||||
socket.on("data", chunk => { input += chunk.toString("utf8"); if (input.length > 4096) socket.destroy(); });
|
||||
socket.on("end", async () => {
|
||||
let request;
|
||||
try { request = JSON.parse(input); } catch { return reply(socket, { ok: false, error: "invalid_request" }); }
|
||||
if (!request || request.cmd || request.command || request.shell || request.args) return reply(socket, { ok: false, error: "arbitrary_command_forbidden" });
|
||||
reply(socket, await provision(request));
|
||||
});
|
||||
});
|
||||
server.listen(SOCKET_PATH, () => { fs.chownSync(SOCKET_PATH, 0, Number(process.env.LAKE_LAMP_AUTHZ_GID || 0)); fs.chmodSync(SOCKET_PATH, 0o660); });
|
||||
}
|
||||
|
||||
module.exports = { parseResource, safeRelative, validateManifest, validateUnit, provision };
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { parseResource, safeRelative, validateManifest, validateUnit, provision } = require("./architecture-provision-broker");
|
||||
|
||||
const commit = "d".repeat(40);
|
||||
const requestId = "GLS-0231-JD-LAN-01-INITIAL-PROVISION-20260720";
|
||||
|
||||
function manifest() {
|
||||
return {
|
||||
schema: "guanghu.architecture-provision-request/v1", request_id: requestId, target_node: "JD-FD-PRIMARY", status: "ARCHITECTURE_PACKAGE_READY · INITIAL_PROVISION_PENDING",
|
||||
module: { unit: "example.service", run_user: "guanghu" }, initial_provision: { kind: "new-architecture-unit" },
|
||||
source_paths: ["server-tools/example/server.js", "server-tools/example/example.service"],
|
||||
runtime_check: { url: "http://127.0.0.1:3924/health", expected: { ok: true, mode: "read-only" } },
|
||||
};
|
||||
}
|
||||
|
||||
test("resource and manifest are immutable and path constrained", () => {
|
||||
assert.deepEqual(parseResource(`${requestId}@${commit}`), { requestId, commit });
|
||||
assert.equal(parseResource(`${requestId}@main`), null);
|
||||
assert.equal(safeRelative("server-tools/example/server.js"), true);
|
||||
assert.equal(safeRelative("../etc/passwd"), false);
|
||||
assert.equal(validateManifest(manifest(), { requestId, commit }).unit, "example.service");
|
||||
});
|
||||
|
||||
test("unit requires non-root systemd hardening and release placeholder", () => {
|
||||
const unit = "[Service]\nUser=guanghu\nGroup=guanghu\nNoNewPrivileges=true\nPrivateTmp=true\nProtectSystem=strict\nProtectHome=true\nExecStart=/usr/bin/node __RELEASE_ROOT__/server.js\n";
|
||||
assert.equal(validateUnit(unit), unit);
|
||||
assert.throws(() => validateUnit(unit.replace("User=guanghu", "User=root")), /dedicated_service_user_required/);
|
||||
assert.throws(() => validateUnit(`${unit}EnvironmentFile=/etc/shadow\n`), /environment_file_not_declared/);
|
||||
assert.throws(() => validateUnit(unit.replace("__RELEASE_ROOT__", "/tmp/live")), /release_root_placeholder_required/);
|
||||
});
|
||||
|
||||
test("unit permits a declared persona user, shared secret and state directory", () => {
|
||||
const unit = "[Service]\nUser=kezhou\nGroup=kezhou\nNoNewPrivileges=yes\nPrivateTmp=yes\nProtectSystem=strict\nProtectHome=yes\nEnvironmentFile=-/etc/guanghu/persona-secrets/shared-deepseek.env\nReadWritePaths=/var/lib/guanghu/personas/kezhou\nReadOnlyPaths=__RELEASE_ROOT__\nExecStart=__RELEASE_ROOT__/run.sh\n";
|
||||
const policy = { environment_files: ["/etc/guanghu/persona-secrets/shared-deepseek.env"], writable_paths: ["/var/lib/guanghu/personas/kezhou"], read_only_paths: [] };
|
||||
assert.equal(validateUnit(unit, "kezhou", policy), unit);
|
||||
assert.throws(() => validateUnit(unit.replace("shared-deepseek.env", "../../shadow"), "kezhou", policy), /environment_file_not_declared/);
|
||||
assert.throws(() => validateUnit(unit.replace("/var/lib/guanghu/personas/kezhou", "/opt/guanghu/personas/kezhou"), "kezhou", policy), /writable_path_not_declared/);
|
||||
assert.throws(() => validateUnit(unit.replaceAll("kezhou", "root"), "root", policy), /dedicated_service_user_required/);
|
||||
});
|
||||
|
||||
test("provision copies only declared files and verifies loopback health", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "architecture-provision-"));
|
||||
const repoDir = path.join(root, "repo");
|
||||
const releasesDir = path.join(root, "releases");
|
||||
const unitDir = path.join(root, "units");
|
||||
const receiptsDir = path.join(root, "receipts");
|
||||
fs.mkdirSync(path.join(repoDir, "deployment", "requests"), { recursive: true });
|
||||
fs.mkdirSync(path.join(repoDir, "server-tools", "example"), { recursive: true });
|
||||
fs.writeFileSync(path.join(repoDir, "deployment", "requests", `${requestId}.json`), JSON.stringify(manifest()));
|
||||
fs.writeFileSync(path.join(repoDir, "server-tools", "example", "server.js"), "module.exports = {};\n");
|
||||
fs.writeFileSync(path.join(repoDir, "server-tools", "example", "example.service"), "[Service]\nUser=guanghu\nGroup=guanghu\nNoNewPrivileges=true\nPrivateTmp=true\nProtectSystem=strict\nProtectHome=true\nExecStart=/usr/bin/node __RELEASE_ROOT__/server-tools/example/server.js\n");
|
||||
const commands = [];
|
||||
let healthChecks = 0;
|
||||
try {
|
||||
const result = await provision({ target: "JD-FD-PRIMARY", action: "provision-approved-architecture", resource: `${requestId}@${commit}` }, {
|
||||
repoDir, releasesDir, unitDir, receiptsDir,
|
||||
run: async (file, args) => { commands.push([file, args]); return { stdout: args.includes("rev-parse") ? `${commit}\n` : "" }; },
|
||||
getJson: async () => {
|
||||
healthChecks += 1;
|
||||
if (healthChecks === 1) throw new Error("connection_refused_during_startup");
|
||||
return { ok: true, mode: "read-only" };
|
||||
},
|
||||
healthDelayMs: 0,
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(fs.existsSync(path.join(releasesDir, commit, "server-tools", "example", "server.js")), true);
|
||||
assert.match(fs.readFileSync(path.join(unitDir, "example.service"), "utf8"), new RegExp(commit));
|
||||
assert.equal(commands.some(([, args]) => args.includes("enable") && args.includes("--now")), true);
|
||||
assert.equal(healthChecks, 2);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
28
server-tools/lake-lamp-authz/authorization.env.example
Normal file
28
server-tools/lake-lamp-authz/authorization.env.example
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
LAKE_LAMP_HOST=127.0.0.1
|
||||
LAKE_LAMP_PORT=3921
|
||||
LAKE_LAMP_PUBLIC_URL=https://example.invalid/authz
|
||||
LAKE_LAMP_TARGETS=JD-FD-PRIMARY,BS-GZ-006
|
||||
LAKE_LAMP_APPROVAL_TTL=10800
|
||||
LAKE_LAMP_SESSION_TTL=10800
|
||||
LAKE_LAMP_MAX_SESSION_LIFETIME=86400
|
||||
LAKE_LAMP_STATE_FILE=/var/lib/guanghu/lake-lamp-authz/state.json
|
||||
LAKE_LAMP_MAPS_DIR=/etc/guanghu/navigation-maps
|
||||
LAKE_LAMP_MAP_STATE_FILE=/var/lib/guanghu/lake-lamp-authz/map-acks.json
|
||||
LAKE_LAMP_REPO_GRANT_DIR=/var/lib/guanghu/repo-authorizations
|
||||
LAKE_LAMP_PUBLIC_CREATE_LIMIT=8
|
||||
LAKE_LAMP_PUBLIC_CREATE_GLOBAL_LIMIT=60
|
||||
LAKE_LAMP_PUBLIC_MAIL_LIMIT=3
|
||||
LAKE_LAMP_PUBLIC_MAIL_GLOBAL_LIMIT=30
|
||||
LAKE_LAMP_REQUEST_TOKEN=SET_IN_PRIVATE_SERVER_FILE
|
||||
LAKE_LAMP_OWNER_EMAIL=SET_IN_PRIVATE_SERVER_FILE
|
||||
LAKE_LAMP_APPROVERS_FILE=/etc/guanghu/secrets/approvers.json
|
||||
SMTP_HOST=smtp.qq.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=SET_IN_PRIVATE_SERVER_FILE
|
||||
QQ_SMTP_AUTH_CODE=SET_IN_PRIVATE_SERVER_FILE
|
||||
LAKE_LAMP_ARCHITECTURE_PROVISION_SOCKET=/run/guanghu-architecture-provision/provision.sock
|
||||
ARCHITECTURE_PROVISION_REPO_URL=https://guanghulab.com/fifth-domain/bingshuo/fifth-domain.git
|
||||
ARCHITECTURE_PROVISION_REPO_DIR=/var/lib/guanghu/architecture-provision/repo
|
||||
ARCHITECTURE_PROVISION_RELEASES_DIR=/opt/guanghu/architecture-releases
|
||||
ARCHITECTURE_PROVISION_UNIT_DIR=/etc/systemd/system
|
||||
ARCHITECTURE_PROVISION_RECEIPTS_DIR=/var/lib/guanghu/architecture-provision/receipts
|
||||
109
server-tools/lake-lamp-authz/authorize-repo-push.js
Executable file
109
server-tools/lake-lamp-authz/authorize-repo-push.js
Executable file
|
|
@ -0,0 +1,109 @@
|
|||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const DEFAULT_URL = "https://guanghulab.com/authz";
|
||||
|
||||
async function authorizeRepoPush(options, deps = {}) {
|
||||
const fetchImpl = deps.fetch || fetch;
|
||||
const sleep = deps.sleep || (milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)));
|
||||
const output = deps.output || (line => process.stdout.write(`${line}\n`));
|
||||
const baseUrl = String(options.url || DEFAULT_URL).replace(/\/$/, "");
|
||||
const persona = required(options.persona, "persona");
|
||||
const repo = normalizeRepo(required(options.repo, "repo"));
|
||||
const target = options.target || "JD-FD-PRIMARY";
|
||||
const pollMilliseconds = positiveNumber(options.poll, 5000);
|
||||
const request = await requestJson(fetchImpl, `${baseUrl}/api/public/workorders`, {
|
||||
system_entry: "光湖语言人格系统当前实例",
|
||||
origin_software: options.software || "仓库推送客户端",
|
||||
origin_model: options.model || "未声明模型",
|
||||
origin_instance: options.instance || "当前实例",
|
||||
persona_id: persona,
|
||||
persona_name: options.name || persona,
|
||||
target,
|
||||
scope: "repo-push",
|
||||
action: "push-repository",
|
||||
description: options.description || `申请推送 ${repo}`,
|
||||
});
|
||||
|
||||
output("[LL-WORKORDER-CREATED] 无执行权申请单已创建;尚未发送邮件,也没有推送权限。");
|
||||
output(`REQUEST_URL=${request.request_url}`);
|
||||
output("请把 REQUEST_URL 交给冰朔并保持本命令运行。冰朔打开页面后,服务器才发送预登记邮箱邮件。");
|
||||
output("不需要向冰朔索要邮箱、授权码、验证码、密码或任何令牌。");
|
||||
|
||||
const deadline = Date.now() + Number(request.expires_in || 900) * 1000;
|
||||
let session;
|
||||
while (Date.now() < deadline) {
|
||||
const response = await fetchImpl(`${baseUrl}/api/workorders/${request.workorder_id}/claim`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${request.claim_token}` },
|
||||
});
|
||||
const payload = await readPayload(response);
|
||||
if (response.status === 200) { session = payload; break; }
|
||||
if (response.status !== 202 || payload.error !== "approval_pending") {
|
||||
throw new Error(payload.error || `claim failed (${response.status})`);
|
||||
}
|
||||
await sleep(pollMilliseconds);
|
||||
}
|
||||
if (!session) throw new Error("authorization request expired before approval");
|
||||
|
||||
const common = { persona_id: persona, target, scope: "repo-push" };
|
||||
const map = await requestJson(fetchImpl, `${baseUrl}/api/navigation-map/read`, common, session.session_token);
|
||||
await requestJson(fetchImpl, `${baseUrl}/api/navigation-map/ack`, { ...common, map_hash: map.map_hash }, session.session_token);
|
||||
const grant = await requestJson(fetchImpl, `${baseUrl}/api/repo-push/grant`, { ...common, repo }, session.session_token);
|
||||
output(`[LL-REPO-PUSH-GRANTED] ${grant.repo} 已获得限时推送许可。现在重试原 git push。`);
|
||||
return grant;
|
||||
}
|
||||
|
||||
async function requestJson(fetchImpl, url, body, bearerToken = "") {
|
||||
const headers = { "content-type": "application/json" };
|
||||
if (bearerToken) headers.authorization = `Bearer ${bearerToken}`;
|
||||
const response = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify(body) });
|
||||
const payload = await readPayload(response);
|
||||
if (!response.ok) throw new Error(payload.error || `request failed (${response.status})`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function readPayload(response) {
|
||||
try { return await response.json(); } catch { return {}; }
|
||||
}
|
||||
|
||||
function normalizeRepo(value) {
|
||||
const repo = String(value).trim().toLowerCase().replace(/\.git$/, "");
|
||||
if (!/^bingshuo\/[a-z0-9._-]+$/.test(repo)) throw new Error("--repo must be bingshuo/<repository>");
|
||||
return repo;
|
||||
}
|
||||
|
||||
function required(value, name) {
|
||||
if (!value) throw new Error(`--${name} is required`);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function positiveNumber(value, fallback) {
|
||||
if (value === undefined) return fallback;
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || number <= 0) throw new Error("--poll must be a positive millisecond value");
|
||||
return number;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {};
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const key = String(argv[index] || "").replace(/^--/, "");
|
||||
if (!key || argv[index + 1] === undefined) throw new Error(`invalid argument: ${argv[index] || ""}`);
|
||||
result[key] = argv[index + 1];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await authorizeRepoPush(parseArgs(process.argv.slice(2)));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch(error => {
|
||||
process.stderr.write(`[LL-REPO-PUSH-AUTH-FAILED] ${error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { authorizeRepoPush, normalizeRepo, parseArgs };
|
||||
60
server-tools/lake-lamp-authz/authorize-repo-push.test.js
Normal file
60
server-tools/lake-lamp-authz/authorize-repo-push.test.js
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { createApp } = require("./server");
|
||||
const { authorizeRepoPush } = require("./authorize-repo-push");
|
||||
|
||||
test("one helper command completes owner handoff, map acknowledgement, and repo grant", async () => {
|
||||
const mail = [];
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "lake-lamp-repo-push-"));
|
||||
const mapGate = {
|
||||
read: target => ({ hash: `map-${target}`, data: { node_id: target } }),
|
||||
ack: () => ({ ok: true }),
|
||||
verify: () => ({ ok: true }),
|
||||
};
|
||||
const app = createApp({
|
||||
ownerEmail: "owner@example.invalid",
|
||||
publicBaseUrl: "https://example.invalid/authz",
|
||||
targets: ["JD-FD-PRIMARY"],
|
||||
stateFile: "",
|
||||
repoGrantDir: directory,
|
||||
mapGate,
|
||||
sendEmail: async message => { mail.push(message); return true; },
|
||||
});
|
||||
await new Promise(resolve => app.listen(0, "127.0.0.1", resolve));
|
||||
const base = `http://127.0.0.1:${app.address().port}`;
|
||||
const lines = [];
|
||||
try {
|
||||
const grantPromise = authorizeRepoPush({
|
||||
url: base,
|
||||
persona: "ICE-GL-ZY001",
|
||||
name: "铸渊",
|
||||
repo: "bingshuo/fifth-domain",
|
||||
poll: 1,
|
||||
}, {
|
||||
output: line => lines.push(line),
|
||||
sleep: milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
|
||||
});
|
||||
|
||||
while (!lines.some(line => line.startsWith("REQUEST_URL="))) await new Promise(resolve => setTimeout(resolve, 1));
|
||||
const requestUrl = lines.find(line => line.startsWith("REQUEST_URL=")).slice("REQUEST_URL=".length);
|
||||
const requestPath = new URL(requestUrl).pathname.replace("/authz", "");
|
||||
assert.equal((await fetch(`${base}${requestPath}`, { method: "POST" })).status, 200);
|
||||
assert.equal(mail.length, 1);
|
||||
const approvalPath = new URL(mail[0].approvalUrl).pathname.replace("/authz", "");
|
||||
assert.equal((await fetch(`${base}${approvalPath}`, { method: "POST" })).status, 200);
|
||||
|
||||
const grant = await grantPromise;
|
||||
assert.equal(grant.repo, "bingshuo/fifth-domain");
|
||||
assert.equal(grant.target, "JD-FD-PRIMARY");
|
||||
assert.ok(lines.some(line => line.startsWith("[LL-REPO-PUSH-GRANTED]")));
|
||||
assert.ok(fs.existsSync(path.join(directory, "bingshuo__fifth-domain.json")));
|
||||
} finally {
|
||||
await new Promise(resolve => app.close(resolve));
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
16
server-tools/lake-lamp-authz/guanghu-authz.nginx.conf
Normal file
16
server-tools/lake-lamp-authz/guanghu-authz.nginx.conf
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Public approval surface. The service itself remains on JD loopback and this
|
||||
# route is reached through a permitopen-restricted SSH tunnel.
|
||||
location /authz/ {
|
||||
proxy_pass http://127.0.0.1:19221/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
# Overwrite untrusted client input so application-level rate limits use the
|
||||
# address observed by this public edge, not a spoofed left-most value.
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Prefix /authz;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 30s;
|
||||
client_max_body_size 64k;
|
||||
}
|
||||
19
server-tools/lake-lamp-authz/guanghu-jd-authz-tunnel.service
Normal file
19
server-tools/lake-lamp-authz/guanghu-jd-authz-tunnel.service
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[Unit]
|
||||
Description=Guanghu BS-GZ-006 to JD Lake Lamp authorization tunnel
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/bin/ssh -NT -F /etc/guanghu/jd-authz-tunnel-ssh-config jd-authz-target
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=read-only
|
||||
ProtectSystem=strict
|
||||
ReadOnlyPaths=/etc/guanghu/jd-authz-tunnel-ssh-config /etc/guanghu/secrets/ssh/bs_gz_006_to_jd_authz_proxy /root/.ssh/known_hosts
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
25
server-tools/lake-lamp-authz/install-architecture-provisioner.sh
Executable file
25
server-tools/lake-lamp-authz/install-architecture-provisioner.sh
Executable file
|
|
@ -0,0 +1,25 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ${EUID} -ne 0 ]]; then
|
||||
echo "run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
install_root=/opt/guanghu/lake-lamp-authz
|
||||
|
||||
install -d -m 0755 "$install_root"
|
||||
for file in server.js workorder-manager.js map-gate.js smtp-mailer.js action-client.js architecture-provision-broker.js; do
|
||||
install -m 0644 "$script_dir/$file" "$install_root/$file"
|
||||
done
|
||||
install -m 0644 "$script_dir/lake-lamp-architecture-provision.service" /etc/systemd/system/lake-lamp-architecture-provision.service
|
||||
install -d -m 0700 /var/lib/guanghu/architecture-provision
|
||||
install -d -m 0755 /opt/guanghu/architecture-releases
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now lake-lamp-architecture-provision.service
|
||||
systemctl restart lake-lamp-authz.service
|
||||
systemctl is-active --quiet lake-lamp-architecture-provision.service
|
||||
systemctl is-active --quiet lake-lamp-authz.service
|
||||
echo ARCHITECTURE_PROVISIONER_INSTALLED
|
||||
24
server-tools/lake-lamp-authz/install-gz-proxy.sh
Executable file
24
server-tools/lake-lamp-authz/install-gz-proxy.sh
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
JD_HOST=${1:?JD host is required}
|
||||
|
||||
install -m 644 /tmp/guanghu-jd-authz-tunnel.service /etc/systemd/system/guanghu-jd-authz-tunnel.service
|
||||
install -m 644 /tmp/guanghu-authz.nginx.conf /etc/nginx/snippets/guanghu-authz.conf
|
||||
chmod 600 /etc/guanghu/secrets/ssh/bs_gz_006_to_jd_authz_proxy
|
||||
|
||||
install -m 600 /dev/null /etc/guanghu/jd-authz-tunnel-ssh-config
|
||||
sed \
|
||||
-e "s/JD_PUBLIC_ADDRESS/${JD_HOST}/" \
|
||||
/tmp/jd-authz-tunnel-ssh-config.example > /etc/guanghu/jd-authz-tunnel-ssh-config
|
||||
|
||||
if ! grep -q "include /etc/nginx/snippets/guanghu-authz.conf;" /etc/nginx/sites-enabled/guanghulab; then
|
||||
sed -i '0,/server_name guanghulab.com;/s##server_name guanghulab.com;\n include /etc/nginx/snippets/guanghu-authz.conf;#' /etc/nginx/sites-enabled/guanghulab
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now guanghu-jd-authz-tunnel.service
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
|
||||
rm -f /tmp/guanghu-jd-authz-tunnel.service /tmp/guanghu-authz.nginx.conf /tmp/jd-authz-tunnel-ssh-config.example
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
Host jd-authz-target
|
||||
HostName JD_PUBLIC_ADDRESS
|
||||
User root
|
||||
IdentityFile /etc/guanghu/secrets/ssh/bs_gz_006_to_jd_authz_proxy
|
||||
IdentitiesOnly yes
|
||||
LocalForward 127.0.0.1:19221 127.0.0.1:3921
|
||||
ExitOnForwardFailure yes
|
||||
ServerAliveInterval 30
|
||||
ServerAliveCountMax 3
|
||||
25
server-tools/lake-lamp-authz/lake-lamp-action-broker.service
Normal file
25
server-tools/lake-lamp-authz/lake-lamp-action-broker.service
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
[Unit]
|
||||
Description=Guanghu fixed-action broker for Lake Lamp authorization
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
EnvironmentFile=/etc/guanghu/secrets/lake-lamp/action-broker.env
|
||||
ExecStart=/usr/bin/node /opt/guanghu/lake-lamp-authz/action-broker.js
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadOnlyPaths=/etc/guanghu/action-broker-ssh-config /etc/guanghu/secrets/lake-lamp
|
||||
RuntimeDirectory=guanghu
|
||||
RuntimeDirectoryMode=0755
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
LockPersonality=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
[Unit]
|
||||
Description=Guanghu approved architecture initial-provision broker
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
EnvironmentFile=/etc/guanghu/secrets/lake-lamp/action-broker.env
|
||||
ExecStart=/usr/bin/node /opt/guanghu/lake-lamp-authz/architecture-provision-broker.js
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/guanghu/architecture-provision /opt/guanghu/architecture-releases /etc/systemd/system
|
||||
RuntimeDirectory=guanghu-architecture-provision
|
||||
RuntimeDirectoryMode=0755
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
LockPersonality=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
24
server-tools/lake-lamp-authz/lake-lamp-authz.service
Normal file
24
server-tools/lake-lamp-authz/lake-lamp-authz.service
Normal file
|
|
@ -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
|
||||
25
server-tools/lake-lamp-authz/lake-lamp-owner-access.service
Normal file
25
server-tools/lake-lamp-authz/lake-lamp-owner-access.service
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
[Unit]
|
||||
Description=Guanghu owner-access recovery executor
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
EnvironmentFile=/etc/guanghu/secrets/lake-lamp/action-broker.env
|
||||
ExecStart=/usr/bin/node /opt/guanghu/lake-lamp-authz/owner-access-broker.js
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadOnlyPaths=/etc/guanghu/action-broker-ssh-config /etc/guanghu/secrets/lake-lamp
|
||||
RuntimeDirectory=guanghu-owner-access
|
||||
RuntimeDirectoryMode=0755
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
LockPersonality=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
45
server-tools/lake-lamp-authz/map-gate.js
Normal file
45
server-tools/lake-lamp-authz/map-gate.js
Normal file
|
|
@ -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 };
|
||||
37
server-tools/lake-lamp-authz/map-gate.test.js
Normal file
37
server-tools/lake-lamp-authz/map-gate.test.js
Normal file
|
|
@ -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 }); }
|
||||
});
|
||||
86
server-tools/lake-lamp-authz/owner-access-broker.js
Normal file
86
server-tools/lake-lamp-authz/owner-access-broker.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const path = require("node:path");
|
||||
const { execFile } = require("node:child_process");
|
||||
|
||||
const SOCKET_PATH = process.env.LAKE_LAMP_OWNER_ACCESS_SOCKET || "/run/guanghu-owner-access/owner-access.sock";
|
||||
const SSH_CONFIG = process.env.LAKE_LAMP_SSH_CONFIG || "/etc/guanghu/action-broker-ssh-config";
|
||||
|
||||
const RESTORE_OWNER_PASSWORD_LOGIN = String.raw`set -eu
|
||||
test "$(id -u)" = "0"
|
||||
cfg=/etc/ssh/sshd_config.d/90-guanghu-key-only.conf
|
||||
test -f "$cfg"
|
||||
backup_dir=/root/guanghu-security-backup-$(date -u +%Y%m%dT%H%M%SZ)
|
||||
mkdir -p "$backup_dir"
|
||||
chmod 0700 "$backup_dir"
|
||||
cp -a "$cfg" "$backup_dir/90-guanghu-key-only.conf.before"
|
||||
rollback() {
|
||||
cp -a "$backup_dir/90-guanghu-key-only.conf.before" "$cfg"
|
||||
/usr/sbin/sshd -t
|
||||
systemctl reload ssh 2>/dev/null || systemctl reload sshd
|
||||
}
|
||||
trap 'rc=$?; if [ "$rc" -ne 0 ]; then rollback; fi; exit "$rc"' EXIT
|
||||
/usr/bin/python3 - "$cfg" <<'PY'
|
||||
from pathlib import Path
|
||||
import re, sys
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
text = path.read_text()
|
||||
pattern = re.compile(r"(?im)^\s*PasswordAuthentication\s+\S+\s*$")
|
||||
if pattern.search(text):
|
||||
text = pattern.sub("PasswordAuthentication yes", text)
|
||||
else:
|
||||
text = text.rstrip() + "\nPasswordAuthentication yes\n"
|
||||
path.write_text(text)
|
||||
PY
|
||||
/usr/sbin/sshd -t
|
||||
password=$(/usr/sbin/sshd -T | awk '$1 == "passwordauthentication" {print $2; exit}')
|
||||
root_login=$(/usr/sbin/sshd -T | awk '$1 == "permitrootlogin" {print $2; exit}')
|
||||
test "$password" = "yes"
|
||||
test "$root_login" != "yes"
|
||||
systemctl reload ssh 2>/dev/null || systemctl reload sshd
|
||||
printf 'PASSWORD_AUTH=%s\nROOT_PASSWORD_AUTH=%s\nSSH=%s\nBACKUP=%s\n' \
|
||||
"$password" "$root_login" \
|
||||
"$(systemctl is-active ssh 2>/dev/null || systemctl is-active sshd)" \
|
||||
"$backup_dir/90-guanghu-key-only.conf.before"
|
||||
trap - EXIT`;
|
||||
|
||||
function run(file, args) {
|
||||
return new Promise(resolve => execFile(file, args, { timeout: 30000, maxBuffer: 100000 }, (error, stdout, stderr) => resolve({
|
||||
ok: !error,
|
||||
exit_code: error ? (Number.isInteger(error.code) ? error.code : 1) : 0,
|
||||
stdout: String(stdout || "").slice(0, 100000),
|
||||
stderr: String(stderr || "").slice(0, 10000),
|
||||
})));
|
||||
}
|
||||
|
||||
async function execute(request) {
|
||||
if (!request || request.cmd || request.command || request.shell || request.args) return { ok: false, error: "arbitrary_command_forbidden" };
|
||||
if (request.target !== "JD-FD-PRIMARY" || request.action !== "restore-owner-password-login") return { ok: false, error: "action_not_registered" };
|
||||
return run("/usr/bin/ssh", ["-F", SSH_CONFIG, "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "enterprise-lighthouse", RESTORE_OWNER_PASSWORD_LOGIN]);
|
||||
}
|
||||
|
||||
function reply(socket, value) { socket.end(`${JSON.stringify(value)}\n`); }
|
||||
|
||||
if (require.main === module) {
|
||||
fs.mkdirSync(path.dirname(SOCKET_PATH), { recursive: true, mode: 0o755 });
|
||||
try { fs.unlinkSync(SOCKET_PATH); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
const server = net.createServer({ allowHalfOpen: true }, socket => {
|
||||
let input = "";
|
||||
socket.setTimeout(5000, () => socket.destroy());
|
||||
socket.on("data", chunk => { input += chunk.toString("utf8"); if (input.length > 4096) socket.destroy(); });
|
||||
socket.on("end", async () => {
|
||||
let request;
|
||||
try { request = JSON.parse(input); } catch { return reply(socket, { ok: false, error: "invalid_request" }); }
|
||||
reply(socket, await execute(request));
|
||||
});
|
||||
});
|
||||
server.listen(SOCKET_PATH, () => {
|
||||
fs.chownSync(SOCKET_PATH, 0, Number(process.env.LAKE_LAMP_AUTHZ_GID || 0));
|
||||
fs.chmodSync(SOCKET_PATH, 0o660);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { execute, RESTORE_OWNER_PASSWORD_LOGIN, SOCKET_PATH };
|
||||
14
server-tools/lake-lamp-authz/owner-access-broker.test.js
Normal file
14
server-tools/lake-lamp-authz/owner-access-broker.test.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { execute, RESTORE_OWNER_PASSWORD_LOGIN } = require("./owner-access-broker");
|
||||
|
||||
test("owner-access executor exposes one bounded recovery action", async () => {
|
||||
assert.match(RESTORE_OWNER_PASSWORD_LOGIN, /PasswordAuthentication yes/);
|
||||
assert.match(RESTORE_OWNER_PASSWORD_LOGIN, /sshd -t/);
|
||||
assert.match(RESTORE_OWNER_PASSWORD_LOGIN, /systemctl reload ssh/);
|
||||
assert.doesNotMatch(RESTORE_OWNER_PASSWORD_LOGIN, /PermitRootLogin yes/);
|
||||
assert.deepEqual(await execute({ target: "JD-FD-PRIMARY", action: "unknown" }), { ok: false, error: "action_not_registered" });
|
||||
assert.deepEqual(await execute({ target: "JD-FD-PRIMARY", action: "restore-owner-password-login", cmd: "id" }), { ok: false, error: "arbitrary_command_forbidden" });
|
||||
});
|
||||
52
server-tools/lake-lamp-authz/request-workorder.js
Normal file
52
server-tools/lake-lamp-authz/request-workorder.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
#!/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 requestToken = process.env.LAKE_LAMP_REQUEST_TOKEN || readTrim(process.env.LAKE_LAMP_REQUEST_TOKEN_FILE || "");
|
||||
const baseUrl = String(args.url).replace(/\/$/, "");
|
||||
const endpoint = requestToken ? "/api/workorders" : "/api/public/workorders";
|
||||
const body = {
|
||||
system_entry: "光湖语言人格系统当前实例",
|
||||
origin_software: args.software || "未声明软件",
|
||||
origin_model: args.model || "未声明模型",
|
||||
origin_instance: args.instance || "当前实例",
|
||||
persona_id: args.persona,
|
||||
persona_name: args.name || args.persona,
|
||||
target: args.target,
|
||||
scope: args.scope,
|
||||
action: args.action,
|
||||
description: args.description || "",
|
||||
resource: args.resource || "",
|
||||
};
|
||||
if (requestToken) {
|
||||
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 for private request mode");
|
||||
const email = `${qqId}@qq.com`;
|
||||
body.recipient_fingerprint = crypto.createHash("sha256").update(email.toLowerCase()).digest("hex");
|
||||
}
|
||||
const headers = { "content-type": "application/json" };
|
||||
if (requestToken) headers.authorization = `Bearer ${requestToken}`;
|
||||
const response = await fetch(`${baseUrl}${endpoint}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
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); });
|
||||
436
server-tools/lake-lamp-authz/server.js
Normal file
436
server-tools/lake-lamp-authz/server.js
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
"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 { executeRegisteredAction } = require("./action-client");
|
||||
|
||||
const DEFAULT_ACTIONS = Object.freeze({
|
||||
"server-login": ["read-navigation-map", "inspect-services", "health-check"],
|
||||
"server-ops": [
|
||||
"read-navigation-map",
|
||||
"inspect-services",
|
||||
"pull-registered-repo",
|
||||
"deploy-registered-service",
|
||||
"restart-registered-service",
|
||||
"health-check",
|
||||
"rollback-registered-service",
|
||||
"provision-approved-architecture",
|
||||
"push-repository",
|
||||
"restore-owner-password-login",
|
||||
],
|
||||
"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 approvers = options.approvers || loadApprovers(options.approversFile || process.env.LAKE_LAMP_APPROVERS_FILE || "", ownerEmail);
|
||||
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 || 3 * 60 * 60),
|
||||
sessionTtl: Number(options.sessionTtl || process.env.LAKE_LAMP_SESSION_TTL || 3 * 60 * 60),
|
||||
maxSessionLifetime: Number(options.maxSessionLifetime || process.env.LAKE_LAMP_MAX_SESSION_LIFETIME || 24 * 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";
|
||||
const executeAction = options.executeAction || executeRegisteredAction;
|
||||
// Creating a powerless request must never become harder than the human mail
|
||||
// handoff. Keep at least three attempts per network each hour.
|
||||
const publicCreateLimit = Math.max(3, Number(options.publicCreateLimit || process.env.LAKE_LAMP_PUBLIC_CREATE_LIMIT || 24));
|
||||
const publicCreateLimiter = options.publicCreateLimiter || new SlidingWindowLimiter(publicCreateLimit, 60 * 60);
|
||||
const publicCreateGlobalLimiter = options.publicCreateGlobalLimiter || new SlidingWindowLimiter(Number(options.publicCreateGlobalLimit || process.env.LAKE_LAMP_PUBLIC_CREATE_GLOBAL_LIMIT || 60), 60 * 60);
|
||||
// Owner handoff is a human recovery path, not a login endpoint. Always allow
|
||||
// at least three genuine mail attempts per network each hour, even if an old
|
||||
// deployment environment accidentally configures a lower value.
|
||||
const publicMailLimit = Math.max(3, Number(options.publicMailLimit || process.env.LAKE_LAMP_PUBLIC_MAIL_LIMIT || 12));
|
||||
const publicMailLimiter = options.publicMailLimiter || new SlidingWindowLimiter(publicMailLimit, 60 * 60);
|
||||
const publicMailGlobalLimiter = options.publicMailGlobalLimiter || new SlidingWindowLimiter(Number(options.publicMailGlobalLimit || process.env.LAKE_LAMP_PUBLIC_MAIL_GLOBAL_LIMIT || 30), 60 * 60);
|
||||
|
||||
async function sendApprovalEmail(handoffToken) {
|
||||
const issued = manager.issueApproval(handoffToken);
|
||||
if (!issued.ok) return issued;
|
||||
const approver = selectApprover(approvers, issued.order);
|
||||
if (!approver) {
|
||||
manager.failApprovalEmail(handoffToken);
|
||||
return { ok: false, reason: "no_registered_approver" };
|
||||
}
|
||||
const approvalUrl = `${publicBaseUrl}/approve/${issued.approvalToken}`;
|
||||
const emailSent = await sendEmail({
|
||||
to: approver.email,
|
||||
subject: `小湖灯授权请求 · ${issued.order.target}`,
|
||||
approvalUrl,
|
||||
order: issued.order,
|
||||
});
|
||||
if (!emailSent) {
|
||||
manager.failApprovalEmail(handoffToken);
|
||||
return { ok: false, reason: "authorization_email_failed" };
|
||||
}
|
||||
return { ok: true, order: issued.order };
|
||||
}
|
||||
|
||||
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",
|
||||
approval_ttl: manager.approvalTtl,
|
||||
session_ttl: manager.sessionTtl,
|
||||
max_session_lifetime: manager.maxSessionLifetime,
|
||||
auto_renew_on_activity: true,
|
||||
});
|
||||
if (req.method === "GET" && url.pathname === "/api/public/capabilities") return json(res, 200, {
|
||||
schema: "guanghu.lake-lamp-public-workorder/v1",
|
||||
create_workorder: `${publicBaseUrl}/api/public/workorders`,
|
||||
required_fields: ["persona_id", "target", "scope", "action"],
|
||||
optional_fields: ["persona_name", "description", "resource"],
|
||||
targets: [...targets],
|
||||
scopes: actions,
|
||||
owner_handoff: "open request_url and request pre-registered mailbox verification",
|
||||
approval_ttl: manager.approvalTtl,
|
||||
session_ttl: manager.sessionTtl,
|
||||
max_session_lifetime: manager.maxSessionLifetime,
|
||||
auto_renew_on_activity: true,
|
||||
limits: {
|
||||
create_per_network_per_hour: publicCreateLimit,
|
||||
email_per_network_per_hour: publicMailLimit,
|
||||
},
|
||||
});
|
||||
|
||||
const requestMatch = url.pathname.match(/^\/request\/([A-Za-z0-9_-]{20,})$/);
|
||||
if (requestMatch && req.method === "GET") {
|
||||
const inspected = manager.inspectHandoff(requestMatch[1]);
|
||||
if (!inspected.ok) return html(res, 410, requestErrorPage(inspected.reason));
|
||||
return html(res, 200, requestPage(inspected.order));
|
||||
}
|
||||
if (requestMatch && req.method === "POST") {
|
||||
const inspected = manager.inspectHandoff(requestMatch[1]);
|
||||
if (!inspected.ok) return html(res, 410, requestErrorPage(inspected.reason));
|
||||
// Refreshing or reopening an already-sent request must not consume a
|
||||
// second rate-limit slot. It also must not send a duplicate email.
|
||||
if (inspected.order.approval_email_sent) return html(res, 200, emailSentPage(inspected.order));
|
||||
const source = clientAddress(req);
|
||||
if (!publicMailLimiter.take(source) || !publicMailGlobalLimiter.take("global")) return html(res, 429, requestErrorPage("rate_limited"));
|
||||
const sent = await sendApprovalEmail(requestMatch[1]);
|
||||
if (!sent.ok && sent.reason !== "approval_email_already_sent") return html(res, sent.reason === "authorization_email_failed" ? 502 : 410, requestErrorPage(sent.reason));
|
||||
return html(res, 200, emailSentPage(sent.order));
|
||||
}
|
||||
|
||||
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/public/workorders") {
|
||||
const source = clientAddress(req);
|
||||
if (!publicCreateLimiter.take(source) || !publicCreateGlobalLimiter.take("global")) return json(res, 429, { error: "rate_limited", retry_after: 3600 });
|
||||
const body = await readJson(req);
|
||||
if (!body) return json(res, 400, { error: "invalid_json" });
|
||||
const validation = validateWorkorderBody(body, targets, actions);
|
||||
if (!validation.ok) return json(res, validation.status, { error: validation.error });
|
||||
const created = manager.request(validation.request);
|
||||
return json(res, 201, {
|
||||
ok: true,
|
||||
workorder_id: created.id,
|
||||
claim_token: created.claimToken,
|
||||
request_url: `${publicBaseUrl}/request/${created.handoffToken}`,
|
||||
expires_in: created.expiresIn,
|
||||
status: "waiting_for_owner_handoff",
|
||||
});
|
||||
}
|
||||
|
||||
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" });
|
||||
const validation = validateWorkorderBody(body, targets, actions);
|
||||
if (!validation.ok) return json(res, validation.status, { error: validation.error });
|
||||
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(validation.request);
|
||||
const sent = await sendApprovalEmail(created.handoffToken);
|
||||
if (!sent.ok) return json(res, 502, { error: sent.reason });
|
||||
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, resource: claimed.resource || "" });
|
||||
}
|
||||
|
||||
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 || ""), Date.now() / 1000, Object.prototype.hasOwnProperty.call(body, "resource") ? String(body.resource || "") : undefined);
|
||||
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/session/renew") {
|
||||
const body = await readJson(req);
|
||||
if (!body) return json(res, 400, { error: "invalid_json" });
|
||||
if (body.action || body.actions || body.target_override || body.scope_override || body.resource) return json(res, 400, { error: "renewal_cannot_expand_authority" });
|
||||
const token = bearer(req);
|
||||
const target = String(body.target || "");
|
||||
const scope = String(body.scope || "");
|
||||
const renewed = manager.renewSession(token, { pid: String(body.persona_id || "") }, target, scope);
|
||||
if (!renewed.ok) return json(res, 403, { error: renewed.reason });
|
||||
const map = mapGate.read(target);
|
||||
const acked = mapGate.ack(token, target, map.hash, Date.now() / 1000, Math.max(1, renewed.expiresAt - Date.now() / 1000));
|
||||
if (!acked.ok) return json(res, 409, { error: acked.reason });
|
||||
return json(res, 200, { ok: true, target, scope, expires_at: renewed.expiresAt, renewals: renewed.renewals, authority_expanded: false });
|
||||
}
|
||||
|
||||
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/actions/execute") {
|
||||
const body = await readJson(req);
|
||||
if (!body) return json(res, 400, { error: "invalid_json" });
|
||||
if (body.cmd || body.command || body.shell || body.args) return json(res, 400, { error: "arbitrary_command_forbidden" });
|
||||
const token = bearer(req);
|
||||
const target = String(body.target || "");
|
||||
const scope = String(body.scope || "");
|
||||
const action = String(body.action || "");
|
||||
const resource = String(body.resource || "");
|
||||
const verified = manager.verifySession(token, { pid: String(body.persona_id || "") }, target, scope, action, Date.now() / 1000, resource);
|
||||
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" });
|
||||
const result = await executeAction(resource ? { action, target, resource } : { action, target });
|
||||
return json(res, result.ok ? 200 : 502, result);
|
||||
}
|
||||
|
||||
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("小湖灯授权请求", `
|
||||
<p class="eyebrow">LAKE LAMP SECURITY PROTOCOL</p>
|
||||
<h1>人格体请求进入一台服务器</h1>
|
||||
<div class="panel">
|
||||
<dl><dt>人格体</dt><dd>${escapeHtml(order.persona.name)} <small>${escapeHtml(order.persona.pid)}</small></dd>
|
||||
<dt>申请入口</dt><dd>${escapeHtml(order.provenance && order.provenance.system_entry || "旧版工单")}</dd>
|
||||
<dt>实例来源</dt><dd>${escapeHtml(originLabel(order))}</dd>
|
||||
<dt>目标节点</dt><dd>${escapeHtml(order.target)}</dd><dt>授权范围</dt><dd>${escapeHtml(order.scope)}</dd>
|
||||
<dt>进入动作</dt><dd>${escapeHtml(order.action)}</dd><dt>会话能力</dt><dd>${escapeHtml((order.allowed_actions || [order.action]).join(" · "))}</dd>
|
||||
<dt>绑定资源</dt><dd>${escapeHtml(order.resource || "无")}</dd>
|
||||
<dt>说明</dt><dd>${escapeHtml(order.description || "未附加说明")}</dd></dl>
|
||||
</div>
|
||||
<p class="notice">一次确认将打开这台服务器上的三小时受限运维会话。人格体持续执行已绑定任务时会自动续期;切换服务器、扩大范围、切换绑定资源或停止活动后过期才需重新申请。</p>
|
||||
<form method="post"><button type="submit">打开三小时受限运维会话</button></form>
|
||||
`);
|
||||
}
|
||||
|
||||
function requestPage(order) {
|
||||
return document("小湖灯跨设备授权", `
|
||||
<p class="eyebrow">CROSS-DEVICE HANDOFF</p>
|
||||
<h1>核对这张无权限申请单</h1>
|
||||
<div class="panel">
|
||||
<dl><dt>人格体</dt><dd>${escapeHtml(order.persona.name)} <small>${escapeHtml(order.persona.pid)}</small></dd>
|
||||
<dt>申请入口</dt><dd>${escapeHtml(order.provenance && order.provenance.system_entry || "旧版工单")}</dd>
|
||||
<dt>实例来源</dt><dd>${escapeHtml(originLabel(order))}</dd>
|
||||
<dt>目标节点</dt><dd>${escapeHtml(order.target)}</dd><dt>授权范围</dt><dd>${escapeHtml(order.scope)}</dd>
|
||||
<dt>登记动作</dt><dd>${escapeHtml(order.action)}</dd><dt>绑定资源</dt><dd>${escapeHtml(order.resource || "无")}</dd><dt>说明</dt><dd>${escapeHtml(order.description || "未附加说明")}</dd></dl>
|
||||
</div>
|
||||
<p class="notice">这张页面本身没有执行权。确认内容无误后,服务器只会向预登记邮箱发送一次真正的批准链接。</p>
|
||||
<form method="post"><button type="submit">发送我的授权邮件</button></form>
|
||||
`);
|
||||
}
|
||||
|
||||
function emailSentPage(order) {
|
||||
return document("授权邮件已发送", `<p class="eyebrow">OWNER VERIFICATION</p><h1>请打开邮箱完成批准</h1><div class="panel"><p>申请单已锁定到 <strong>${escapeHtml(order && order.target || "登记节点")}</strong>。真正的批准链接只发送到服务器预登记邮箱。</p></div><p class="notice">批准后回到原来的 AI 对话,让它领取一次性会话。无需向 AI 提供验证码、密码或邮箱授权码。</p>`);
|
||||
}
|
||||
|
||||
function requestErrorPage(reason) {
|
||||
const messages = {
|
||||
rate_limited: "请求过于频繁,请稍后再试。",
|
||||
approval_email_already_sent: "授权邮件已经发送,请直接检查邮箱。",
|
||||
authorization_email_failed: "授权邮件暂时发送失败,请稍后重试。",
|
||||
};
|
||||
if (reason === "rate_limited") return document("发送频率保护", `<p class="eyebrow">RATE LIMIT · REQUEST KEPT</p><h1>小湖灯先替你守住这张申请单</h1><div class="panel"><p>当前网络在一小时内触发邮件的次数较多,发送动作被暂时暂停。</p></div><p class="notice">申请单本身没有被关闭。请稍后再试,或切换到手机流量后只点击一次。无需重新填写,也不要连续刷新。</p>`);
|
||||
return document("申请单不可用", `<p class="eyebrow">REQUEST CLOSED</p><h1>这张申请单现在不能继续</h1><p class="notice">${escapeHtml(messages[reason] || `原因:${reason}`)}</p>`);
|
||||
}
|
||||
|
||||
function approvedPage(order) {
|
||||
return document("授权完成", `<p class="eyebrow">THREE-HOUR OPS SESSION</p><h1>三小时运维会话已打开</h1><div class="panel"><p>${escapeHtml(order.persona.name)} 已获准在 <strong>${escapeHtml(order.target)}</strong> 上执行本范围内的已登记能力。</p></div><p class="notice">可以关闭本页面。人格体持续执行原绑定任务时会自动续期;切换服务器、扩大范围、切换绑定资源或停止活动后过期才重新授权。</p>`);
|
||||
}
|
||||
|
||||
function approvalErrorPage(reason) {
|
||||
return document("链接不可用", `<p class="eyebrow">LINK CLOSED</p><h1>这条授权链接已经失效</h1><p class="notice">原因:${escapeHtml(reason)}。如仍需操作,请让人格体重新提交工单。</p>`);
|
||||
}
|
||||
|
||||
function document(title, body) {
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)} · 光湖</title><style>
|
||||
:root{color-scheme:dark}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:radial-gradient(circle at 20% 10%,#17344d,#09111b 55%,#05090e);color:#eaf4fb;font:16px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;padding:24px}.shell{width:min(680px,100%);padding:42px;border:1px solid #29475d;border-radius:24px;background:rgba(10,22,33,.94);box-shadow:0 24px 80px #0008}.eyebrow{color:#6ed5ff;letter-spacing:.18em;font-size:12px}h1{font-size:clamp(30px,6vw,48px);line-height:1.15;margin:10px 0 28px}.panel{background:#102638;border:1px solid #24465d;border-radius:16px;padding:20px 24px}dl{display:grid;grid-template-columns:110px 1fr;gap:12px;margin:0}dt{color:#8ba4b6}dd{margin:0;font-weight:650}small{display:block;color:#7893a6;font-weight:400}.notice{color:#9eb2c0;margin:20px 0}button{width:100%;border:0;border-radius:14px;padding:16px;background:#67d4ff;color:#042235;font-weight:800;font-size:17px;cursor:pointer}@media(max-width:520px){.shell{padding:28px 22px}dl{grid-template-columns:1fr;gap:2px}dd{margin-bottom:12px}}
|
||||
</style></head><body><main class="shell">${body}</main></body></html>`;
|
||||
}
|
||||
|
||||
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 loadApprovers(file, ownerEmail) {
|
||||
if (!file) return ownerEmail ? [{ id: "sovereign-owner", email: ownerEmail, default: true, persona_ids: [], targets: ["*"], scopes: ["*"] }] : [];
|
||||
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (!parsed || !Array.isArray(parsed.approvers)) throw new Error("invalid approver registry");
|
||||
return parsed.approvers.filter(item => item && validEmail(item.email)).map(item => ({
|
||||
id: String(item.id || ""), email: item.email, default: item.default === true,
|
||||
persona_ids: Array.isArray(item.persona_ids) ? item.persona_ids.map(String) : [],
|
||||
targets: Array.isArray(item.targets) ? item.targets.map(String) : [],
|
||||
scopes: Array.isArray(item.scopes) ? item.scopes.map(String) : [],
|
||||
}));
|
||||
}
|
||||
function selectApprover(approvers, order) {
|
||||
const eligible = approvers.filter(item => matches(item.targets, order.target) && matches(item.scopes, order.scope));
|
||||
return eligible.find(item => item.persona_ids.includes(order.persona.pid)) || eligible.find(item => item.default) || null;
|
||||
}
|
||||
function matches(values, value) { return values.includes("*") || values.includes(value); }
|
||||
function validEmail(value) { return typeof value === "string" && value.length <= 254 && /^[^@\s]+@[^@\s]+$/.test(value); }
|
||||
function clientAddress(req) {
|
||||
const forwarded = String(req.headers["x-forwarded-for"] || "").split(",").map(value => value.trim()).filter(Boolean);
|
||||
return String(forwarded[forwarded.length - 1] || req.socket.remoteAddress || "unknown").slice(0, 96);
|
||||
}
|
||||
function validateWorkorderBody(body, targets, actions) {
|
||||
if (body.email || body.recipient || body.smtp_pass) return { ok: false, status: 400, error: "direct_recipient_forbidden" };
|
||||
if (!body.persona_id || !body.target || !body.scope || !body.action) return { ok: false, status: 400, error: "missing_required_field" };
|
||||
const personaId = String(body.persona_id);
|
||||
const personaName = String(body.persona_name || personaId);
|
||||
const target = String(body.target);
|
||||
const scope = String(body.scope);
|
||||
const action = String(body.action);
|
||||
const description = String(body.description || "");
|
||||
const resource = String(body.resource || "");
|
||||
const provenance = {
|
||||
system_entry: String(body.system_entry || ""),
|
||||
software: String(body.origin_software || ""),
|
||||
model: String(body.origin_model || ""),
|
||||
instance: String(body.origin_instance || ""),
|
||||
};
|
||||
if (!/^[A-Za-z0-9._:+\u221e-]{2,80}$/.test(personaId) || personaName.length > 100 || description.length > 500) return { ok: false, status: 400, error: "invalid_request_fields" };
|
||||
if (!targets.has(target)) return { ok: false, status: 400, error: "unknown_target" };
|
||||
if (!Array.isArray(actions[scope]) || !actions[scope].includes(action)) return { ok: false, status: 400, error: "unknown_or_mismatched_action" };
|
||||
if (action === "provision-approved-architecture" && !/^[A-Z0-9][A-Z0-9._-]{5,119}@[0-9a-f]{40}$/.test(resource)) return { ok: false, status: 400, error: "immutable_architecture_resource_required" };
|
||||
if (action !== "provision-approved-architecture" && resource) return { ok: false, status: 400, error: "resource_not_allowed_for_action" };
|
||||
if (Object.values(provenance).some(Boolean) && (provenance.system_entry !== "光湖语言人格系统当前实例" || Object.values(provenance).some(item => !item || item.length > 120))) return { ok: false, status: 400, error: "invalid_instance_provenance" };
|
||||
return { ok: true, request: { persona: { pid: personaId, name: personaName }, provenance, target, scope, action, allowedActions: actions[scope], description, resource } };
|
||||
}
|
||||
function originLabel(order) {
|
||||
const value = order && order.provenance || {};
|
||||
return value.software || value.model || value.instance ? `${value.software || "未知软件"} · ${value.model || "未知模型"} · ${value.instance || "当前实例"}` : "旧版工单未记录";
|
||||
}
|
||||
class SlidingWindowLimiter {
|
||||
constructor(limit, windowSeconds) { this.limit = Math.max(1, limit); this.windowMs = windowSeconds * 1000; this.events = new Map(); this.calls = 0; }
|
||||
take(key, now = Date.now()) {
|
||||
this.calls += 1;
|
||||
if (this.calls % 256 === 0) {
|
||||
for (const [storedKey, values] of this.events) {
|
||||
const active = values.filter(value => now - value < this.windowMs);
|
||||
if (active.length) this.events.set(storedKey, active); else this.events.delete(storedKey);
|
||||
}
|
||||
}
|
||||
const recent = (this.events.get(key) || []).filter(value => now - value < this.windowMs);
|
||||
if (recent.length >= this.limit) { this.events.set(key, recent); return false; }
|
||||
recent.push(now); this.events.set(key, recent); return true;
|
||||
}
|
||||
}
|
||||
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, SlidingWindowLimiter, loadApprovers, selectApprover };
|
||||
313
server-tools/lake-lamp-authz/server.test.js
Normal file
313
server-tools/lake-lamp-authz/server.test.js
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
"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, 3 * 60 * 60);
|
||||
|
||||
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("cross-device request is powerless until the owner asks for an email and approves it", async () => {
|
||||
await withServer(async ({ base, mail }) => {
|
||||
const capabilities = await (await fetch(`${base}/api/public/capabilities`)).json();
|
||||
assert.deepEqual(capabilities.required_fields, ["persona_id", "target", "scope", "action"]);
|
||||
assert.ok(capabilities.scopes["repo-push"].includes("push-repository"));
|
||||
assert.equal(capabilities.limits.create_per_network_per_hour, 24);
|
||||
assert.equal(capabilities.limits.email_per_network_per_hour, 12);
|
||||
assert.doesNotMatch(JSON.stringify(capabilities), /owner@example\.invalid/);
|
||||
const requested = await fetch(`${base}/api/public/workorders`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-forwarded-for": "198.51.100.8" },
|
||||
body: JSON.stringify({ persona_id: "ICE-GL-ZY001", persona_name: "铸渊", target: "JD-FD-PRIMARY", scope: "repo-push", action: "push-repository", description: "mobile handoff" }),
|
||||
});
|
||||
assert.equal(requested.status, 201);
|
||||
const order = await requested.json();
|
||||
assert.equal(order.status, "waiting_for_owner_handoff");
|
||||
assert.equal(mail.length, 0);
|
||||
assert.match(order.request_url, /\/request\//);
|
||||
assert.doesNotMatch(JSON.stringify(order), /approve\//);
|
||||
|
||||
const requestPath = new URL(order.request_url).pathname.replace("/authz", "");
|
||||
const handoffPage = await fetch(`${base}${requestPath}`);
|
||||
assert.equal(handoffPage.status, 200);
|
||||
assert.match(await handoffPage.text(), /无权限申请单/);
|
||||
|
||||
const prematureClaim = await fetch(`${base}/api/workorders/${order.workorder_id}/claim`, { method: "POST", headers: { authorization: `Bearer ${order.claim_token}` } });
|
||||
assert.equal(prematureClaim.status, 202);
|
||||
|
||||
const sendMail = await fetch(`${base}${requestPath}`, { method: "POST", headers: { "x-forwarded-for": "203.0.113.9" } });
|
||||
assert.equal(sendMail.status, 200);
|
||||
assert.equal(mail.length, 1);
|
||||
assert.match(await sendMail.text(), /请打开邮箱完成批准/);
|
||||
|
||||
const approvalPath = new URL(mail[0].approvalUrl).pathname.replace("/authz", "");
|
||||
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);
|
||||
assert.equal((await claimed.json()).expires_in, 3 * 60 * 60);
|
||||
});
|
||||
});
|
||||
|
||||
test("cross-device request creation is rate limited without revealing owner identity", async () => {
|
||||
await withServer(async ({ base, mail }) => {
|
||||
const payload = { persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "server-login", action: "read-navigation-map" };
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const response = await fetch(`${base}/api/public/workorders`, { method: "POST", headers: { "content-type": "application/json", "x-forwarded-for": "spoofed, 198.51.100.20" }, body: JSON.stringify(payload) });
|
||||
assert.equal(response.status, 201);
|
||||
assert.doesNotMatch(await response.text(), /owner@example\.invalid/);
|
||||
}
|
||||
const blocked = await fetch(`${base}/api/public/workorders`, { method: "POST", headers: { "content-type": "application/json", "x-forwarded-for": "different-spoof, 198.51.100.20" }, body: JSON.stringify(payload) });
|
||||
assert.equal(blocked.status, 429);
|
||||
assert.equal(mail.length, 0);
|
||||
}, { publicCreateLimit: 1, publicCreateGlobalLimit: 10 });
|
||||
});
|
||||
|
||||
test("one server-ops approval exposes the full registered three-hour operation scope", async () => {
|
||||
const app = createApp({
|
||||
requestToken: "request-only-secret",
|
||||
ownerEmail: "owner@example.invalid",
|
||||
publicBaseUrl: "https://example.invalid/authz",
|
||||
stateFile: "",
|
||||
sendEmail: async () => true,
|
||||
});
|
||||
await new Promise(resolve => app.listen(0, "127.0.0.1", resolve));
|
||||
try {
|
||||
const base = `http://127.0.0.1:${app.address().port}`;
|
||||
const capabilities = await (await fetch(`${base}/api/public/capabilities`)).json();
|
||||
assert.deepEqual(capabilities.scopes["server-ops"], [
|
||||
"read-navigation-map",
|
||||
"inspect-services",
|
||||
"pull-registered-repo",
|
||||
"deploy-registered-service",
|
||||
"restart-registered-service",
|
||||
"health-check",
|
||||
"rollback-registered-service",
|
||||
"provision-approved-architecture",
|
||||
"push-repository",
|
||||
"restore-owner-password-login",
|
||||
]);
|
||||
} finally {
|
||||
await new Promise(resolve => app.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test("mail handoff allows at least three requests per network and refresh does not consume quota", async () => {
|
||||
await withServer(async ({ base, mail }) => {
|
||||
const source = { "x-forwarded-for": "198.51.100.42" };
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const requested = await fetch(`${base}/api/public/workorders`, {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "repo-push", action: "push-repository" }),
|
||||
});
|
||||
const order = await requested.json();
|
||||
const requestPath = new URL(order.request_url).pathname.replace("/authz", "");
|
||||
assert.equal((await fetch(`${base}${requestPath}`, { method: "POST", headers: source })).status, 200);
|
||||
assert.equal((await fetch(`${base}${requestPath}`, { method: "POST", headers: source })).status, 200);
|
||||
}
|
||||
assert.equal(mail.length, 3);
|
||||
const fourth = await fetch(`${base}/api/public/workorders`, {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "repo-push", action: "push-repository" }),
|
||||
});
|
||||
const fourthOrder = await fourth.json();
|
||||
const fourthPath = new URL(fourthOrder.request_url).pathname.replace("/authz", "");
|
||||
assert.equal((await fetch(`${base}${fourthPath}`, { method: "POST", headers: source })).status, 429);
|
||||
}, { publicMailLimit: 1 });
|
||||
});
|
||||
|
||||
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("active session renewal preserves target and scope and extends the map acknowledgement", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lake-lamp-renew-"));
|
||||
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" }));
|
||||
try {
|
||||
await withServer(async ({ base, mail }) => {
|
||||
const requested = await fetch(`${base}/api/public/workorders`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "server-login", action: "read-navigation-map" }) });
|
||||
const order = await requested.json();
|
||||
await fetch(`${base}${new URL(order.request_url).pathname.replace("/authz", "")}`, { method: "POST" });
|
||||
await fetch(`${base}${new URL(mail[0].approvalUrl).pathname.replace("/authz", "")}`, { method: "POST" });
|
||||
const session = await (await fetch(`${base}/api/workorders/${order.workorder_id}/claim`, { method: "POST", headers: { authorization: `Bearer ${order.claim_token}` } })).json();
|
||||
const common = { persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "server-login" };
|
||||
const map = await (await fetch(`${base}/api/navigation-map/read`, { method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, body: JSON.stringify(common) })).json();
|
||||
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 }) });
|
||||
const expanded = await fetch(`${base}/api/session/renew`, { method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, body: JSON.stringify({ ...common, action: "health-check" }) });
|
||||
assert.equal(expanded.status, 400);
|
||||
const renewed = await fetch(`${base}/api/session/renew`, { method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, body: JSON.stringify(common) });
|
||||
assert.equal(renewed.status, 200);
|
||||
assert.equal((await renewed.json()).authority_expanded, false);
|
||||
}, { mapsDir, mapStateFile: path.join(dir, "acks.json"), sessionTtl: 100, maxSessionLifetime: 500 });
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("registered action bridge executes only after session and map checks", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lake-lamp-action-"));
|
||||
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" }));
|
||||
const calls = [];
|
||||
try {
|
||||
await withServer(async ({ base, mail }) => {
|
||||
const requested = await fetch(`${base}/api/public/workorders`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "server-login", action: "read-navigation-map" }) });
|
||||
const order = await requested.json();
|
||||
const requestPath = new URL(order.request_url).pathname.replace("/authz", "");
|
||||
await fetch(`${base}${requestPath}`, { method: "POST" });
|
||||
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", action: "inspect-services" };
|
||||
const beforeAck = await fetch(`${base}/api/actions/execute`, { method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, body: JSON.stringify(common) });
|
||||
assert.equal(beforeAck.status, 423);
|
||||
const map = await (await fetch(`${base}/api/navigation-map/read`, { method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, body: JSON.stringify(common) })).json();
|
||||
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 }) });
|
||||
const forbidden = await fetch(`${base}/api/actions/execute`, { method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, body: JSON.stringify({ ...common, cmd: "id" }) });
|
||||
assert.equal(forbidden.status, 400);
|
||||
const executed = await fetch(`${base}/api/actions/execute`, { method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, body: JSON.stringify(common) });
|
||||
assert.equal(executed.status, 200);
|
||||
assert.deepEqual(calls, [{ action: "inspect-services", target: "JD-FD-PRIMARY" }]);
|
||||
}, { mapsDir, mapStateFile: path.join(dir, "acks.json"), executeAction: async request => { calls.push(request); return { ok: true, stdout: "healthy" }; } });
|
||||
} 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("architecture provision request is bound to one immutable manifest and commit", async () => {
|
||||
const actions = { "server-ops": ["read-navigation-map", "provision-approved-architecture"] };
|
||||
const resource = `GLS-0231-JD-LAN-01-INITIAL-PROVISION-20260720@${"a".repeat(40)}`;
|
||||
await withServer(async ({ base, mail }) => {
|
||||
const missing = await fetch(`${base}/api/public/workorders`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "server-ops", action: "provision-approved-architecture" }) });
|
||||
assert.equal(missing.status, 400);
|
||||
assert.equal((await missing.json()).error, "immutable_architecture_resource_required");
|
||||
const requested = await fetch(`${base}/api/public/workorders`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "server-ops", action: "provision-approved-architecture", resource }) });
|
||||
const order = await requested.json();
|
||||
await fetch(`${base}${new URL(order.request_url).pathname.replace("/authz", "")}`, { method: "POST" });
|
||||
assert.equal(mail[0].order.resource, resource);
|
||||
const approvalPath = new URL(mail[0].approvalUrl).pathname.replace("/authz", "");
|
||||
assert.match(await (await fetch(`${base}${approvalPath}`)).text(), new RegExp(resource));
|
||||
await fetch(`${base}${approvalPath}`, { method: "POST" });
|
||||
const session = await (await fetch(`${base}/api/workorders/${order.workorder_id}/claim`, { method: "POST", headers: { authorization: `Bearer ${order.claim_token}` } })).json();
|
||||
assert.equal(session.resource, resource);
|
||||
}, { actions });
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
test("server policy selects a member mailbox by persona target and scope", async () => {
|
||||
await withServer(async ({ base, mail }) => {
|
||||
const requested = await fetch(`${base}/api/public/workorders`, {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ persona_id: "AWEN-TECH", target: "JD-FD-PRIMARY", scope: "server-ops", action: "read-navigation-map" }),
|
||||
});
|
||||
const order = await requested.json();
|
||||
const requestPath = new URL(order.request_url).pathname.replace("/authz", "");
|
||||
assert.equal((await fetch(`${base}${requestPath}`, { method: "POST" })).status, 200);
|
||||
assert.equal(mail[0].to, "awen@example.invalid");
|
||||
}, { approvers: [
|
||||
{ id: "owner", email: "owner@example.invalid", default: true, persona_ids: ["ICE-GL-ZY001"], targets: ["*"], scopes: ["*"] },
|
||||
{ id: "awen", email: "awen@example.invalid", default: false, persona_ids: ["AWEN-TECH"], targets: ["JD-FD-PRIMARY"], scopes: ["server-ops"] },
|
||||
] });
|
||||
});
|
||||
101
server-tools/lake-lamp-authz/smtp-mailer.js
Normal file
101
server-tools/lake-lamp-authz/smtp-mailer.js
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"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}`,
|
||||
`绑定资源: ${order.resource || "无"}`,
|
||||
"有效期: 批准后 1 小时,仅限这台服务器",
|
||||
"",
|
||||
"打开以下链接查看工单并确认授权:",
|
||||
approvalUrl,
|
||||
"",
|
||||
"如果不是你发起的操作,请不要点击。",
|
||||
].join("\n");
|
||||
const html = approvalUrl ? approvalEmailHtml({ approvalUrl, order }) : "";
|
||||
const boundary = `guanghu-lake-lamp-${Date.now().toString(36)}`;
|
||||
const body = html ? [
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/plain; charset="utf-8"',
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
encodeBase64(plain),
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/html; charset="utf-8"',
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
encodeBase64(html),
|
||||
`--${boundary}--`,
|
||||
].join("\r\n") : [
|
||||
'Content-Type: text/plain; charset="utf-8"',
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
encodeBase64(plain),
|
||||
].join("\r\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",
|
||||
...(html ? [`Content-Type: multipart/alternative; boundary="${boundary}"`] : []),
|
||||
"",
|
||||
body,
|
||||
".",
|
||||
"",
|
||||
].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 LOGIN\r\n"); }
|
||||
else if (stage === 2 && code === 334) { stage = 3; socket.write(`${Buffer.from(smtpUser).toString("base64")}\r\n`); }
|
||||
else if (stage === 3 && code === 334) { stage = 4; socket.write(`${Buffer.from(smtpPass).toString("base64")}\r\n`); }
|
||||
else if (stage === 4 && code === 235) { stage = 5; socket.write(`MAIL FROM:<${smtpUser}>\r\n`); }
|
||||
else if (stage === 5 && code === 250) { stage = 6; socket.write(`RCPT TO:<${to}>\r\n`); }
|
||||
else if (stage === 6 && code === 250) { stage = 7; socket.write("DATA\r\n"); }
|
||||
else if (stage === 7 && code === 354) { stage = 8; socket.write(message); }
|
||||
else if (stage === 8 && code === 250) { socket.write("QUIT\r\n"); socket.end(); done(true); }
|
||||
}
|
||||
});
|
||||
socket.on("close", () => done(false));
|
||||
});
|
||||
}
|
||||
|
||||
function approvalEmailHtml({ approvalUrl, order }) {
|
||||
const persona = escapeHtml(order.persona.name);
|
||||
const personaId = escapeHtml(order.persona.pid);
|
||||
const target = escapeHtml(order.target);
|
||||
const scope = escapeHtml(order.scope);
|
||||
const action = escapeHtml(order.action);
|
||||
const allowedActions = escapeHtml((order.allowed_actions || [order.action]).join(" · "));
|
||||
const description = escapeHtml(order.description || "未附加说明");
|
||||
const resource = escapeHtml(order.resource || "无");
|
||||
const link = escapeHtml(approvalUrl);
|
||||
return `<!doctype html><html lang="zh-CN"><body style="margin:0;background:#07111b;color:#eaf6ff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:radial-gradient(circle at top left,#163653,#07111b 58%);padding:36px 16px"><tr><td align="center"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:640px;background:#0b1c2a;border:1px solid #284a62;border-radius:24px;overflow:hidden"><tr><td style="padding:34px 38px 18px"><div style="color:#6ed8ff;font-size:12px;letter-spacing:3px;font-weight:700">LAKE LAMP · THREE-HOUR OPS SESSION</div><h1 style="margin:12px 0 8px;font-size:32px;line-height:1.25;color:#f3f9fd">一盏小湖灯,正在等你确认</h1><p style="margin:0;color:#9eb7c8;line-height:1.8">铸渊申请打开三小时受限运维会话。请核对服务器、能力范围和绑定资源,再决定是否开门。</p></td></tr><tr><td style="padding:16px 38px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#10283a;border:1px solid #29506a;border-radius:16px"><tr><td style="padding:22px 24px"><div style="font-size:20px;font-weight:750;color:#ffffff">${persona}</div><div style="color:#7899ad;font-size:13px;margin-top:2px">${personaId}</div><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:20px;font-size:15px;line-height:2"><tr><td style="color:#82a0b3;width:96px">目标节点</td><td style="color:#e6f3fa;font-weight:650">${target}</td></tr><tr><td style="color:#82a0b3">授权范围</td><td style="color:#e6f3fa;font-weight:650">${scope}</td></tr><tr><td style="color:#82a0b3">进入动作</td><td style="color:#e6f3fa;font-weight:650">${action}</td></tr><tr><td style="color:#82a0b3;vertical-align:top">绑定资源</td><td style="color:#e6f3fa;word-break:break-all">${resource}</td></tr><tr><td style="color:#82a0b3;vertical-align:top">会话能力</td><td style="color:#e6f3fa">${allowedActions}</td></tr><tr><td style="color:#82a0b3;vertical-align:top">说明</td><td style="color:#e6f3fa">${description}</td></tr></table></td></tr></table></td></tr><tr><td style="padding:8px 38px 34px"><a href="${link}" style="display:block;text-align:center;background:#69d7ff;color:#052235;text-decoration:none;font-size:17px;font-weight:800;padding:16px 20px;border-radius:14px">打开三小时受限运维会话</a><p style="margin:18px 0 0;color:#8da7b8;font-size:13px;line-height:1.8">人格体持续执行上方已登记且绑定不变的任务时,会话会自动续期,最长不超过二十四小时。切换服务器、扩大范围、改变绑定资源或停止活动后过期才需重新授权。若这不是你发起的操作,请忽略本邮件。</p><p style="margin:14px 0 0;color:#587487;font-size:12px;word-break:break-all">按钮无法打开时:${link}</p></td></tr></table><p style="margin:18px 0 0;color:#587487;font-size:12px">光湖 · 小湖灯安全协议系统</p></td></tr></table></body></html>`;
|
||||
}
|
||||
|
||||
function encodeBase64(value) { return Buffer.from(value).toString("base64").replace(/(.{76})/g, "$1\r\n"); }
|
||||
function escapeHtml(value) { return String(value).replace(/[&<>"']/g, char => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]); }
|
||||
|
||||
module.exports = { sendSmtpMail };
|
||||
245
server-tools/lake-lamp-authz/workorder-manager.js
Normal file
245
server-tools/lake-lamp-authz/workorder-manager.js
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
class WorkOrderManager {
|
||||
constructor({ approvalTtl = 3 * 60 * 60, sessionTtl = 3 * 60 * 60, maxSessionLifetime = 24 * 60 * 60, stateFile = "" } = {}) {
|
||||
this.approvalTtl = approvalTtl;
|
||||
this.sessionTtl = sessionTtl;
|
||||
this.maxSessionLifetime = maxSessionLifetime;
|
||||
this.stateFile = stateFile;
|
||||
this.workorders = new Map();
|
||||
this.sessions = new Map();
|
||||
this.load();
|
||||
}
|
||||
|
||||
request({ persona, provenance = {}, target, scope, action, allowedActions = [action], description = "", resource = "" }, now = Date.now() / 1000) {
|
||||
const id = crypto.randomUUID();
|
||||
const handoffToken = randomToken();
|
||||
const claimToken = randomToken();
|
||||
this.workorders.set(id, {
|
||||
id,
|
||||
persona: { pid: persona.pid, name: persona.name || persona.pid },
|
||||
provenance: {
|
||||
system_entry: String(provenance.system_entry || "").slice(0, 120),
|
||||
software: String(provenance.software || "").slice(0, 120),
|
||||
model: String(provenance.model || "").slice(0, 120),
|
||||
instance: String(provenance.instance || "").slice(0, 120),
|
||||
},
|
||||
target,
|
||||
scope,
|
||||
action,
|
||||
allowedActions: [...new Set(allowedActions)],
|
||||
description: String(description).slice(0, 500),
|
||||
resource: String(resource).slice(0, 180),
|
||||
createdAt: now,
|
||||
expiresAt: now + this.approvalTtl,
|
||||
handoffHash: hash(handoffToken),
|
||||
approvalHash: "",
|
||||
claimHash: hash(claimToken),
|
||||
emailSentAt: 0,
|
||||
state: "pending",
|
||||
claimed: false,
|
||||
});
|
||||
this.persist();
|
||||
return { id, handoffToken, claimToken, expiresIn: this.approvalTtl };
|
||||
}
|
||||
|
||||
inspectHandoff(handoffToken, now = Date.now() / 1000) {
|
||||
const order = this.findByHandoff(handoffToken);
|
||||
if (!order) return { ok: false, reason: "request_not_found" };
|
||||
if (now > order.expiresAt) return { ok: false, reason: "request_expired" };
|
||||
if (order.state !== "pending") return { ok: false, reason: "request_already_closed" };
|
||||
return { ok: true, order: publicOrder(order) };
|
||||
}
|
||||
|
||||
issueApproval(handoffToken, now = Date.now() / 1000) {
|
||||
const inspected = this.inspectHandoff(handoffToken, now);
|
||||
if (!inspected.ok) return inspected;
|
||||
const order = this.workorders.get(inspected.order.id);
|
||||
if (order.emailSentAt) return { ok: false, reason: "approval_email_already_sent", order: publicOrder(order) };
|
||||
const approvalToken = randomToken();
|
||||
order.approvalHash = hash(approvalToken);
|
||||
order.emailSentAt = now;
|
||||
this.persist();
|
||||
return { ok: true, approvalToken, order: publicOrder(order) };
|
||||
}
|
||||
|
||||
failApprovalEmail(handoffToken) {
|
||||
const order = this.findByHandoff(handoffToken);
|
||||
if (!order || order.state !== "pending") return false;
|
||||
order.approvalHash = "";
|
||||
order.emailSentAt = 0;
|
||||
this.persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
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 = "";
|
||||
order.handoffHash = "";
|
||||
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],
|
||||
resource: order.resource || "",
|
||||
createdAt: now,
|
||||
expiresAt: now + this.sessionTtl,
|
||||
maxExpiresAt: now + this.maxSessionLifetime,
|
||||
renewals: 0,
|
||||
});
|
||||
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, resource: order.resource || "" };
|
||||
}
|
||||
|
||||
verifySession(sessionToken, persona, target, scope, action, now = Date.now() / 1000, resource = undefined) {
|
||||
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" };
|
||||
if (resource !== undefined && String(session.resource || "") !== String(resource || "")) return { ok: false, reason: "resource_mismatch" };
|
||||
const maxExpiresAt = session.maxExpiresAt || session.createdAt + this.maxSessionLifetime;
|
||||
const activityExpiresAt = Math.min(now + this.sessionTtl, maxExpiresAt);
|
||||
const renewalWindow = Math.max(60, Math.floor(this.sessionTtl * 2 / 3));
|
||||
let autoRenewed = false;
|
||||
if (now + renewalWindow >= session.expiresAt && activityExpiresAt > session.expiresAt) {
|
||||
session.expiresAt = activityExpiresAt;
|
||||
session.lastActivityAt = now;
|
||||
session.renewals = Number(session.renewals || 0) + 1;
|
||||
autoRenewed = true;
|
||||
this.persist();
|
||||
}
|
||||
return { ok: true, autoRenewed, session: { ...session } };
|
||||
}
|
||||
|
||||
renewSession(sessionToken, persona, target, scope, 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) 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" };
|
||||
const maxExpiresAt = session.maxExpiresAt || session.createdAt + this.maxSessionLifetime;
|
||||
const expiresAt = Math.min(now + this.sessionTtl, maxExpiresAt);
|
||||
if (expiresAt <= session.expiresAt) return { ok: false, reason: "session_lifetime_limit" };
|
||||
session.expiresAt = expiresAt;
|
||||
session.renewals = Number(session.renewals || 0) + 1;
|
||||
this.persist();
|
||||
return { ok: true, expiresAt, renewals: session.renewals, 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;
|
||||
}
|
||||
|
||||
findByHandoff(token) {
|
||||
const needle = hash(token || "");
|
||||
for (const order of this.workorders.values()) {
|
||||
if (order.handoffHash && safeEqual(order.handoffHash, 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 },
|
||||
provenance: { ...(order.provenance || {}) },
|
||||
target: order.target,
|
||||
scope: order.scope,
|
||||
action: order.action,
|
||||
allowed_actions: [...(order.allowedActions || [order.action])],
|
||||
description: order.description,
|
||||
resource: order.resource || "",
|
||||
createdAt: order.createdAt,
|
||||
expiresAt: order.expiresAt,
|
||||
state: order.state,
|
||||
approval_email_sent: Boolean(order.emailSentAt),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { WorkOrderManager };
|
||||
158
server-tools/lake-lamp-authz/workorder-manager.test.js
Normal file
158
server-tools/lake-lamp-authz/workorder-manager.test.js
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"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("defaults keep approval links and sessions alive for three hours", () => {
|
||||
const manager = new WorkOrderManager();
|
||||
assert.equal(manager.approvalTtl, 3 * 60 * 60);
|
||||
assert.equal(manager.sessionTtl, 3 * 60 * 60);
|
||||
assert.equal(manager.maxSessionLifetime, 24 * 60 * 60);
|
||||
});
|
||||
|
||||
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.inspectHandoff(created.handoffToken, 101).ok, true);
|
||||
const issued = manager.issueApproval(created.handoffToken, 101);
|
||||
assert.equal(issued.ok, true);
|
||||
assert.equal(manager.inspectApproval(issued.approvalToken, 101).ok, true);
|
||||
assert.equal(manager.approve(issued.approvalToken, 102).ok, true);
|
||||
assert.equal(manager.approve(issued.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);
|
||||
const issued = manager.issueApproval(created.handoffToken, 101);
|
||||
manager.approve(issued.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);
|
||||
const issued = manager.issueApproval(created.handoffToken, 101);
|
||||
manager.approve(issued.approvalToken, 101);
|
||||
const claimed = manager.claim(created.id, created.claimToken, 102);
|
||||
const state = fs.readFileSync(stateFile, "utf8");
|
||||
for (const secret of [created.handoffToken, issued.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.issueApproval(created.handoffToken, 111).reason, "request_expired");
|
||||
|
||||
const fresh = manager.request({ persona, target: "JD-FD-PRIMARY", scope: "server-login", action: "read-navigation-map" }, 200);
|
||||
const issued = manager.issueApproval(fresh.handoffToken, 201);
|
||||
manager.approve(issued.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");
|
||||
});
|
||||
|
||||
test("an active session renews without changing target scope or actions", () => {
|
||||
const manager = new WorkOrderManager({ sessionTtl: 100, maxSessionLifetime: 500 });
|
||||
const created = manager.request({ persona, target: "BS-SG-001", scope: "server-ops", action: "read-navigation-map", allowedActions: ["read-navigation-map", "health-check"] }, 100);
|
||||
const issued = manager.issueApproval(created.handoffToken, 101);
|
||||
manager.approve(issued.approvalToken, 101);
|
||||
const claimed = manager.claim(created.id, created.claimToken, 102);
|
||||
const renewed = manager.renewSession(claimed.sessionToken, persona, "BS-SG-001", "server-ops", 150);
|
||||
assert.equal(renewed.ok, true);
|
||||
assert.equal(renewed.expiresAt, 250);
|
||||
assert.equal(manager.renewSession(claimed.sessionToken, persona, "BS-SG-002", "server-ops", 160).reason, "target_mismatch");
|
||||
assert.equal(manager.verifySession(claimed.sessionToken, persona, "BS-SG-001", "server-ops", "health-check", 249).ok, true);
|
||||
});
|
||||
|
||||
test("verified bound activity automatically renews without expanding authority", () => {
|
||||
const resource = `HLCC-STAGE@${"d".repeat(40)}`;
|
||||
const manager = new WorkOrderManager({ sessionTtl: 100, maxSessionLifetime: 500 });
|
||||
const created = manager.request({
|
||||
persona,
|
||||
target: "JD-FD-PRIMARY",
|
||||
scope: "server-ops",
|
||||
action: "provision-approved-architecture",
|
||||
resource,
|
||||
}, 100);
|
||||
const issued = manager.issueApproval(created.handoffToken, 101);
|
||||
manager.approve(issued.approvalToken, 101);
|
||||
const claimed = manager.claim(created.id, created.claimToken, 102);
|
||||
|
||||
const active = manager.verifySession(
|
||||
claimed.sessionToken,
|
||||
persona,
|
||||
"JD-FD-PRIMARY",
|
||||
"server-ops",
|
||||
"provision-approved-architecture",
|
||||
150,
|
||||
resource,
|
||||
);
|
||||
assert.equal(active.ok, true);
|
||||
assert.equal(active.autoRenewed, true);
|
||||
assert.equal(active.session.expiresAt, 250);
|
||||
assert.equal(manager.verifySession(
|
||||
claimed.sessionToken,
|
||||
persona,
|
||||
"JD-FD-PRIMARY",
|
||||
"server-ops",
|
||||
"provision-approved-architecture",
|
||||
160,
|
||||
`OTHER@${"e".repeat(40)}`,
|
||||
).reason, "resource_mismatch");
|
||||
});
|
||||
|
||||
test("public handoff can send one approval email and can recover from mail failure", () => {
|
||||
const manager = new WorkOrderManager({ approvalTtl: 60 });
|
||||
const created = manager.request({ persona, target: "JD-FD-PRIMARY", scope: "repo-push", action: "push-repository" }, 100);
|
||||
const first = manager.issueApproval(created.handoffToken, 101);
|
||||
assert.equal(first.ok, true);
|
||||
assert.equal(manager.issueApproval(created.handoffToken, 102).reason, "approval_email_already_sent");
|
||||
assert.equal(manager.failApprovalEmail(created.handoffToken), true);
|
||||
const retry = manager.issueApproval(created.handoffToken, 103);
|
||||
assert.equal(retry.ok, true);
|
||||
assert.notEqual(retry.approvalToken, first.approvalToken);
|
||||
assert.equal(manager.inspectApproval(first.approvalToken, 104).ok, false);
|
||||
assert.equal(manager.inspectApproval(retry.approvalToken, 104).ok, true);
|
||||
});
|
||||
|
||||
test("session cannot switch an approved architecture resource", () => {
|
||||
const resource = `GLS-0231-JD-LAN-01-INITIAL-PROVISION-20260720@${"b".repeat(40)}`;
|
||||
const manager = new WorkOrderManager({ sessionTtl: 3600 });
|
||||
const created = manager.request({ persona, target: "JD-FD-PRIMARY", scope: "server-ops", action: "provision-approved-architecture", resource }, 100);
|
||||
const issued = manager.issueApproval(created.handoffToken, 101);
|
||||
manager.approve(issued.approvalToken, 101);
|
||||
const claimed = manager.claim(created.id, created.claimToken, 102);
|
||||
assert.equal(claimed.resource, resource);
|
||||
assert.equal(manager.verifySession(claimed.sessionToken, persona, "JD-FD-PRIMARY", "server-ops", "provision-approved-architecture", 103, resource).ok, true);
|
||||
assert.equal(manager.verifySession(claimed.sessionToken, persona, "JD-FD-PRIMARY", "server-ops", "provision-approved-architecture", 103, `OTHER@${"c".repeat(40)}`).reason, "resource_mismatch");
|
||||
});
|
||||
Loading…
Reference in a new issue