feat(hololake): add mobile server account and capability proxy
This commit is contained in:
parent
758eef79ab
commit
19bd38c6e3
11 changed files with 1436 additions and 1 deletions
|
|
@ -27,6 +27,22 @@
|
|||
- 所有改变服务器状态的登记动作必须先生成备份引用和回滚方案;失败自动回滚,成功写验收回执;
|
||||
- 广州公开代理只应暴露 `/approve/`、`/api/workorders` 与 claim 路由,服务本体监听 JD 回环地址。
|
||||
|
||||
## HoloLake 手机身份、知识湖与模型代理
|
||||
|
||||
HoloLake 手机端使用独立的邮箱验证码会话,不复用工单批准链接:
|
||||
|
||||
- `/api/hololake/session/email/request` 对已登记和未登记邮箱返回相同形态,避免枚举账号;
|
||||
- 六位验证码只在邮件正文出现,服务器状态仅保存带私密 pepper 的摘要,十分钟失效且最多尝试五次;
|
||||
- 会话绑定 HoloLake 设备编号,手机仅保存短期会话令牌;服务器磁盘仍只保存令牌摘要;
|
||||
- `/api/hololake/knowledge/manifest` 与 `/archive` 只暴露固定登记仓库
|
||||
`bingshuo/hololake-knowledge-base` 的当前 `refs/heads/main` 快照,不向手机下发 Forgejo 凭据;
|
||||
- `/api/hololake/ai/catalog` 只返回可用模型编号;`/execute` 只调用服务器登记的提供商与模型,
|
||||
不接受任意 URL、请求头或密钥,响应和回执均不包含服务器密钥。
|
||||
|
||||
私密模型登记文件使用 `hololake-ai-providers.example.json` 的结构,真实文件只放在
|
||||
`/etc/guanghu/secrets/hololake-ai-providers.json`,不得提交到仓库。知识仓库路径、模型
|
||||
登记文件、session pepper 和会话状态路径由 `authorization.env` 固定;手机不能切换这些路径。
|
||||
|
||||
`request-workorder.js` 从临时环境变量读取 QQ 数字,在内存中补全邮箱并只发送
|
||||
SHA-256 指纹;数字本身不会写入请求正文、状态文件或代码仓库。未提供私密
|
||||
request credential 时,脚本自动切换到跨设备公开建单模式。
|
||||
|
|
|
|||
|
|
@ -20,6 +20,14 @@ SMTP_HOST=smtp.qq.com
|
|||
SMTP_PORT=465
|
||||
SMTP_USER=SET_IN_PRIVATE_SERVER_FILE
|
||||
QQ_SMTP_AUTH_CODE=SET_IN_PRIVATE_SERVER_FILE
|
||||
HOLOLAKE_SESSION_PEPPER=SET_RANDOM_32_BYTE_VALUE_IN_PRIVATE_SERVER_FILE
|
||||
HOLOLAKE_SESSION_STATE_FILE=/var/lib/guanghu/lake-lamp-authz/hololake-sessions.json
|
||||
HOLOLAKE_OTP_TTL=600
|
||||
HOLOLAKE_ACCOUNT_SESSION_TTL=86400
|
||||
HOLOLAKE_OTP_REQUEST_LIMIT=6
|
||||
HOLOLAKE_KNOWLEDGE_REPOSITORY_PATH=/var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories/bingshuo/hololake-knowledge-base.git
|
||||
HOLOLAKE_KNOWLEDGE_MAX_ARCHIVE_BYTES=134217728
|
||||
HOLOLAKE_AI_PROVIDERS_FILE=/etc/guanghu/secrets/hololake-ai-providers.json
|
||||
LAKE_LAMP_ARCHITECTURE_PROVISION_SOCKET=/run/guanghu-architecture-provision/provision.sock
|
||||
ARCHITECTURE_PROVISION_REPO_URL=https://guanghulab.com/code/bingshuo/guanghu-ice-heart.git
|
||||
ARCHITECTURE_PROVISION_REPO_DIR=/var/lib/guanghu/architecture-provision/repo
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"schema": "guanghu.hololake-ai-providers/v1",
|
||||
"providers": {
|
||||
"default": {
|
||||
"name": "HoloLake Server Model",
|
||||
"base_url": "https://api.example.invalid/v1",
|
||||
"api_key": "SET_IN_PRIVATE_SERVER_FILE",
|
||||
"models": [
|
||||
"SET_REGISTERED_MODEL_ID"
|
||||
],
|
||||
"timeout_ms": 120000
|
||||
}
|
||||
}
|
||||
}
|
||||
236
server-tools/lake-lamp-authz/hololake-api.test.js
Normal file
236
server-tools/lake-lamp-authz/hololake-api.test.js
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const {
|
||||
HoloLakeSessionManager,
|
||||
} = require("./hololake-session");
|
||||
const { createApp } = require("./server");
|
||||
|
||||
async function withServer(run) {
|
||||
const mail = [];
|
||||
const sessionManager = new HoloLakeSessionManager({
|
||||
registeredEmails: ["owner@example.invalid"],
|
||||
pepper: "test-only-pepper-with-enough-entropy",
|
||||
stateFile: "",
|
||||
sendEmail: async message => {
|
||||
mail.push(message);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const knowledgeProvider = {
|
||||
manifest: () => ({
|
||||
schema: "guanghu.hololake-knowledge-manifest/v1",
|
||||
repository: "bingshuo/hololake-knowledge-base",
|
||||
ref: "refs/heads/main",
|
||||
commit: "a".repeat(40),
|
||||
committed_at: 1_800_000_000,
|
||||
archive_url: `/api/hololake/knowledge/archive?commit=${"a".repeat(40)}`,
|
||||
}),
|
||||
archive: commit => ({
|
||||
schema: "guanghu.hololake-knowledge-archive/v1",
|
||||
repository: "bingshuo/hololake-knowledge-base",
|
||||
commit,
|
||||
sha256: "b".repeat(64),
|
||||
content_type: "application/zip",
|
||||
body: Buffer.from("PK-test-archive"),
|
||||
}),
|
||||
};
|
||||
const aiGateway = {
|
||||
catalog: () => ({
|
||||
schema: "guanghu.hololake-ai-catalog/v1",
|
||||
providers: [{ id: "default", name: "HoloLake", models: ["gpt-test"] }],
|
||||
}),
|
||||
execute: async body => ({
|
||||
ok: true,
|
||||
response: {
|
||||
choices: [{
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: body.messages[0].content,
|
||||
},
|
||||
}],
|
||||
},
|
||||
receipt: {
|
||||
schema: "guanghu.hololake-ai-receipt/v1",
|
||||
state: "executed",
|
||||
provider: body.provider,
|
||||
model: body.model,
|
||||
},
|
||||
}),
|
||||
};
|
||||
const app = createApp({
|
||||
requestToken: "request-only-secret",
|
||||
ownerEmail: "owner@example.invalid",
|
||||
publicBaseUrl: "https://example.invalid/authz",
|
||||
stateFile: "",
|
||||
sendEmail: async () => true,
|
||||
hololakeSessionManager: sessionManager,
|
||||
hololakeKnowledgeProvider: knowledgeProvider,
|
||||
hololakeAiGateway: aiGateway,
|
||||
});
|
||||
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 {
|
||||
app.closeAllConnections?.();
|
||||
await new Promise(resolve => app.close(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
async function login(base, mail, deviceId = "ios-device-001") {
|
||||
const request = await fetch(`${base}/api/hololake/session/email/request`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-hololake-device-id": deviceId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: "owner@example.invalid",
|
||||
device_id: deviceId,
|
||||
}),
|
||||
});
|
||||
assert.equal(request.status, 202);
|
||||
const requested = await request.json();
|
||||
const code = mail[0].text.match(/\b\d{6}\b/)[0];
|
||||
const verifiedResponse = await fetch(
|
||||
`${base}/api/hololake/session/email/verify`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-hololake-device-id": deviceId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
request_id: requested.request_id,
|
||||
code,
|
||||
device_id: deviceId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
assert.equal(verifiedResponse.status, 200);
|
||||
return (await verifiedResponse.json()).session_token;
|
||||
}
|
||||
|
||||
test("HoloLake session API is non-enumerating and rejects invalid verification", async () => {
|
||||
await withServer(async ({ base, mail }) => {
|
||||
const unknown = await fetch(
|
||||
`${base}/api/hololake/session/email/request`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: "nobody@example.invalid",
|
||||
device_id: "ios-device-001",
|
||||
}),
|
||||
},
|
||||
);
|
||||
assert.equal(unknown.status, 202);
|
||||
assert.equal(mail.length, 0);
|
||||
assert.equal((await unknown.json()).accepted, true);
|
||||
|
||||
const invalid = await fetch(
|
||||
`${base}/api/hololake/session/email/verify`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
request_id: crypto.randomUUID(),
|
||||
code: "000000",
|
||||
device_id: "ios-device-001",
|
||||
}),
|
||||
},
|
||||
);
|
||||
assert.equal(invalid.status, 403);
|
||||
assert.equal((await invalid.json()).error, "invalid_or_expired_code");
|
||||
});
|
||||
});
|
||||
|
||||
test("authenticated device can read fixed knowledge, use AI proxy, inspect and revoke session", async () => {
|
||||
await withServer(async ({ base, mail }) => {
|
||||
const deviceId = "ios-device-001";
|
||||
const token = await login(base, mail, deviceId);
|
||||
const headers = {
|
||||
authorization: `Bearer ${token}`,
|
||||
"x-hololake-device-id": deviceId,
|
||||
};
|
||||
|
||||
const session = await fetch(`${base}/api/hololake/session`, { headers });
|
||||
assert.equal(session.status, 200);
|
||||
assert.equal((await session.json()).session.device_id, deviceId);
|
||||
|
||||
const manifest = await fetch(
|
||||
`${base}/api/hololake/knowledge/manifest`,
|
||||
{ headers },
|
||||
);
|
||||
assert.equal(manifest.status, 200);
|
||||
assert.equal((await manifest.json()).commit, "a".repeat(40));
|
||||
|
||||
const archive = await fetch(
|
||||
`${base}/api/hololake/knowledge/archive?commit=${"a".repeat(40)}`,
|
||||
{ headers },
|
||||
);
|
||||
assert.equal(archive.status, 200);
|
||||
assert.equal(archive.headers.get("x-hololake-commit"), "a".repeat(40));
|
||||
assert.equal(archive.headers.get("x-content-sha256"), "b".repeat(64));
|
||||
assert.equal(Buffer.from(await archive.arrayBuffer()).toString(), "PK-test-archive");
|
||||
|
||||
const ai = await fetch(`${base}/api/hololake/ai/execute`, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: "default",
|
||||
model: "gpt-test",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
}),
|
||||
});
|
||||
assert.equal(ai.status, 200);
|
||||
assert.equal(
|
||||
(await ai.json()).response.choices[0].message.content,
|
||||
"hello",
|
||||
);
|
||||
const catalog = await fetch(`${base}/api/hololake/ai/catalog`, { headers });
|
||||
assert.equal(catalog.status, 200);
|
||||
assert.deepEqual((await catalog.json()).providers[0].models, ["gpt-test"]);
|
||||
|
||||
const logout = await fetch(`${base}/api/hololake/session`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
assert.equal(logout.status, 200);
|
||||
assert.equal(
|
||||
(await fetch(`${base}/api/hololake/session`, { headers })).status,
|
||||
401,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("knowledge and AI endpoints require the session and matching device", async () => {
|
||||
await withServer(async ({ base, mail }) => {
|
||||
assert.equal(
|
||||
(await fetch(`${base}/api/hololake/knowledge/manifest`)).status,
|
||||
401,
|
||||
);
|
||||
assert.equal(
|
||||
(await fetch(`${base}/api/hololake/ai/execute`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{}",
|
||||
})).status,
|
||||
401,
|
||||
);
|
||||
|
||||
const token = await login(base, mail);
|
||||
assert.equal(
|
||||
(await fetch(`${base}/api/hololake/knowledge/manifest`, {
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"x-hololake-device-id": "ios-device-002",
|
||||
},
|
||||
})).status,
|
||||
403,
|
||||
);
|
||||
});
|
||||
});
|
||||
242
server-tools/lake-lamp-authz/hololake-capabilities.js
Normal file
242
server-tools/lake-lamp-authz/hololake-capabilities.js
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
"use strict";
|
||||
|
||||
const childProcess = require("node:child_process");
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
|
||||
class HoloLakeKnowledgeProvider {
|
||||
constructor(options = {}) {
|
||||
this.repositoryId = String(options.repositoryId || "");
|
||||
this.repositoryPath = String(options.repositoryPath || "");
|
||||
this.ref = "refs/heads/main";
|
||||
this.maxArchiveBytes = Math.max(
|
||||
1024 * 1024,
|
||||
Number(options.maxArchiveBytes || 128 * 1024 * 1024),
|
||||
);
|
||||
if (!/^[a-z0-9._-]+\/[a-z0-9._-]+$/.test(this.repositoryId)) {
|
||||
throw new Error("knowledge_repository_id_invalid");
|
||||
}
|
||||
if (!this.repositoryPath || !fs.existsSync(this.repositoryPath)) {
|
||||
throw new Error("knowledge_repository_unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
manifest() {
|
||||
const commit = this.git(["rev-parse", "--verify", `${this.ref}^{commit}`])
|
||||
.toString("utf8")
|
||||
.trim();
|
||||
if (!/^[0-9a-f]{40}$/.test(commit)) {
|
||||
throw new Error("knowledge_commit_invalid");
|
||||
}
|
||||
const committedAt = Number(
|
||||
this.git(["show", "-s", "--format=%ct", commit]).toString("utf8").trim(),
|
||||
);
|
||||
return {
|
||||
schema: "guanghu.hololake-knowledge-manifest/v1",
|
||||
repository: this.repositoryId,
|
||||
ref: this.ref,
|
||||
commit,
|
||||
committed_at: committedAt,
|
||||
archive_url: `/api/hololake/knowledge/archive?commit=${commit}`,
|
||||
};
|
||||
}
|
||||
|
||||
archive(commit) {
|
||||
const manifest = this.manifest();
|
||||
if (!safeEqual(String(commit || ""), manifest.commit)) {
|
||||
throw new Error("knowledge_commit_not_current");
|
||||
}
|
||||
const body = this.git(
|
||||
["archive", "--format=zip", manifest.commit],
|
||||
this.maxArchiveBytes + 1,
|
||||
);
|
||||
if (body.length > this.maxArchiveBytes) {
|
||||
throw new Error("knowledge_archive_too_large");
|
||||
}
|
||||
return {
|
||||
schema: "guanghu.hololake-knowledge-archive/v1",
|
||||
repository: this.repositoryId,
|
||||
commit: manifest.commit,
|
||||
sha256: crypto.createHash("sha256").update(body).digest("hex"),
|
||||
content_type: "application/zip",
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
git(args, maxBuffer = 1024 * 1024) {
|
||||
return childProcess.execFileSync(
|
||||
"git",
|
||||
[`--git-dir=${this.repositoryPath}`, ...args],
|
||||
{
|
||||
encoding: "buffer",
|
||||
maxBuffer,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HoloLakeAiGateway {
|
||||
constructor(options = {}) {
|
||||
this.fetchImpl = options.fetchImpl || fetch;
|
||||
this.maxMessages = Math.max(1, Number(options.maxMessages || 64));
|
||||
this.maxInputCharacters = Math.max(
|
||||
1,
|
||||
Number(options.maxInputCharacters || 120_000),
|
||||
);
|
||||
this.maxOutputTokens = Math.max(
|
||||
16,
|
||||
Number(options.maxOutputTokens || 16_384),
|
||||
);
|
||||
this.maxResponseBytes = Math.max(
|
||||
64 * 1024,
|
||||
Number(options.maxResponseBytes || 2 * 1024 * 1024),
|
||||
);
|
||||
this.providers = normalizeProviders(options.providers || {});
|
||||
}
|
||||
|
||||
catalog() {
|
||||
return {
|
||||
schema: "guanghu.hololake-ai-catalog/v1",
|
||||
providers: Object.entries(this.providers).map(([id, provider]) => ({
|
||||
id,
|
||||
name: provider.name || id,
|
||||
models: provider.models,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async execute(request = {}) {
|
||||
const providerId = String(request.provider || "");
|
||||
const provider = this.providers[providerId];
|
||||
if (!provider) throw new Error("ai_provider_not_registered");
|
||||
const model = String(request.model || "");
|
||||
if (!provider.models.includes(model)) {
|
||||
throw new Error("ai_model_not_registered");
|
||||
}
|
||||
const messages = validateMessages(
|
||||
request.messages,
|
||||
this.maxMessages,
|
||||
this.maxInputCharacters,
|
||||
);
|
||||
const maxTokens = Number(request.max_tokens || 1024);
|
||||
if (
|
||||
!Number.isInteger(maxTokens)
|
||||
|| maxTokens < 1
|
||||
|| maxTokens > this.maxOutputTokens
|
||||
) {
|
||||
throw new Error("ai_output_limit_exceeded");
|
||||
}
|
||||
|
||||
const response = await this.fetchImpl(`${provider.baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${provider.apiKey}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
max_tokens: maxTokens,
|
||||
stream: false,
|
||||
}),
|
||||
signal: AbortSignal.timeout(provider.timeoutMs),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (Buffer.byteLength(text) > this.maxResponseBytes) {
|
||||
throw new Error("ai_response_too_large");
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = new Error("ai_upstream_failed");
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error("ai_upstream_invalid_json");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
response: parsed,
|
||||
receipt: {
|
||||
schema: "guanghu.hololake-ai-receipt/v1",
|
||||
state: "executed",
|
||||
provider: providerId,
|
||||
model,
|
||||
upstream_status: response.status,
|
||||
occurred_at: Date.now() / 1000,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function loadAiProviders(file) {
|
||||
if (!file || !fs.existsSync(file)) return {};
|
||||
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (!parsed || parsed.schema !== "guanghu.hololake-ai-providers/v1") {
|
||||
throw new Error("ai_provider_registry_invalid");
|
||||
}
|
||||
return parsed.providers || {};
|
||||
}
|
||||
|
||||
function normalizeProviders(providers) {
|
||||
const normalized = {};
|
||||
for (const [id, value] of Object.entries(providers)) {
|
||||
if (!/^[a-z0-9._-]{1,64}$/.test(id) || !value) continue;
|
||||
const baseUrl = String(value.baseUrl || value.base_url || "").replace(/\/$/, "");
|
||||
const apiKey = String(value.apiKey || value.api_key || "");
|
||||
const models = Array.isArray(value.models)
|
||||
? value.models.map(String).filter(model => /^[A-Za-z0-9._:/-]{1,160}$/.test(model))
|
||||
: [];
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(baseUrl);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (parsed.protocol !== "https:" || !apiKey || models.length === 0) continue;
|
||||
normalized[id] = {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
name: String(value.name || id).slice(0, 120),
|
||||
models,
|
||||
timeoutMs: Math.max(1_000, Number(value.timeoutMs || value.timeout_ms || 120_000)),
|
||||
};
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateMessages(messages, maxMessages, maxInputCharacters) {
|
||||
if (!Array.isArray(messages) || messages.length < 1) {
|
||||
throw new Error("ai_messages_required");
|
||||
}
|
||||
if (messages.length > maxMessages) throw new Error("ai_input_too_large");
|
||||
let characters = 0;
|
||||
const normalized = messages.map(message => {
|
||||
if (
|
||||
!message
|
||||
|| !["system", "user", "assistant"].includes(message.role)
|
||||
|| typeof message.content !== "string"
|
||||
) {
|
||||
throw new Error("ai_message_invalid");
|
||||
}
|
||||
characters += message.content.length;
|
||||
return { role: message.role, content: message.content };
|
||||
});
|
||||
if (characters > maxInputCharacters) throw new Error("ai_input_too_large");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
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 = {
|
||||
HoloLakeAiGateway,
|
||||
HoloLakeKnowledgeProvider,
|
||||
loadAiProviders,
|
||||
};
|
||||
158
server-tools/lake-lamp-authz/hololake-capabilities.test.js
Normal file
158
server-tools/lake-lamp-authz/hololake-capabilities.test.js
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const childProcess = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const {
|
||||
HoloLakeAiGateway,
|
||||
HoloLakeKnowledgeProvider,
|
||||
} = require("./hololake-capabilities");
|
||||
|
||||
function createBareKnowledgeRepository() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "hololake-knowledge-"));
|
||||
const work = path.join(root, "work");
|
||||
const bare = path.join(root, "knowledge.git");
|
||||
fs.mkdirSync(work);
|
||||
childProcess.execFileSync("git", ["init", "-b", "main"], { cwd: work });
|
||||
childProcess.execFileSync("git", ["config", "user.email", "test@example.invalid"], { cwd: work });
|
||||
childProcess.execFileSync("git", ["config", "user.name", "HoloLake Test"], { cwd: work });
|
||||
fs.writeFileSync(path.join(work, "INDEX.md"), "# HoloLake\n");
|
||||
childProcess.execFileSync("git", ["add", "INDEX.md"], { cwd: work });
|
||||
childProcess.execFileSync("git", ["commit", "-m", "knowledge baseline"], { cwd: work });
|
||||
childProcess.execFileSync("git", ["clone", "--bare", work, bare]);
|
||||
return { root, bare };
|
||||
}
|
||||
|
||||
test("knowledge provider exposes only the fixed main commit and archive", () => {
|
||||
const repository = createBareKnowledgeRepository();
|
||||
const provider = new HoloLakeKnowledgeProvider({
|
||||
repositoryId: "bingshuo/hololake-knowledge-base",
|
||||
repositoryPath: repository.bare,
|
||||
maxArchiveBytes: 2 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const manifest = provider.manifest();
|
||||
assert.equal(
|
||||
manifest.repository,
|
||||
"bingshuo/hololake-knowledge-base",
|
||||
);
|
||||
assert.match(manifest.commit, /^[0-9a-f]{40}$/);
|
||||
assert.equal(manifest.ref, "refs/heads/main");
|
||||
|
||||
const archive = provider.archive(manifest.commit);
|
||||
assert.equal(archive.commit, manifest.commit);
|
||||
assert.equal(archive.content_type, "application/zip");
|
||||
assert.equal(archive.body.subarray(0, 2).toString("ascii"), "PK");
|
||||
assert.equal(archive.sha256.length, 64);
|
||||
|
||||
assert.throws(
|
||||
() => provider.archive("0000000000000000000000000000000000000000"),
|
||||
/knowledge_commit_not_current/,
|
||||
);
|
||||
});
|
||||
|
||||
test("AI gateway accepts only registered provider models and never leaks the key", async () => {
|
||||
const requests = [];
|
||||
const gateway = new HoloLakeAiGateway({
|
||||
providers: {
|
||||
default: {
|
||||
baseUrl: "https://models.example.invalid/v1",
|
||||
apiKey: "server-secret-key",
|
||||
models: ["gpt-test"],
|
||||
},
|
||||
},
|
||||
fetchImpl: async (url, options) => {
|
||||
requests.push({ url, options });
|
||||
return new Response(JSON.stringify({
|
||||
id: "response-1",
|
||||
choices: [{ message: { role: "assistant", content: "ok" } }],
|
||||
usage: { total_tokens: 3 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const result = await gateway.execute({
|
||||
provider: "default",
|
||||
model: "gpt-test",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
max_tokens: 32,
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.response.choices[0].message.content, "ok");
|
||||
assert.equal(result.receipt.provider, "default");
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(
|
||||
requests[0].options.headers.authorization,
|
||||
"Bearer server-secret-key",
|
||||
);
|
||||
assert.doesNotMatch(JSON.stringify(result), /server-secret-key/);
|
||||
const catalog = gateway.catalog();
|
||||
assert.deepEqual(catalog.providers[0].models, ["gpt-test"]);
|
||||
assert.doesNotMatch(JSON.stringify(catalog), /server-secret-key|models\\.example/);
|
||||
|
||||
await assert.rejects(
|
||||
() => gateway.execute({
|
||||
provider: "default",
|
||||
model: "not-registered",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
}),
|
||||
/ai_model_not_registered/,
|
||||
);
|
||||
await assert.rejects(
|
||||
() => gateway.execute({
|
||||
provider: "https://attacker.invalid/v1",
|
||||
model: "gpt-test",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
}),
|
||||
/ai_provider_not_registered/,
|
||||
);
|
||||
assert.equal(requests.length, 1);
|
||||
});
|
||||
|
||||
test("AI gateway bounds message shape, count, text, and requested output", async () => {
|
||||
const gateway = new HoloLakeAiGateway({
|
||||
providers: {
|
||||
default: {
|
||||
baseUrl: "https://models.example.invalid/v1",
|
||||
apiKey: "server-secret-key",
|
||||
models: ["gpt-test"],
|
||||
},
|
||||
},
|
||||
fetchImpl: async () => new Response("{}", { status: 200 }),
|
||||
maxMessages: 2,
|
||||
maxInputCharacters: 10,
|
||||
maxOutputTokens: 64,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => gateway.execute({
|
||||
provider: "default",
|
||||
model: "gpt-test",
|
||||
messages: [{ role: "user", content: "12345678901" }],
|
||||
}),
|
||||
/ai_input_too_large/,
|
||||
);
|
||||
await assert.rejects(
|
||||
() => gateway.execute({
|
||||
provider: "default",
|
||||
model: "gpt-test",
|
||||
messages: [{ role: "tool", content: "hello" }],
|
||||
}),
|
||||
/ai_message_invalid/,
|
||||
);
|
||||
await assert.rejects(
|
||||
() => gateway.execute({
|
||||
provider: "default",
|
||||
model: "gpt-test",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
max_tokens: 65,
|
||||
}),
|
||||
/ai_output_limit_exceeded/,
|
||||
);
|
||||
});
|
||||
260
server-tools/lake-lamp-authz/hololake-session.js
Normal file
260
server-tools/lake-lamp-authz/hololake-session.js
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
class HoloLakeSessionManager {
|
||||
constructor(options = {}) {
|
||||
this.now = options.now || Date.now;
|
||||
this.sendEmail = options.sendEmail || (async () => false);
|
||||
this.pepper = String(options.pepper || "");
|
||||
if (this.pepper.length < 24) {
|
||||
throw new Error("HOLOLAKE_SESSION_PEPPER must contain at least 24 characters");
|
||||
}
|
||||
this.stateFile = String(options.stateFile || "");
|
||||
this.otpTtlMs = Number(options.otpTtlSeconds || 10 * 60) * 1000;
|
||||
this.sessionTtlMs = Number(options.sessionTtlSeconds || 24 * 60 * 60) * 1000;
|
||||
this.maxOtpAttempts = Math.max(1, Number(options.maxOtpAttempts || 5));
|
||||
this.requestLimit = Math.max(1, Number(options.requestLimit || 6));
|
||||
this.requestWindowMs = Number(options.requestWindowSeconds || 60 * 60) * 1000;
|
||||
this.registeredEmails = new Map(
|
||||
(options.registeredEmails || [])
|
||||
.map(normalizeEmail)
|
||||
.filter(validEmail)
|
||||
.map(email => [email, this.digest(`account:${email}`)]),
|
||||
);
|
||||
this.requestEvents = new Map();
|
||||
this.state = this.loadState();
|
||||
}
|
||||
|
||||
async requestOtp({ email, deviceId, networkKey = "unknown" }) {
|
||||
const now = this.now();
|
||||
this.prune(now);
|
||||
const normalizedDevice = normalizeDeviceId(deviceId);
|
||||
const requestId = crypto.randomUUID();
|
||||
if (!normalizedDevice) return { accepted: false, error: "invalid_device" };
|
||||
if (!this.takeRequest(String(networkKey || "unknown"), now)) {
|
||||
return { accepted: false, error: "rate_limited" };
|
||||
}
|
||||
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
const accountId = this.registeredEmails.get(normalizedEmail);
|
||||
if (!accountId) return { accepted: true, request_id: requestId };
|
||||
|
||||
const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
|
||||
this.state.challenges[requestId] = {
|
||||
account_id: accountId,
|
||||
device_id: normalizedDevice,
|
||||
code_digest: this.digest(
|
||||
`otp:${requestId}:${normalizedDevice}:${code}`,
|
||||
),
|
||||
attempts: 0,
|
||||
expires_at: now + this.otpTtlMs,
|
||||
};
|
||||
this.persist();
|
||||
const sent = await this.sendEmail({
|
||||
to: normalizedEmail,
|
||||
subject: "HoloLake 登录验证码",
|
||||
text: [
|
||||
`你的 HoloLake 登录验证码是:${code}`,
|
||||
`验证码将在 ${Math.ceil(this.otpTtlMs / 60_000)} 分钟后失效。`,
|
||||
"如果不是你本人操作,请忽略这封邮件。",
|
||||
].join("\n"),
|
||||
});
|
||||
if (!sent) {
|
||||
delete this.state.challenges[requestId];
|
||||
this.persist();
|
||||
}
|
||||
return { accepted: true, request_id: requestId };
|
||||
}
|
||||
|
||||
verifyOtp({ requestId, code, deviceId }) {
|
||||
const now = this.now();
|
||||
this.prune(now);
|
||||
const id = String(requestId || "");
|
||||
const challenge = this.state.challenges[id];
|
||||
const normalizedDevice = normalizeDeviceId(deviceId);
|
||||
if (
|
||||
!challenge
|
||||
|| !normalizedDevice
|
||||
|| challenge.expires_at <= now
|
||||
|| challenge.attempts >= this.maxOtpAttempts
|
||||
) {
|
||||
if (challenge) {
|
||||
delete this.state.challenges[id];
|
||||
this.persist();
|
||||
}
|
||||
return invalidCode();
|
||||
}
|
||||
|
||||
challenge.attempts += 1;
|
||||
const candidate = this.digest(
|
||||
`otp:${id}:${normalizedDevice}:${String(code || "")}`,
|
||||
);
|
||||
const matches = safeEqual(candidate, challenge.code_digest)
|
||||
&& safeEqual(normalizedDevice, challenge.device_id);
|
||||
if (!matches) {
|
||||
if (challenge.attempts >= this.maxOtpAttempts) {
|
||||
delete this.state.challenges[id];
|
||||
}
|
||||
this.persist();
|
||||
return invalidCode();
|
||||
}
|
||||
|
||||
delete this.state.challenges[id];
|
||||
const token = crypto.randomBytes(48).toString("base64url");
|
||||
const tokenDigest = this.digest(`session:${token}`);
|
||||
this.state.sessions[tokenDigest] = {
|
||||
account_id: challenge.account_id,
|
||||
device_id: normalizedDevice,
|
||||
created_at: now,
|
||||
expires_at: now + this.sessionTtlMs,
|
||||
};
|
||||
this.persist();
|
||||
return {
|
||||
ok: true,
|
||||
session_token: token,
|
||||
expires_at: (now + this.sessionTtlMs) / 1000,
|
||||
expires_in: this.sessionTtlMs / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
authenticate(token, deviceId) {
|
||||
const now = this.now();
|
||||
const normalizedDevice = normalizeDeviceId(deviceId);
|
||||
const tokenDigest = this.digest(`session:${String(token || "")}`);
|
||||
const session = this.state.sessions[tokenDigest];
|
||||
if (!session) return { ok: false, error: "session_invalid" };
|
||||
if (session.expires_at <= now) {
|
||||
delete this.state.sessions[tokenDigest];
|
||||
this.persist();
|
||||
return { ok: false, error: "session_expired" };
|
||||
}
|
||||
if (!normalizedDevice || !safeEqual(normalizedDevice, session.device_id)) {
|
||||
return { ok: false, error: "session_device_mismatch" };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
session: {
|
||||
account_id: session.account_id,
|
||||
device_id: session.device_id,
|
||||
created_at: session.created_at / 1000,
|
||||
expires_at: session.expires_at / 1000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
revoke(token, deviceId) {
|
||||
const authenticated = this.authenticate(token, deviceId);
|
||||
if (!authenticated.ok) return authenticated;
|
||||
const tokenDigest = this.digest(`session:${String(token || "")}`);
|
||||
delete this.state.sessions[tokenDigest];
|
||||
this.persist();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
inspectState() {
|
||||
return JSON.parse(JSON.stringify(this.state));
|
||||
}
|
||||
|
||||
digest(value) {
|
||||
return crypto.createHmac("sha256", this.pepper).update(value).digest("hex");
|
||||
}
|
||||
|
||||
takeRequest(key, now) {
|
||||
const previous = (this.requestEvents.get(key) || [])
|
||||
.filter(timestamp => now - timestamp < this.requestWindowMs);
|
||||
if (previous.length >= this.requestLimit) {
|
||||
this.requestEvents.set(key, previous);
|
||||
return false;
|
||||
}
|
||||
previous.push(now);
|
||||
this.requestEvents.set(key, previous);
|
||||
return true;
|
||||
}
|
||||
|
||||
prune(now) {
|
||||
let changed = false;
|
||||
for (const [id, challenge] of Object.entries(this.state.challenges)) {
|
||||
if (challenge.expires_at <= now) {
|
||||
delete this.state.challenges[id];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (const [digest, session] of Object.entries(this.state.sessions)) {
|
||||
if (session.expires_at <= now) {
|
||||
delete this.state.sessions[digest];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) this.persist();
|
||||
}
|
||||
|
||||
loadState() {
|
||||
if (!this.stateFile || !fs.existsSync(this.stateFile)) {
|
||||
return emptyState();
|
||||
}
|
||||
const parsed = JSON.parse(fs.readFileSync(this.stateFile, "utf8"));
|
||||
if (
|
||||
!parsed
|
||||
|| parsed.schema !== "guanghu.hololake-session-state/v1"
|
||||
|| !isRecord(parsed.challenges)
|
||||
|| !isRecord(parsed.sessions)
|
||||
) {
|
||||
throw new Error("invalid HoloLake session state");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
persist() {
|
||||
if (!this.stateFile) return;
|
||||
fs.mkdirSync(path.dirname(this.stateFile), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${this.stateFile}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temporary, `${JSON.stringify(this.state, null, 2)}\n`, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.renameSync(temporary, this.stateFile);
|
||||
fs.chmodSync(this.stateFile, 0o600);
|
||||
}
|
||||
}
|
||||
|
||||
function emptyState() {
|
||||
return {
|
||||
schema: "guanghu.hololake-session-state/v1",
|
||||
challenges: {},
|
||||
sessions: {},
|
||||
};
|
||||
}
|
||||
|
||||
function invalidCode() {
|
||||
return { ok: false, error: "invalid_or_expired_code" };
|
||||
}
|
||||
|
||||
function normalizeEmail(value) {
|
||||
return String(value || "").trim().normalize("NFKC").toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeDeviceId(value) {
|
||||
const normalized = String(value || "").trim();
|
||||
return /^[A-Za-z0-9._:-]{8,120}$/.test(normalized) ? normalized : "";
|
||||
}
|
||||
|
||||
function validEmail(value) {
|
||||
return value.length <= 254 && /^[^@\s]+@[^@\s]+$/.test(value);
|
||||
}
|
||||
|
||||
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 isRecord(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HoloLakeSessionManager,
|
||||
};
|
||||
211
server-tools/lake-lamp-authz/hololake-session.test.js
Normal file
211
server-tools/lake-lamp-authz/hololake-session.test.js
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
HoloLakeSessionManager,
|
||||
} = require("./hololake-session");
|
||||
|
||||
function fixture(overrides = {}) {
|
||||
let now = 1_800_000_000_000;
|
||||
const mail = [];
|
||||
const manager = new HoloLakeSessionManager({
|
||||
registeredEmails: ["owner@example.invalid"],
|
||||
pepper: "test-only-pepper-with-enough-entropy",
|
||||
stateFile: "",
|
||||
now: () => now,
|
||||
sendEmail: async message => {
|
||||
mail.push(message);
|
||||
return true;
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
return {
|
||||
manager,
|
||||
mail,
|
||||
advance(milliseconds) {
|
||||
now += milliseconds;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("OTP request is non-enumerating and stores no plaintext code", async () => {
|
||||
const known = fixture();
|
||||
const requested = await known.manager.requestOtp({
|
||||
email: "Owner@Example.Invalid",
|
||||
deviceId: "ios-device-001",
|
||||
});
|
||||
assert.equal(requested.accepted, true);
|
||||
assert.match(requested.request_id, /^[0-9a-f-]{36}$/);
|
||||
assert.equal(known.mail.length, 1);
|
||||
assert.match(known.mail[0].text, /\b\d{6}\b/);
|
||||
const otp = known.mail[0].text.match(/\b\d{6}\b/)[0];
|
||||
assert.doesNotMatch(JSON.stringify(known.manager.inspectState()), new RegExp(otp));
|
||||
|
||||
const unknown = fixture();
|
||||
const decoy = await unknown.manager.requestOtp({
|
||||
email: "nobody@example.invalid",
|
||||
deviceId: "ios-device-001",
|
||||
});
|
||||
assert.equal(decoy.accepted, requested.accepted);
|
||||
assert.match(decoy.request_id, /^[0-9a-f-]{36}$/);
|
||||
assert.equal(unknown.mail.length, 0);
|
||||
});
|
||||
|
||||
test("OTP verification is device-bound, attempt-limited, and returns a one-time session token", async () => {
|
||||
const state = fixture();
|
||||
const requested = await state.manager.requestOtp({
|
||||
email: "owner@example.invalid",
|
||||
deviceId: "ios-device-001",
|
||||
});
|
||||
const otp = state.mail[0].text.match(/\b\d{6}\b/)[0];
|
||||
|
||||
const wrongDevice = state.manager.verifyOtp({
|
||||
requestId: requested.request_id,
|
||||
code: otp,
|
||||
deviceId: "ios-device-002",
|
||||
});
|
||||
assert.equal(wrongDevice.ok, false);
|
||||
assert.equal(wrongDevice.error, "invalid_or_expired_code");
|
||||
|
||||
const verified = state.manager.verifyOtp({
|
||||
requestId: requested.request_id,
|
||||
code: otp,
|
||||
deviceId: "ios-device-001",
|
||||
});
|
||||
assert.equal(verified.ok, true);
|
||||
assert.match(verified.session_token, /^[A-Za-z0-9_-]{40,}$/);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(state.manager.inspectState()),
|
||||
new RegExp(verified.session_token),
|
||||
);
|
||||
|
||||
const reused = state.manager.verifyOtp({
|
||||
requestId: requested.request_id,
|
||||
code: otp,
|
||||
deviceId: "ios-device-001",
|
||||
});
|
||||
assert.equal(reused.ok, false);
|
||||
assert.equal(reused.error, "invalid_or_expired_code");
|
||||
});
|
||||
|
||||
test("session authentication, expiry, and revocation never return the stored token", async () => {
|
||||
const state = fixture({ sessionTtlSeconds: 60 });
|
||||
const requested = await state.manager.requestOtp({
|
||||
email: "owner@example.invalid",
|
||||
deviceId: "ios-device-001",
|
||||
});
|
||||
const otp = state.mail[0].text.match(/\b\d{6}\b/)[0];
|
||||
const verified = state.manager.verifyOtp({
|
||||
requestId: requested.request_id,
|
||||
code: otp,
|
||||
deviceId: "ios-device-001",
|
||||
});
|
||||
|
||||
const active = state.manager.authenticate(
|
||||
verified.session_token,
|
||||
"ios-device-001",
|
||||
);
|
||||
assert.equal(active.ok, true);
|
||||
assert.equal(active.session.device_id, "ios-device-001");
|
||||
assert.equal(Object.hasOwn(active.session, "token"), false);
|
||||
|
||||
assert.equal(
|
||||
state.manager.authenticate(verified.session_token, "ios-device-002").error,
|
||||
"session_device_mismatch",
|
||||
);
|
||||
assert.equal(
|
||||
state.manager.revoke(verified.session_token, "ios-device-002").error,
|
||||
"session_device_mismatch",
|
||||
);
|
||||
assert.equal(
|
||||
state.manager.revoke(verified.session_token, "ios-device-001").ok,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
state.manager.authenticate(verified.session_token, "ios-device-001").error,
|
||||
"session_invalid",
|
||||
);
|
||||
|
||||
const second = await state.manager.requestOtp({
|
||||
email: "owner@example.invalid",
|
||||
deviceId: "ios-device-001",
|
||||
});
|
||||
const secondOtp = state.mail[1].text.match(/\b\d{6}\b/)[0];
|
||||
const secondSession = state.manager.verifyOtp({
|
||||
requestId: second.request_id,
|
||||
code: secondOtp,
|
||||
deviceId: "ios-device-001",
|
||||
});
|
||||
state.advance(61_000);
|
||||
assert.equal(
|
||||
state.manager.authenticate(
|
||||
secondSession.session_token,
|
||||
"ios-device-001",
|
||||
).error,
|
||||
"session_expired",
|
||||
);
|
||||
});
|
||||
|
||||
test("OTP expires, locks after five failed attempts, and request rate is bounded", async () => {
|
||||
const expired = fixture({ otpTtlSeconds: 30 });
|
||||
const requested = await expired.manager.requestOtp({
|
||||
email: "owner@example.invalid",
|
||||
deviceId: "ios-device-001",
|
||||
});
|
||||
const otp = expired.mail[0].text.match(/\b\d{6}\b/)[0];
|
||||
expired.advance(31_000);
|
||||
assert.equal(
|
||||
expired.manager.verifyOtp({
|
||||
requestId: requested.request_id,
|
||||
code: otp,
|
||||
deviceId: "ios-device-001",
|
||||
}).error,
|
||||
"invalid_or_expired_code",
|
||||
);
|
||||
|
||||
const locked = fixture({ maxOtpAttempts: 5 });
|
||||
const lockRequest = await locked.manager.requestOtp({
|
||||
email: "owner@example.invalid",
|
||||
deviceId: "ios-device-001",
|
||||
});
|
||||
const realOtp = locked.mail[0].text.match(/\b\d{6}\b/)[0];
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
assert.equal(
|
||||
locked.manager.verifyOtp({
|
||||
requestId: lockRequest.request_id,
|
||||
code: "000000",
|
||||
deviceId: "ios-device-001",
|
||||
}).ok,
|
||||
false,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
locked.manager.verifyOtp({
|
||||
requestId: lockRequest.request_id,
|
||||
code: realOtp,
|
||||
deviceId: "ios-device-001",
|
||||
}).error,
|
||||
"invalid_or_expired_code",
|
||||
);
|
||||
|
||||
const limited = fixture({ requestLimit: 2 });
|
||||
await limited.manager.requestOtp({
|
||||
email: "owner@example.invalid",
|
||||
deviceId: "ios-device-001",
|
||||
networkKey: "198.51.100.7",
|
||||
});
|
||||
await limited.manager.requestOtp({
|
||||
email: "owner@example.invalid",
|
||||
deviceId: "ios-device-001",
|
||||
networkKey: "198.51.100.7",
|
||||
});
|
||||
const denied = await limited.manager.requestOtp({
|
||||
email: "owner@example.invalid",
|
||||
deviceId: "ios-device-001",
|
||||
networkKey: "198.51.100.7",
|
||||
});
|
||||
assert.equal(denied.accepted, false);
|
||||
assert.equal(denied.error, "rate_limited");
|
||||
assert.equal(limited.mail.length, 2);
|
||||
});
|
||||
|
|
@ -10,7 +10,7 @@ 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 deployment-event.js deployment-event-worker.js deployment-source-policy.js; do
|
||||
for file in server.js workorder-manager.js map-gate.js smtp-mailer.js action-client.js architecture-provision-broker.js deployment-event.js deployment-event-worker.js deployment-source-policy.js hololake-session.js hololake-capabilities.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
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ PrivateTmp=true
|
|||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/guanghu/lake-lamp-authz /var/lib/guanghu/repo-authorizations /var/lib/guanghu/repo-push-uploads /var/lib/guanghu/forgejo/repositories/bingshuo/hololake-platform.git /var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories/bingshuo/guanghu-ice-heart.git
|
||||
ReadOnlyPaths=-/var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories/bingshuo/hololake-knowledge-base.git -/etc/guanghu/secrets/hololake-ai-providers.json
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
LockPersonality=true
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ const { sendSmtpMail } = require("./smtp-mailer");
|
|||
const { executeRegisteredAction } = require("./action-client");
|
||||
const { enqueueDeploymentEvent } = require("./deployment-event");
|
||||
const { GuanghuRouter, loadDevices } = require("./guanghu-router");
|
||||
const { HoloLakeSessionManager } = require("./hololake-session");
|
||||
const {
|
||||
HoloLakeAiGateway,
|
||||
HoloLakeKnowledgeProvider,
|
||||
loadAiProviders,
|
||||
} = require("./hololake-capabilities");
|
||||
const {
|
||||
loadRegistry: loadRepoPushRegistry,
|
||||
receiveBundle,
|
||||
|
|
@ -71,6 +77,72 @@ function createApp(options = {}) {
|
|||
smtpUser: process.env.SMTP_USER || ownerEmail,
|
||||
smtpPass: process.env.QQ_SMTP_AUTH_CODE || "",
|
||||
}));
|
||||
const hololakePepper = String(
|
||||
options.hololakeSessionPepper
|
||||
|| process.env.HOLOLAKE_SESSION_PEPPER
|
||||
|| "",
|
||||
);
|
||||
const hololakeSessionManager = options.hololakeSessionManager || (
|
||||
hololakePepper
|
||||
? new HoloLakeSessionManager({
|
||||
registeredEmails: approvers.map(item => item.email),
|
||||
pepper: hololakePepper,
|
||||
stateFile: Object.prototype.hasOwnProperty.call(options, "hololakeSessionStateFile")
|
||||
? options.hololakeSessionStateFile
|
||||
: (
|
||||
process.env.HOLOLAKE_SESSION_STATE_FILE
|
||||
|| "/var/lib/guanghu/lake-lamp-authz/hololake-sessions.json"
|
||||
),
|
||||
otpTtlSeconds: Number(
|
||||
options.hololakeOtpTtlSeconds
|
||||
|| process.env.HOLOLAKE_OTP_TTL
|
||||
|| 10 * 60,
|
||||
),
|
||||
sessionTtlSeconds: Number(
|
||||
options.hololakeSessionTtlSeconds
|
||||
|| process.env.HOLOLAKE_ACCOUNT_SESSION_TTL
|
||||
|| 24 * 60 * 60,
|
||||
),
|
||||
requestLimit: Number(
|
||||
options.hololakeOtpRequestLimit
|
||||
|| process.env.HOLOLAKE_OTP_REQUEST_LIMIT
|
||||
|| 6,
|
||||
),
|
||||
sendEmail,
|
||||
})
|
||||
: null
|
||||
);
|
||||
const hololakeKnowledgePath = String(
|
||||
options.hololakeKnowledgeRepositoryPath
|
||||
|| process.env.HOLOLAKE_KNOWLEDGE_REPOSITORY_PATH
|
||||
|| "",
|
||||
);
|
||||
const hololakeKnowledgeProvider = options.hololakeKnowledgeProvider || (
|
||||
hololakeKnowledgePath
|
||||
? new HoloLakeKnowledgeProvider({
|
||||
repositoryId: "bingshuo/hololake-knowledge-base",
|
||||
repositoryPath: hololakeKnowledgePath,
|
||||
maxArchiveBytes: Number(
|
||||
options.hololakeKnowledgeMaxArchiveBytes
|
||||
|| process.env.HOLOLAKE_KNOWLEDGE_MAX_ARCHIVE_BYTES
|
||||
|| 128 * 1024 * 1024,
|
||||
),
|
||||
})
|
||||
: null
|
||||
);
|
||||
const hololakeAiProvidersFile = String(
|
||||
options.hololakeAiProvidersFile
|
||||
|| process.env.HOLOLAKE_AI_PROVIDERS_FILE
|
||||
|| "",
|
||||
);
|
||||
const hololakeAiProviders = options.hololakeAiProviders || (
|
||||
hololakeAiProvidersFile ? loadAiProviders(hololakeAiProvidersFile) : {}
|
||||
);
|
||||
const hololakeAiGateway = options.hololakeAiGateway || (
|
||||
Object.keys(hololakeAiProviders).length > 0
|
||||
? new HoloLakeAiGateway({ providers: hololakeAiProviders })
|
||||
: null
|
||||
);
|
||||
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"),
|
||||
|
|
@ -139,6 +211,23 @@ function createApp(options = {}) {
|
|||
return { ok: true, order: issued.order };
|
||||
}
|
||||
|
||||
function authenticateHoloLake(req) {
|
||||
if (!hololakeSessionManager) {
|
||||
return { ok: false, status: 503, error: "hololake_session_unavailable" };
|
||||
}
|
||||
const token = bearer(req);
|
||||
if (!token) return { ok: false, status: 401, error: "session_required" };
|
||||
const deviceId = String(req.headers["x-hololake-device-id"] || "");
|
||||
const authenticated = hololakeSessionManager.authenticate(token, deviceId);
|
||||
if (!authenticated.ok) {
|
||||
return {
|
||||
...authenticated,
|
||||
status: authenticated.error === "session_device_mismatch" ? 403 : 401,
|
||||
};
|
||||
}
|
||||
return { ...authenticated, token, deviceId };
|
||||
}
|
||||
|
||||
function bindAndDeliver(created) {
|
||||
const inspected = manager.inspectHandoff(created.handoffToken);
|
||||
if (!inspected.ok) return { delivered: 0, approver: null, order: null };
|
||||
|
|
@ -190,6 +279,206 @@ function createApp(options = {}) {
|
|||
},
|
||||
});
|
||||
|
||||
if (
|
||||
req.method === "POST"
|
||||
&& url.pathname === "/api/hololake/session/email/request"
|
||||
) {
|
||||
if (!hololakeSessionManager) {
|
||||
return json(res, 503, failure("hololake_session_unavailable"));
|
||||
}
|
||||
const body = await readJson(req);
|
||||
if (!body) return json(res, 400, failure("invalid_json"));
|
||||
const headerDeviceId = String(
|
||||
req.headers["x-hololake-device-id"] || "",
|
||||
);
|
||||
if (
|
||||
headerDeviceId
|
||||
&& body.device_id
|
||||
&& !safeEqual(headerDeviceId, String(body.device_id))
|
||||
) {
|
||||
return json(res, 400, failure("invalid_device"));
|
||||
}
|
||||
const requested = await hololakeSessionManager.requestOtp({
|
||||
email: body.email,
|
||||
deviceId: String(body.device_id || headerDeviceId),
|
||||
networkKey: clientAddress(req),
|
||||
});
|
||||
if (!requested.accepted) {
|
||||
return json(
|
||||
res,
|
||||
requested.error === "rate_limited" ? 429 : 400,
|
||||
failure(requested.error),
|
||||
);
|
||||
}
|
||||
return json(res, 202, {
|
||||
accepted: true,
|
||||
request_id: requested.request_id,
|
||||
expires_in: Number(
|
||||
options.hololakeOtpTtlSeconds
|
||||
|| process.env.HOLOLAKE_OTP_TTL
|
||||
|| 10 * 60,
|
||||
),
|
||||
next_step: "如果邮箱已登记,输入邮件中的六位验证码。",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
req.method === "POST"
|
||||
&& url.pathname === "/api/hololake/session/email/verify"
|
||||
) {
|
||||
if (!hololakeSessionManager) {
|
||||
return json(res, 503, failure("hololake_session_unavailable"));
|
||||
}
|
||||
const body = await readJson(req);
|
||||
if (!body) return json(res, 400, failure("invalid_json"));
|
||||
const headerDeviceId = String(
|
||||
req.headers["x-hololake-device-id"] || "",
|
||||
);
|
||||
if (
|
||||
headerDeviceId
|
||||
&& body.device_id
|
||||
&& !safeEqual(headerDeviceId, String(body.device_id))
|
||||
) {
|
||||
return json(res, 400, failure("invalid_device"));
|
||||
}
|
||||
const verified = hololakeSessionManager.verifyOtp({
|
||||
requestId: body.request_id,
|
||||
code: body.code,
|
||||
deviceId: String(body.device_id || headerDeviceId),
|
||||
});
|
||||
if (!verified.ok) return json(res, 403, failure(verified.error));
|
||||
return json(res, 200, verified);
|
||||
}
|
||||
|
||||
if (
|
||||
(req.method === "GET" || req.method === "DELETE")
|
||||
&& url.pathname === "/api/hololake/session"
|
||||
) {
|
||||
const authenticated = authenticateHoloLake(req);
|
||||
if (!authenticated.ok) {
|
||||
return json(
|
||||
res,
|
||||
authenticated.status,
|
||||
failure(authenticated.error),
|
||||
);
|
||||
}
|
||||
if (req.method === "DELETE") {
|
||||
const revoked = hololakeSessionManager.revoke(
|
||||
authenticated.token,
|
||||
authenticated.deviceId,
|
||||
);
|
||||
return json(res, revoked.ok ? 200 : 401, revoked);
|
||||
}
|
||||
return json(res, 200, {
|
||||
ok: true,
|
||||
session: authenticated.session,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
req.method === "GET"
|
||||
&& url.pathname === "/api/hololake/knowledge/manifest"
|
||||
) {
|
||||
const authenticated = authenticateHoloLake(req);
|
||||
if (!authenticated.ok) {
|
||||
return json(
|
||||
res,
|
||||
authenticated.status,
|
||||
failure(authenticated.error),
|
||||
);
|
||||
}
|
||||
if (!hololakeKnowledgeProvider) {
|
||||
return json(res, 503, failure("knowledge_repository_unavailable"));
|
||||
}
|
||||
try {
|
||||
return json(res, 200, hololakeKnowledgeProvider.manifest());
|
||||
} catch {
|
||||
return json(res, 503, failure("knowledge_repository_unavailable"));
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
req.method === "GET"
|
||||
&& url.pathname === "/api/hololake/knowledge/archive"
|
||||
) {
|
||||
const authenticated = authenticateHoloLake(req);
|
||||
if (!authenticated.ok) {
|
||||
return json(
|
||||
res,
|
||||
authenticated.status,
|
||||
failure(authenticated.error),
|
||||
);
|
||||
}
|
||||
if (!hololakeKnowledgeProvider) {
|
||||
return json(res, 503, failure("knowledge_repository_unavailable"));
|
||||
}
|
||||
try {
|
||||
const archive = hololakeKnowledgeProvider.archive(
|
||||
url.searchParams.get("commit"),
|
||||
);
|
||||
res.writeHead(200, {
|
||||
"content-type": archive.content_type,
|
||||
"content-length": archive.body.length,
|
||||
"content-disposition": `attachment; filename="hololake-knowledge-${archive.commit}.zip"`,
|
||||
"cache-control": "private, no-store",
|
||||
"x-content-type-options": "nosniff",
|
||||
"x-hololake-commit": archive.commit,
|
||||
"x-content-sha256": archive.sha256,
|
||||
});
|
||||
res.end(archive.body);
|
||||
return;
|
||||
} catch (error) {
|
||||
const code = error && error.message === "knowledge_commit_not_current"
|
||||
? "knowledge_commit_not_current"
|
||||
: "knowledge_archive_unavailable";
|
||||
return json(res, code === "knowledge_commit_not_current" ? 409 : 503, failure(code));
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
req.method === "GET"
|
||||
&& url.pathname === "/api/hololake/ai/catalog"
|
||||
) {
|
||||
const authenticated = authenticateHoloLake(req);
|
||||
if (!authenticated.ok) {
|
||||
return json(
|
||||
res,
|
||||
authenticated.status,
|
||||
failure(authenticated.error),
|
||||
);
|
||||
}
|
||||
if (!hololakeAiGateway) {
|
||||
return json(res, 503, failure("ai_gateway_unavailable"));
|
||||
}
|
||||
return json(res, 200, hololakeAiGateway.catalog());
|
||||
}
|
||||
|
||||
if (
|
||||
req.method === "POST"
|
||||
&& url.pathname === "/api/hololake/ai/execute"
|
||||
) {
|
||||
const authenticated = authenticateHoloLake(req);
|
||||
if (!authenticated.ok) {
|
||||
return json(
|
||||
res,
|
||||
authenticated.status,
|
||||
failure(authenticated.error),
|
||||
);
|
||||
}
|
||||
if (!hololakeAiGateway) {
|
||||
return json(res, 503, failure("ai_gateway_unavailable"));
|
||||
}
|
||||
const body = await readJson(req);
|
||||
if (!body) return json(res, 400, failure("invalid_json"));
|
||||
try {
|
||||
return json(res, 200, await hololakeAiGateway.execute(body));
|
||||
} catch (error) {
|
||||
const code = String(error && error.message || "ai_gateway_failed");
|
||||
const status = code.startsWith("ai_upstream_") ? 502 : 400;
|
||||
return json(res, status, failure(code));
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/repositories/resolve") {
|
||||
const body = await readJson(req);
|
||||
if (!body) return json(res, 400, failure("invalid_json"));
|
||||
|
|
|
|||
Loading…
Reference in a new issue