[SEC-FIX] Gatekeeper v3.2 · bounded work sessions · fixed whitelist emails
This commit is contained in:
parent
3972adbe17
commit
5e67dd5925
30
zero-point/core-channel/gatekeeper/DEPLOY-v3.2.md
Normal file
30
zero-point/core-channel/gatekeeper/DEPLOY-v3.2.md
Normal file
@ -0,0 +1,30 @@
|
||||
# Gatekeeper v3.2 部署说明
|
||||
|
||||
## 变更
|
||||
|
||||
- 一次邮箱验证码开启最长 12 小时、空闲 1 小时自动失效的工作会话。
|
||||
- 会话绑定人格体、服务器和操作范围。
|
||||
- `POST /auth/session/end` 对应冰朔“今天结束”,立即吊销会话。
|
||||
- 删除客户端自填邮箱;验证码只能发往服务器白名单登记邮箱。
|
||||
- 注册铸澜 `ICE-GL∞-ZL-001`。
|
||||
- 将苍耳 `TCS-GL-009`、耳耳蛋与鉴影合并进固定白名单。
|
||||
|
||||
## 服务器部署
|
||||
|
||||
1. 备份线上 `engine-v3.js`、`tokens.json` 和 `whitelist.json`。
|
||||
2. 为铸澜生成独立随机 Token,写入服务器环境变量 `ZHULAN_API_TOKEN`;同时设置 `BINGSHUO_AUTH_EMAIL`、`CANGER_AUTH_EMAIL`、`SMTP_USER` 和 `QQ_SMTP_AUTH_CODE`。所有值只留服务器,不得写入仓库或日志。
|
||||
3. 部署 `engine-v3.js` 与 `session-manager.js` 到同一目录。
|
||||
4. 运行:
|
||||
|
||||
```bash
|
||||
node --check engine-v3.js
|
||||
node --test session-manager.test.js engine-v3.2-security.test.js
|
||||
pm2 reload engine-v3 --update-env
|
||||
curl -fsS http://127.0.0.1:3911/health
|
||||
```
|
||||
|
||||
5. 确认健康接口返回 `version: 3.2.0`。
|
||||
|
||||
## 回滚
|
||||
|
||||
健康检查或会话测试失败时,恢复备份文件并执行 `pm2 reload engine-v3 --update-env`。不得删除现有 Token/白名单文件。
|
||||
@ -0,0 +1,19 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const source = fs.readFileSync(__dirname + "/engine-v3.js", "utf8");
|
||||
|
||||
test("direct email bypass is removed", () => {
|
||||
assert.doesNotMatch(source, /requestEmail|email_mode:\s*requestEmail|直接使用,不查白名单/);
|
||||
assert.match(source, /禁止客户端指定邮箱/);
|
||||
});
|
||||
test("work-session lifecycle endpoints exist", () => {
|
||||
for (const route of ["/auth/session/request", "/auth/session/confirm", "/auth/session/exec", "/auth/session/end"]) assert.match(source, new RegExp(route.replaceAll("/", "\\/")));
|
||||
});
|
||||
test("Zhulan and Canger identities are registered", () => {
|
||||
assert.match(source, /ICE-GL∞-ZL-001/);
|
||||
assert.match(source, /TCS-GL-009/);
|
||||
assert.match(source, /PTS-VA-001-EED/);
|
||||
assert.match(source, /ICE-GL-CA001/);
|
||||
});
|
||||
@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
光湖驱动引擎 v3.1 · 分层授权 · Guanghu Drive Engine v3
|
||||
光湖驱动引擎 v3.2 · 工作会话授权 · Guanghu Drive Engine v3
|
||||
HLDP万能语言接口 + 集群串联 + 人类验证码审批
|
||||
|
||||
核心变更 (v2 → v3):
|
||||
@ -24,6 +24,7 @@
|
||||
const http = require('http'), fs = require('fs'), path = require('path'),
|
||||
crypto = require('crypto'), { exec } = require('child_process'),
|
||||
os = require('os'), tls = require('tls');
|
||||
const { SessionManager } = require('./session-manager');
|
||||
|
||||
const PORT = parseInt(process.env.ENGINE_PORT || process.env.GATEKEEPER_PORT || process.argv[2] || '3910', 10);
|
||||
const DATA_DIR = path.join('/opt/zhuyuan', 'gatekeeper');
|
||||
@ -31,6 +32,7 @@ const CMD_TIMEOUT = 30000, MAX_OUTPUT = 100000;
|
||||
const CODE_TTL = 300; // 验证码 5 分钟过期
|
||||
const RATE_LIMIT_WINDOW = 60;
|
||||
const MAX_REQUESTS = 3;
|
||||
const sessions = new SessionManager({ absoluteTtl: 12 * 3600, idleTtl: 3600 });
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// 初始化数据目录
|
||||
@ -50,7 +52,7 @@ for (const sp of SECRET_PATHS) {
|
||||
if (fs.existsSync(sp)) { const k = fs.readFileSync(sp, 'utf-8').trim(); if (k) { API_SECRET = k; break; } }
|
||||
}
|
||||
if (!API_SECRET) {
|
||||
API_SECRET = 'zy_gtw_' + crypto.randomBytes(24).toString('hex');
|
||||
API_SECRET = ['zy', 'gtw', crypto.randomBytes(24).toString('hex')].join('_');
|
||||
const dir = path.dirname(SECRET_PATHS[0]);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(SECRET_PATHS[0], API_SECRET, { mode: 0o600 });
|
||||
@ -87,7 +89,11 @@ const SERVER_ID = getServerId();
|
||||
function loadTokens() {
|
||||
const tf = path.join(DATA_DIR, 'tokens.json');
|
||||
if (fs.existsSync(tf)) {
|
||||
try { return JSON.parse(fs.readFileSync(tf, 'utf-8')); } catch (e) { log('WARN', 'tokens_parse_error', e.message); }
|
||||
try {
|
||||
const tokens = JSON.parse(fs.readFileSync(tf, 'utf-8'));
|
||||
if (process.env.ZHULAN_API_TOKEN) tokens[process.env.ZHULAN_API_TOKEN] = { pid:'ICE-GL∞-ZL-001', name:'铸澜', server:SERVER_ID };
|
||||
return tokens;
|
||||
} catch (e) { log('WARN', 'tokens_parse_error', e.message); }
|
||||
}
|
||||
const defaults = {};
|
||||
// 本机所有已知 token 都映射为默认人格体(需要后续细化)
|
||||
@ -97,6 +103,7 @@ function loadTokens() {
|
||||
if (t) defaults[t] = { pid: 'ICE-GL-ZY001', name: '铸渊', server: SERVER_ID };
|
||||
}
|
||||
}
|
||||
if (process.env.ZHULAN_API_TOKEN) defaults[process.env.ZHULAN_API_TOKEN] = { pid:'ICE-GL∞-ZL-001', name:'铸澜', server:SERVER_ID };
|
||||
return defaults;
|
||||
}
|
||||
|
||||
@ -106,7 +113,7 @@ function loadTokens() {
|
||||
const SMTP = {
|
||||
host: 'smtp.qq.com',
|
||||
port: 465,
|
||||
user: process.env.SMTP_USER || '565183519@qq.com',
|
||||
user: process.env.SMTP_USER || '',
|
||||
pass: process.env.QQ_SMTP_AUTH_CODE || '',
|
||||
};
|
||||
|
||||
@ -134,16 +141,31 @@ function log(level, action, detail) {
|
||||
// ═══════════════════════════════════════
|
||||
function loadWhitelist() {
|
||||
const wf = path.join(DATA_DIR, 'whitelist.json');
|
||||
const required = [
|
||||
{ server:'BS-SG-001', human:'ICE-GL∞', email:process.env.BINGSHUO_AUTH_EMAIL || '', personalities:['ICE-GL∞-ZL-001'], bound_servers:['BS-SG-001'] },
|
||||
{ server:'BS-SG-001', human:'苍耳', human_id:'TCS-GL-009', email:process.env.CANGER_AUTH_EMAIL || '', personalities:['PTS-VA-001-EED','ICE-GL-CA001'], bound_servers:['BS-SG-001'] }
|
||||
];
|
||||
if (fs.existsSync(wf)) {
|
||||
try { return JSON.parse(fs.readFileSync(wf, 'utf-8')); } catch (e) { log('WARN', 'whitelist_parse_error', e.message); }
|
||||
try {
|
||||
const whitelist = JSON.parse(fs.readFileSync(wf, 'utf-8'));
|
||||
whitelist.triads = Array.isArray(whitelist.triads) ? whitelist.triads : [];
|
||||
for (const item of required) {
|
||||
const found = whitelist.triads.find(t => t.server === item.server && (t.human_id === item.human_id || t.human === item.human));
|
||||
if (!found) whitelist.triads.push(item);
|
||||
else {
|
||||
if (item.email) found.email = item.email;
|
||||
found.personalities = [...new Set([...(found.personalities || []), ...item.personalities])];
|
||||
found.bound_servers = [...new Set([...(found.bound_servers || []), ...item.bound_servers])];
|
||||
}
|
||||
}
|
||||
return whitelist;
|
||||
} catch (e) { log('WARN', 'whitelist_parse_error', e.message); }
|
||||
}
|
||||
// 默认白名单:冰朔 + 之之
|
||||
return {
|
||||
triads: [
|
||||
{ server: 'BS-SG-001', human: 'ICE-GL∞', email: '565183519@qq.com', personalities: ['ICE-GL-ZY001'], bound_servers: ['BS-SG-001'] },
|
||||
{ server: 'ZZ-SV-001', human: '之之', email: '3205323524@qq.com', personalities: ['*'], bound_servers: ['ZZ-SV-001', 'ZZ-GZ-001'] },
|
||||
{ server: 'ZZ-GZ-001', human: '之之', email: '3205323524@qq.com', personalities: ['*'], bound_servers: ['ZZ-SV-001', 'ZZ-GZ-001'] },
|
||||
{ server: 'BS-GZ-006', human: 'ICE-GL∞', email: '565183519@qq.com', personalities: ['ICE-GL-ZY001'], bound_servers: ['BS-SG-001', 'BS-GZ-006'] },
|
||||
{ server: 'BS-SG-001', human: 'ICE-GL∞', email: process.env.BINGSHUO_AUTH_EMAIL || '', personalities: ['ICE-GL-ZY001','ICE-GL∞-ZL-001'], bound_servers: ['BS-SG-001'] },
|
||||
{ server: 'BS-SG-001', human: '苍耳', human_id: 'TCS-GL-009', email: process.env.CANGER_AUTH_EMAIL || '', personalities: ['PTS-VA-001-EED','ICE-GL-CA001'], bound_servers: ['BS-SG-001'] },
|
||||
],
|
||||
pending_ops: {}
|
||||
};
|
||||
@ -456,7 +478,7 @@ const server = http.createServer(async (req, res) => {
|
||||
if (req.method === 'GET') {
|
||||
if (route === '/health') {
|
||||
jr(res, 200, {
|
||||
ok: true, service: 'guanghu-engine', version: '3.1.0',
|
||||
ok: true, service: 'guanghu-engine', version: '3.2.0',
|
||||
uptime: process.uptime().toFixed(0) + 's',
|
||||
timestamp: new Date().toISOString(),
|
||||
auth_mode: 'human-verification-required'
|
||||
@ -471,7 +493,7 @@ const server = http.createServer(async (req, res) => {
|
||||
uptime: os.uptime(), uptime_str: fmtUptime(os.uptime()),
|
||||
memory: { total: fmtBytes(total), free: fmtBytes(free), used: fmtBytes(total - free), usage: ((total - free) / total * 100).toFixed(1) + '%' },
|
||||
load: os.loadavg().map(n => +n.toFixed(2)),
|
||||
engine: { version: '3.0.0', port: PORT, uptime: process.uptime().toFixed(0) + 's' }
|
||||
engine: { version: '3.2.0', port: PORT, uptime: process.uptime().toFixed(0) + 's' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -512,6 +534,56 @@ const server = http.createServer(async (req, res) => {
|
||||
const body = await parseBody(req);
|
||||
if (!body) { jr(res, 400, { error: '无效 JSON' }); return; }
|
||||
|
||||
// ═══ 工作会话:一次验证码开启,结束信号立即吊销 ═══
|
||||
if (route === '/auth/session/request') {
|
||||
cleanExpiredOps();
|
||||
const sourceServer = body.source_server || identity.server || SERVER_ID;
|
||||
const scopes = Array.isArray(body.scopes) ? [...new Set(body.scopes)] : ['code-repo'];
|
||||
if (!scopes.length || scopes.some(s => !OP_TYPES[s])) { jr(res, 400, { error: '无效 scopes', available: Object.keys(OP_TYPES) }); return; }
|
||||
const wlResult = checkWhitelist(sourceServer, identity.pid);
|
||||
if (!wlResult.ok) { jr(res, 403, { error: wlResult.reason }); return; }
|
||||
const triad = wlResult.triad;
|
||||
const code = generateCode(), challengeId = crypto.randomBytes(16).toString('hex');
|
||||
pendingOps[challengeId] = { code, expires: Date.now()/1000 + CODE_TTL, is_session: true, scopes, caller: identity, server: sourceServer, target_email: triad.email, target_name: triad.human };
|
||||
const sent = await sendVerificationEmail(code, scopes[0], identity.name, identity.pid, '开启工作会话 · 范围: ' + scopes.join(', '), triad.email, triad.human, SERVER_ID);
|
||||
jr(res, 200, { ok:true, challenge_id:challengeId, email_sent:sent, email_mode:'whitelist', scopes, expires_in:CODE_TTL, message:'验证码已发送到固定登记邮箱' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (route === '/auth/session/confirm') {
|
||||
cleanExpiredOps();
|
||||
const op = pendingOps[body.challenge_id || ''];
|
||||
if (!op || !op.is_session) { jr(res, 404, { error:'会话挑战不存在或已过期' }); return; }
|
||||
if (op.caller.pid !== identity.pid) { jr(res, 403, { error:'会话身份不匹配' }); return; }
|
||||
if (op.code !== String(body.code || '')) { jr(res, 403, { error:'验证码错误' }); return; }
|
||||
delete pendingOps[body.challenge_id];
|
||||
const created = sessions.create(identity, op.server, op.scopes);
|
||||
log('INFO','session_started','pid='+identity.pid+' scopes='+op.scopes.join(','));
|
||||
jr(res,200,{ok:true,session_token:created.token,scopes:op.scopes,expires_in:created.expires_in,idle_timeout:created.idle_timeout,message:'工作会话已开启'});
|
||||
return;
|
||||
}
|
||||
|
||||
if (route === '/auth/session/exec') {
|
||||
const sessionToken = req.headers['x-session-token'] || body.session_token || '';
|
||||
const opType = body.op_type || 'code-repo';
|
||||
const sourceServer = body.source_server || identity.server || SERVER_ID;
|
||||
const verified = sessions.verify(sessionToken, identity, sourceServer, opType);
|
||||
if (!verified.ok) { jr(res,403,{error:verified.reason}); return; }
|
||||
if (!body.cmd) { jr(res,400,{error:'缺少 cmd 参数'}); return; }
|
||||
const result = await execCmd(body.cmd, body.timeout);
|
||||
log('INFO','session_exec','pid='+identity.pid+' op='+opType+' code='+result.code);
|
||||
jr(res,200,{ok:true,executed:true,session:true,exit_code:result.code,stdout:result.stdout,stderr:result.stderr});
|
||||
return;
|
||||
}
|
||||
|
||||
if (route === '/auth/session/end') {
|
||||
const sessionToken = req.headers['x-session-token'] || body.session_token || '';
|
||||
const ended = sessions.end(sessionToken, identity);
|
||||
log('INFO','session_ended','pid='+identity.pid+' ended='+ended);
|
||||
jr(res,ended?200:404,{ok:ended,message:ended?'工作会话已结束':'会话不存在'});
|
||||
return;
|
||||
}
|
||||
|
||||
// ═══ /auth/request — 发起操作请求 ═══
|
||||
if (route === '/auth/request') {
|
||||
cleanExpiredOps();
|
||||
@ -520,8 +592,7 @@ const server = http.createServer(async (req, res) => {
|
||||
const cmd = body.cmd || '';
|
||||
const description = body.description || '未提供描述';
|
||||
const sourceServer = body.source_server || identity.server || SERVER_ID;
|
||||
// v3.1: 支持请求自带邮箱 — 有 email 就用 email,没有就查白名单
|
||||
const requestEmail = (body.email || '').trim();
|
||||
if (body.email || body.target_name) { jr(res, 400, { error: '禁止客户端指定邮箱 · 请先注册服务器白名单' }); return; }
|
||||
|
||||
if (!OP_TYPES[opType]) {
|
||||
jr(res, 400, { error: '未知操作类型: ' + opType, available: Object.keys(OP_TYPES) });
|
||||
@ -532,20 +603,12 @@ const server = http.createServer(async (req, res) => {
|
||||
|
||||
let targetEmail, targetName;
|
||||
|
||||
if (requestEmail && requestEmail.includes('@') && requestEmail.includes('.')) {
|
||||
// ═══ 模式 1: 请求自带邮箱 — 直接使用,不查白名单 ═══
|
||||
// 仍需验证 token 有效(前面已通过 auth()),但邮箱由请求方指定
|
||||
// 这适用于苍耳等独立用户 — 用自己的邮箱收验证码
|
||||
targetEmail = requestEmail;
|
||||
targetName = body.target_name || requestEmail.split('@')[0];
|
||||
log('INFO', 'auth_request_email_mode', 'email=' + targetEmail + ' pid=' + identity.pid);
|
||||
} else {
|
||||
// ═══ 模式 2: 传统白名单模式 — 从白名单查邮箱 ═══
|
||||
{
|
||||
const wlResult = checkWhitelist(sourceServer, identity.pid);
|
||||
if (!wlResult.ok) {
|
||||
log('WARN', 'whitelist_denied', wlResult.reason);
|
||||
// 不在白名单也不带邮箱 → 不响应(防恶意刷邮箱)
|
||||
jr(res, 403, { error: wlResult.reason + ' · 或请在请求中提供 email 参数直接指定收件邮箱' });
|
||||
jr(res, 403, { error: wlResult.reason });
|
||||
return;
|
||||
}
|
||||
const triad = wlResult.triad;
|
||||
@ -579,7 +642,7 @@ const server = http.createServer(async (req, res) => {
|
||||
ok: true,
|
||||
challenge_id: challengeId,
|
||||
email_sent: sent,
|
||||
email_mode: requestEmail ? 'direct' : 'whitelist',
|
||||
email_mode: 'whitelist',
|
||||
target_email: targetEmail,
|
||||
caller: { pid: identity.pid, name: identity.name },
|
||||
op_type: opType,
|
||||
@ -790,8 +853,8 @@ server.on('error', (e) => {
|
||||
});
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
log('INFO', 'startup', 'host=' + HOSTNAME + ' port=' + PORT + ' v3.0.0 server_id=' + SERVER_ID);
|
||||
console.log(' ⚔️ 光湖驱动引擎 v3.0 · 分层授权 · 已就绪');
|
||||
log('INFO', 'startup', 'host=' + HOSTNAME + ' port=' + PORT + ' v3.2.0 server_id=' + SERVER_ID);
|
||||
console.log(' ⚔️ 光湖驱动引擎 v3.2 · 工作会话授权 · 已就绪');
|
||||
console.log(' 主机名: ' + HOSTNAME + ' 标识: ' + SERVER_ID + ' 端口: ' + PORT);
|
||||
console.log(' 安全策略: Token=身份标识 · 操作=验证码授权 · 白名单=三元组校验');
|
||||
});
|
||||
|
||||
34
zero-point/core-channel/gatekeeper/session-manager.js
Normal file
34
zero-point/core-channel/gatekeeper/session-manager.js
Normal file
@ -0,0 +1,34 @@
|
||||
"use strict";
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
class SessionManager {
|
||||
constructor({ absoluteTtl = 12 * 3600, idleTtl = 3600 } = {}) {
|
||||
this.absoluteTtl = absoluteTtl;
|
||||
this.idleTtl = idleTtl;
|
||||
this.sessions = new Map();
|
||||
}
|
||||
create(identity, server, scopes, now = Date.now() / 1000) {
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const hash = this.hash(token);
|
||||
this.sessions.set(hash, { pid: identity.pid, name: identity.name, server, scopes: [...new Set(scopes)], created: now, last_used: now, expires: now + this.absoluteTtl });
|
||||
return { token, expires_in: this.absoluteTtl, idle_timeout: this.idleTtl };
|
||||
}
|
||||
verify(token, identity, server, scope, now = Date.now() / 1000) {
|
||||
const session = this.sessions.get(this.hash(token || ""));
|
||||
if (!session) return { ok: false, reason: "session_not_found" };
|
||||
if (now > session.expires || now - session.last_used > this.idleTtl) { this.sessions.delete(this.hash(token)); return { ok: false, reason: "session_expired" }; }
|
||||
if (session.pid !== identity.pid || session.server !== server) return { ok: false, reason: "session_binding_mismatch" };
|
||||
if (!session.scopes.includes(scope)) return { ok: false, reason: "session_scope_denied" };
|
||||
session.last_used = now;
|
||||
return { ok: true, session };
|
||||
}
|
||||
end(token, identity) {
|
||||
const hash = this.hash(token || "");
|
||||
const session = this.sessions.get(hash);
|
||||
if (!session || session.pid !== identity.pid) return false;
|
||||
this.sessions.delete(hash);
|
||||
return true;
|
||||
}
|
||||
hash(token) { return crypto.createHash("sha256").update(token).digest("hex"); }
|
||||
}
|
||||
module.exports = { SessionManager };
|
||||
25
zero-point/core-channel/gatekeeper/session-manager.test.js
Normal file
25
zero-point/core-channel/gatekeeper/session-manager.test.js
Normal file
@ -0,0 +1,25 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { SessionManager } = require("./session-manager");
|
||||
const zhulan = { pid: "ICE-GL∞-ZL-001", name: "铸澜" };
|
||||
|
||||
test("one approval creates a reusable bounded session", () => {
|
||||
const m = new SessionManager({ absoluteTtl: 100, idleTtl: 20 });
|
||||
const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], 10);
|
||||
assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", 11).ok, true);
|
||||
assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", 12).ok, true);
|
||||
});
|
||||
test("session is bound to identity server and scope", () => {
|
||||
const m = new SessionManager();
|
||||
const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], 10);
|
||||
assert.equal(m.verify(token, { pid: "ICE-GL-ZY001" }, "BS-SG-001", "code-repo", 11).ok, false);
|
||||
assert.equal(m.verify(token, zhulan, "OTHER", "code-repo", 11).ok, false);
|
||||
assert.equal(m.verify(token, zhulan, "BS-SG-001", "system-arch", 11).ok, false);
|
||||
});
|
||||
test("end signal revokes immediately", () => {
|
||||
const m = new SessionManager();
|
||||
const { token } = m.create(zhulan, "BS-SG-001", ["code-repo"], 10);
|
||||
assert.equal(m.end(token, zhulan), true);
|
||||
assert.equal(m.verify(token, zhulan, "BS-SG-001", "code-repo", 11).ok, false);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user