Files
lobe-sandbox-backend/orchestrator/src/admin.ts
2026-04-19 22:51:12 +08:00

212 lines
6.8 KiB
TypeScript

import { Hono } from 'hono';
import { streamSSE } from 'hono/streaming';
import { env } from './env.ts';
import { getRecentEvents, onEvent } from './events.ts';
import { containerName, incus } from './incus.ts';
export const admin = new Hono();
// 鉴权中间件 — token 走 query(首屏 iframe/跳转友好)或 header
// 生成用户范围 token(供 LobeChat 内嵌 iframe 用,只能访问自己的沙箱)
export const scopedToken = async (userId: string): Promise<string> => {
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(env.orchSecret),
{ hash: 'SHA-256', name: 'HMAC' },
false,
['sign'],
);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`view:${userId}`));
return Buffer.from(sig).toString('base64url').slice(0, 32);
};
const tokenForUser = (token: string, userId: string) =>
scopedToken(userId).then((t) => t === token);
admin.use('*', async (c, next) => {
const token = c.req.query('token') ?? c.req.header('Admin-Token');
if (!token) return c.text('Unauthorized', 401);
// 1. admin token (全局)
if (env.adminToken && token === env.adminToken) {
await next();
return;
}
// 2. scoped token 针对单用户路由,验证 userId 匹配
const userId =
c.req.param('userId') ??
c.req.query('userId') ??
(c.req.path.match(/\/user\/([^\/]+)/)?.[1]);
if (userId && (await tokenForUser(token, userId))) {
c.set('scopedUserId', userId);
await next();
return;
}
return c.text('Unauthorized', 401);
});
// 允许 ai.milejoy.com / lobehub.kang-kang.com iframe 引用
admin.use('*', async (c, next) => {
await next();
c.header(
'Content-Security-Policy',
"frame-ancestors 'self' https://ai.milejoy.com https://lobehub.kang-kang.com",
);
c.header('X-Frame-Options', '');
});
// 全局用户列表
admin.get('/users', async (c) => {
const r = await Bun.spawn(
['incus', 'list', '--project', env.incus.project, '--format', 'json'],
{ stdout: 'pipe' },
);
const raw = await new Response(r.stdout).text();
await r.exited;
const instances = JSON.parse(raw || '[]') as any[];
const users = instances.map((inst) => ({
name: inst.name as string,
userId: inst.name.replace(new RegExp(`^${env.incus.prefix}`), ''),
state: String(inst.state?.status ?? 'UNKNOWN').toUpperCase(),
ipv4:
inst.state?.network?.eth0?.addresses?.find((a: any) => a.family === 'inet')?.address ?? '',
memory: inst.state?.memory?.usage ?? 0,
disk: inst.state?.disk?.root?.usage ?? 0,
cpuSeconds: inst.state?.cpu?.usage ?? 0,
lastUsedAt: inst.last_used_at as string,
}));
return c.json({ users });
});
// 给一个 userId 生成 scoped token(admin-token only;LobeChat 服务器端也要实现同样算法)
admin.get('/token/:userId', async (c) => {
const userId = c.req.param('userId');
const token = await scopedToken(userId);
return c.json({ userId, token, viewUrl: `/admin/user/${userId}?token=${token}` });
});
// 单用户 detail
admin.get('/user/:userId', async (c) => {
const userId = c.req.param('userId');
const name = containerName(userId);
const s = await incus.state(name);
return c.json({ userId, containerName: name, state: s });
});
// 文件树(递归平铺)
admin.get('/files', async (c) => {
const userId = c.req.query('userId');
if (!userId) return c.json({ error: 'userId required' }, 400);
const name = containerName(userId);
const s = await incus.state(name);
if (s === 'MISSING') return c.json({ files: [], error: 'no sandbox' });
if (s === 'STOPPED') return c.json({ files: [], note: 'sandbox stopped — start to view files' });
// 递归 + 安全限制:最多 500 个文件,排除 venv 里的包
const r = await incus.exec(
name,
[
'bash',
'-c',
`find /workspace -type f -not -path '*/.venv/lib/*' -not -path '*/node_modules/*' -printf '%T@\\t%s\\t%p\\n' 2>/dev/null | head -500`,
],
{ user: 1000 },
);
const files = r.stdout
.split('\n')
.filter(Boolean)
.map((line) => {
const [ts, size, path] = line.split('\t');
return {
path,
size: Number(size),
mtime: Math.floor(Number(ts) * 1000),
};
});
return c.json({ files });
});
// 文件内容(小文件 text / 大文件截断)
admin.get('/file', async (c) => {
const userId = c.req.query('userId');
const path = c.req.query('path');
if (!userId || !path) return c.json({ error: 'userId + path required' }, 400);
const name = containerName(userId);
// 安全:限制 /workspace 下,最大 2MB
if (!path.startsWith('/workspace')) return c.json({ error: 'path must be under /workspace' }, 403);
try {
const bytes = await incus.pull(name, path);
if (bytes.byteLength > 2 * 1024 * 1024) {
return c.json({ error: 'file too large (>2MB), download only' }, 413);
}
const ext = path.slice(path.lastIndexOf('.')).toLowerCase();
const isImage = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'].includes(ext);
if (isImage) {
return new Response(bytes, {
headers: { 'Content-Type': mimeFor(ext) },
});
}
// 文本返回 — 让前端自己渲染
return new Response(bytes, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
} catch (e) {
return c.json({ error: (e as Error).message }, 500);
}
});
// 手动 start/stop
admin.post('/user/:userId/start', async (c) => {
try {
await incus.start(containerName(c.req.param('userId')));
return c.json({ ok: true });
} catch (e) {
return c.json({ error: (e as Error).message }, 500);
}
});
admin.post('/user/:userId/stop', async (c) => {
try {
await incus.stop(containerName(c.req.param('userId')));
return c.json({ ok: true });
} catch (e) {
return c.json({ error: (e as Error).message }, 500);
}
});
// SSE 实时事件流
admin.get('/events', (c) => {
const userId = c.req.query('userId') ?? undefined;
return streamSSE(c, async (stream) => {
// 回放最近事件
for (const e of getRecentEvents(userId)) {
await stream.writeSSE({ data: JSON.stringify(e), event: 'event' });
}
let writing: Promise<unknown> = Promise.resolve();
const off = onEvent((e) => {
if (userId && e.userId !== userId) return;
writing = writing.then(() =>
stream.writeSSE({ data: JSON.stringify(e), event: 'event' }).catch(() => {}),
);
});
try {
// keepalive
while (!stream.aborted) {
await stream.sleep(30_000);
await stream.writeSSE({ data: String(Date.now()), event: 'ping' }).catch(() => {});
}
} finally {
off();
}
});
});
const mimeFor = (ext: string): string =>
({
'.gif': 'image/gif',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
})[ext] ?? 'application/octet-stream';