feat(server): proxy browser desktop with lease-based control/view credentials
/api/v1/browser/desktop/{sessionId}/* proxies Selkies HTTP and WebSocket,
console sessions only. The holder of the active human lease gets the control
password, everyone else the view-only one; the WS bridge rechecks every 3s
and drops the connection on role change so the client reconnects downgraded.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
880cec4d23
commit
7e028fa23e
5 files changed
+563
-1
No files matched your search
@@ -0,0 +1,147 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AuthEnv, Principal, SessionVar } from "../../../http/auth.ts";
|
||||
import { ApiException, errorBody } from "../../../http/errors.ts";
|
||||
import {
|
||||
basicAuth,
|
||||
type DesktopDeps,
|
||||
type DesktopRole,
|
||||
deriveDesktopPasswords,
|
||||
desktopUpstream,
|
||||
registerDesktopRoutes,
|
||||
sameOrigin,
|
||||
} from "../desktop.ts";
|
||||
|
||||
// 桌面模式反代:口令派生与容器 init 脚本一致、角色 → 口令注入、只接受控制台会话、响应头与路径约束。
|
||||
|
||||
const SID = "7d3c1a52-9b0e-4b7a-8a51-0c2f7f7f0a11";
|
||||
const KEY = "p0key";
|
||||
|
||||
function harness(
|
||||
opts: { principal?: Partial<Principal>; role?: DesktopRole; check?: DesktopDeps["checkSession"] } = {},
|
||||
) {
|
||||
const calls: { url: string; headers: Headers; method: string }[] = [];
|
||||
const app = new OpenAPIHono<AuthEnv>();
|
||||
app.onError((err, c) => {
|
||||
if (err instanceof ApiException) return c.json(errorBody(err.code, err.message), err.status as 400);
|
||||
throw err;
|
||||
});
|
||||
app.use("*", async (c, next) => {
|
||||
const p: Principal = { kind: "admin_session", id: "u1", name: "admin", scopes: ["admin"], ...opts.principal };
|
||||
c.set("principal", p);
|
||||
c.set("session", p.kind === "admin_session" ? ({ id: "s1" } as SessionVar) : undefined);
|
||||
await next();
|
||||
});
|
||||
registerDesktopRoutes(app, {
|
||||
upstream: "http://camofox:3000",
|
||||
accessKey: KEY,
|
||||
checkSession:
|
||||
opts.check ??
|
||||
(async (_id, holder) => {
|
||||
expect(holder).toBe("admin_session:admin");
|
||||
return { ok: true, role: opts.role ?? "viewer", leaseExpiresAt: null };
|
||||
}),
|
||||
consoleSessionValid: async () => true,
|
||||
probeTtlMs: 0,
|
||||
fetch: (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
calls.push({ url, headers: new Headers(init?.headers), method: init?.method ?? "GET" });
|
||||
if (url.endsWith("/api/secret-401"))
|
||||
return new Response("no", { status: 401, headers: { "www-authenticate": "Basic" } });
|
||||
return new Response("ok", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html", "www-authenticate": "Basic", "x-frame-options": "DENY" },
|
||||
});
|
||||
}) as typeof fetch,
|
||||
});
|
||||
const root = new OpenAPIHono<AuthEnv>();
|
||||
root.onError((err, c) => {
|
||||
if (err instanceof ApiException) return c.json(errorBody(err.code, err.message), err.status as 400);
|
||||
throw err;
|
||||
});
|
||||
root.route("/api/v1", app);
|
||||
return { app: root, calls };
|
||||
}
|
||||
|
||||
describe("桌面口令与上游地址", () => {
|
||||
it("口令与容器 init-umt-desktop 的 openssl HMAC 派生一致", () => {
|
||||
// printf '%s' umt-desktop/control | openssl dgst -sha256 -hmac p0key -r | cut -c1-48
|
||||
const p = deriveDesktopPasswords(KEY);
|
||||
expect(p.controller).toBe("0b29251f95456b1e4612e3f4c45c2e353fd24e5ecbdf4959");
|
||||
expect(p.viewer).toHaveLength(48);
|
||||
expect(p.viewer).not.toBe(p.controller);
|
||||
});
|
||||
|
||||
it("缺省由浏览器服务地址推出(端口换 3000),可显式覆盖", () => {
|
||||
expect(desktopUpstream({ providerUrl: "http://camofox:9377" })).toBe("http://camofox:3000");
|
||||
expect(desktopUpstream({ providerUrl: "http://camofox:9377", desktopUrl: "http://desk:3000/" })).toBe(
|
||||
"http://desk:3000",
|
||||
);
|
||||
expect(desktopUpstream({})).toBeNull();
|
||||
});
|
||||
|
||||
it("WebSocket 只接受同源页面", () => {
|
||||
const req = (origin: string | null, host = "media.example.com") =>
|
||||
new Request("https://media.example.com/x", { headers: { host, ...(origin ? { origin } : {}) } });
|
||||
expect(sameOrigin(req("https://media.example.com"))).toBe(true);
|
||||
expect(sameOrigin(req("https://evil.example"))).toBe(false);
|
||||
expect(sameOrigin(req(null))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("桌面反代", () => {
|
||||
it("外部 API Key 不能用桌面模式", async () => {
|
||||
const { app } = harness({ principal: { kind: "api_key" } });
|
||||
const r = await app.request(`/api/v1/browser/desktop/${SID}/`);
|
||||
expect(r.status).toBe(403);
|
||||
});
|
||||
|
||||
it("状态:按租约给角色,并返回 iframe 地址", async () => {
|
||||
const { app } = harness({ role: "controller" });
|
||||
const r = await app.request(`/api/v1/browser/desktop/${SID}/_status`);
|
||||
const body = (await r.json()) as { data: { available: boolean; role: string; path: string } };
|
||||
expect(body.data).toMatchObject({ available: true, role: "controller", path: `/api/v1/browser/desktop/${SID}/` });
|
||||
});
|
||||
|
||||
it("静态资源:不带口令、不转发 cookie;响应只允许本站嵌入,不外露认证质询", async () => {
|
||||
const { app, calls } = harness();
|
||||
const r = await app.request(`/api/v1/browser/desktop/${SID}/assets/index.js?v=1`, {
|
||||
headers: { cookie: "umt_session=secret" },
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(calls.at(-1)?.url).toBe("http://camofox:3000/assets/index.js?v=1");
|
||||
expect(calls.at(-1)?.headers.get("authorization")).toBeNull();
|
||||
expect(calls.at(-1)?.headers.get("cookie")).toBeNull();
|
||||
expect(r.headers.get("content-security-policy")).toBe("frame-ancestors 'self'");
|
||||
expect(r.headers.get("x-frame-options")).toBe("SAMEORIGIN");
|
||||
expect(r.headers.get("www-authenticate")).toBeNull();
|
||||
});
|
||||
|
||||
it("Selkies 接口:只读 / 控制口令按角色注入", async () => {
|
||||
const pw = deriveDesktopPasswords(KEY);
|
||||
for (const role of ["viewer", "controller"] as const) {
|
||||
const { app, calls } = harness({ role });
|
||||
await app.request(`/api/v1/browser/desktop/${SID}/api/websockets`);
|
||||
expect(calls.at(-1)?.headers.get("authorization")).toBe(basicAuth(pw[role]));
|
||||
}
|
||||
});
|
||||
|
||||
it("上游 401 不透传质询,报口令不匹配", async () => {
|
||||
const { app } = harness();
|
||||
const r = await app.request(`/api/v1/browser/desktop/${SID}/api/secret-401`);
|
||||
expect(r.status).toBe(502);
|
||||
expect(r.headers.get("www-authenticate")).toBeNull();
|
||||
});
|
||||
|
||||
it("会话不属于自带浏览器服务 → 409;非法会话 id → 404;WebSocket 跨源 → 403", async () => {
|
||||
const { app } = harness({
|
||||
check: async () => ({ ok: false, code: "unsupported", message: "只有部署自带的浏览器服务支持桌面模式" }),
|
||||
});
|
||||
expect((await app.request(`/api/v1/browser/desktop/${SID}/api/websockets`)).status).toBe(409);
|
||||
expect((await app.request("/api/v1/browser/desktop/not-a-uuid/")).status).toBe(404);
|
||||
const ws = await harness().app.request(`/api/v1/browser/desktop/${SID}/api/websockets`, {
|
||||
headers: { upgrade: "websocket", origin: "https://evil.example", host: "media.example.com" },
|
||||
});
|
||||
expect(ws.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import type { DB, Kysely } from "@umt/db";
|
||||
import type { Context } from "hono";
|
||||
import type { AuthEnv } from "../../http/auth.ts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import type { ModuleApp } from "../types.ts";
|
||||
|
||||
// 原站视图桌面模式(docs/design/webtop-remote-browser.md §5.6、docs/runbook/browser.md §8):
|
||||
// /api/v1/browser/desktop/{sessionId}/* 反代浏览器桌面容器里的 Selkies(nginx :3000,静态客户端 + /api/websockets)。
|
||||
// - 只接受控制台 cookie 会话(外部 API Key、DEV key 一律拒绝);Selkies 端口不映射到宿主,唯一入口是这里。
|
||||
// - Selkies 口令由服务端注入(Authorization: Basic),浏览器永远拿不到:持有该会话 active 人工租约的本人 → 控制口令,
|
||||
// 其他人 → 只读口令(Selkies 服务端把只读口令的连接限为 viewer:键鼠/剪贴板输入一律丢弃)。
|
||||
// - 口令 = HMAC-SHA256(CAMOFOX_ACCESS_KEY, "umt-desktop/control" | "umt-desktop/view") 的前 48 位 hex,
|
||||
// 容器 init-umt-desktop 用 openssl 算同一个值,不另设密钥。
|
||||
// - WebSocket 连接期间每隔 roleCheckMs 重新判定角色;租约交还/过期/被夺、控制台会话失效时主动断开,
|
||||
// Selkies 客户端自动重连并按新角色认证(降级为只读)。
|
||||
|
||||
export const DESKTOP_USER = "umt";
|
||||
const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
/** 客户端能拿到的路径前缀里 _status 保留给本模块;其余原样转给 Selkies */
|
||||
const STATUS_SEGMENT = "_status";
|
||||
|
||||
export type DesktopRole = "controller" | "viewer";
|
||||
|
||||
export function deriveDesktopPasswords(accessKey: string): Record<DesktopRole, string> {
|
||||
const d = (label: string) => createHmac("sha256", accessKey).update(label).digest("hex").slice(0, 48);
|
||||
return { controller: d("umt-desktop/control"), viewer: d("umt-desktop/view") };
|
||||
}
|
||||
|
||||
export function basicAuth(password: string): string {
|
||||
return `Basic ${btoa(`${DESKTOP_USER}:${password}`)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 桌面上游地址:BROWSER_DESKTOP_URL 显式指定;否则取 BROWSER_PROVIDER_URL 的主机、端口换成 3000
|
||||
* (compose 缺省 http://camofox:9377 → http://camofox:3000)。
|
||||
*/
|
||||
export function desktopUpstream(env: { providerUrl?: string; desktopUrl?: string }): string | null {
|
||||
if (env.desktopUrl) return env.desktopUrl.replace(/\/+$/, "");
|
||||
if (!env.providerUrl) return null;
|
||||
try {
|
||||
const u = new URL(env.providerUrl);
|
||||
u.port = "3000";
|
||||
u.pathname = "";
|
||||
return u.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionCheck =
|
||||
| { ok: true; role: DesktopRole; leaseExpiresAt: string | null }
|
||||
| { ok: false; code: "not_found" | "closed" | "unsupported"; message: string };
|
||||
|
||||
export interface DesktopDeps {
|
||||
/** 上游 Selkies(nginx)根地址;null = 未配置 */
|
||||
upstream: string | null;
|
||||
/** 访问凭据(派生桌面口令);null = 未配置 */
|
||||
accessKey: string | null;
|
||||
/** 会话/租约判定:holder 与 browser 模块写租约时同一格式 `<principal.kind>:<name>` */
|
||||
checkSession(sessionId: string, holder: string): Promise<SessionCheck>;
|
||||
/** 控制台会话是否仍有效(WS 长连接期间复查) */
|
||||
consoleSessionValid(sessionRowId: string): Promise<boolean>;
|
||||
fetch?: typeof fetch;
|
||||
/** 角色复查间隔 */
|
||||
roleCheckMs?: number;
|
||||
/** 可用性探测缓存 */
|
||||
probeTtlMs?: number;
|
||||
log?: (msg: string, extra?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
/** 数据库实现:会话必须属于环境变量登记的浏览器服务(camofox-env,即 compose 自带的桌面容器) */
|
||||
export function dbSessionChecks(db: Kysely<DB>): Pick<DesktopDeps, "checkSession" | "consoleSessionValid"> {
|
||||
return {
|
||||
async checkSession(sessionId, holder) {
|
||||
const row = await db
|
||||
.selectFrom("browser_session as s")
|
||||
.innerJoin("browser_provider as p", "p.id", "s.provider_id")
|
||||
.select(["s.id", "s.closed_at", "p.config", "p.kind"])
|
||||
.where("s.id", "=", sessionId)
|
||||
.executeTakeFirst();
|
||||
if (!row) return { ok: false, code: "not_found", message: "浏览器会话不存在" };
|
||||
if (row.closed_at) return { ok: false, code: "closed", message: "浏览器会话已关闭" };
|
||||
const cfg = (row.config ?? {}) as Record<string, unknown>;
|
||||
if (row.kind !== "camofox" || cfg.managedBy !== "env")
|
||||
return { ok: false, code: "unsupported", message: "只有部署自带的浏览器服务(camofox-env)支持桌面模式" };
|
||||
const lease = await db
|
||||
.selectFrom("browser_control_lease")
|
||||
.select(["holder_kind", "holder", "expires_at"])
|
||||
.where("session_id", "=", sessionId)
|
||||
.where("status", "=", "active")
|
||||
.executeTakeFirst();
|
||||
const mine =
|
||||
lease?.holder_kind === "human" && lease.holder === holder && new Date(lease.expires_at).getTime() > Date.now();
|
||||
return {
|
||||
ok: true,
|
||||
role: mine ? "controller" : "viewer",
|
||||
leaseExpiresAt: mine ? new Date(lease.expires_at).toISOString() : null,
|
||||
};
|
||||
},
|
||||
async consoleSessionValid(id) {
|
||||
const s = await db
|
||||
.selectFrom("admin_session")
|
||||
.select(["expires_at", "revoked_at"])
|
||||
.where("id", "=", id)
|
||||
.executeTakeFirst();
|
||||
return !!s && !s.revoked_at && new Date(s.expires_at).getTime() > Date.now();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 只允许控制台会话;返回租约 holder 与会话行 id */
|
||||
function consoleActor(c: Context<AuthEnv>): { holder: string; sessionRowId: string } {
|
||||
const p = c.get("principal");
|
||||
const s = c.get("session");
|
||||
if (!p) throw new ApiException("unauthorized", "未登录");
|
||||
if (p.kind !== "admin_session" || !s) throw new ApiException("forbidden", "桌面模式只能在控制台登录后使用");
|
||||
return { holder: `${p.kind}:${p.name ?? p.id}`, sessionRowId: s.id };
|
||||
}
|
||||
|
||||
function sessionIdOf(c: Context<AuthEnv>): string {
|
||||
const id = c.req.param("sessionId") ?? "";
|
||||
if (!SESSION_ID_RE.test(id)) throw new ApiException("not_found", "浏览器会话不存在");
|
||||
return id;
|
||||
}
|
||||
|
||||
async function requireSession(deps: DesktopDeps, sessionId: string, holder: string) {
|
||||
const r = await deps.checkSession(sessionId, holder);
|
||||
if (!r.ok) throw new ApiException(r.code === "not_found" ? "not_found" : "conflict", r.message);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** 浏览器发来的 WebSocket 必须同源(cookie 会话 + WS 无 CORS,防跨站劫持) */
|
||||
export function sameOrigin(req: Request): boolean {
|
||||
const origin = req.headers.get("origin");
|
||||
if (!origin) return false;
|
||||
const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
|
||||
try {
|
||||
return !!host && new URL(origin).host === host.split(",")[0]?.trim();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const HOP_BY_HOP = [
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"content-length",
|
||||
"content-encoding",
|
||||
];
|
||||
|
||||
/** 转给上游的请求头:去掉 cookie / 本站鉴权 / 逐跳头,Authorization 由服务端决定 */
|
||||
function upstreamHeaders(req: Request, auth: string | null): Headers {
|
||||
const h = new Headers();
|
||||
for (const k of ["accept", "accept-language", "content-type", "range", "if-none-match", "if-modified-since"]) {
|
||||
const v = req.headers.get(k);
|
||||
if (v) h.set(k, v);
|
||||
}
|
||||
h.set("accept-encoding", "identity");
|
||||
if (auth) h.set("authorization", auth);
|
||||
return h;
|
||||
}
|
||||
|
||||
/** 回给浏览器的响应头:只允许本站 iframe 嵌入;上游的认证质询不外露(否则浏览器弹 Basic 登录框) */
|
||||
function downstreamHeaders(res: Response): Headers {
|
||||
const h = new Headers(res.headers);
|
||||
for (const k of HOP_BY_HOP) h.delete(k);
|
||||
h.delete("www-authenticate");
|
||||
h.delete("set-cookie");
|
||||
h.set("content-security-policy", "frame-ancestors 'self'");
|
||||
h.set("x-frame-options", "SAMEORIGIN");
|
||||
h.set("referrer-policy", "same-origin");
|
||||
return h;
|
||||
}
|
||||
|
||||
export interface DesktopStatus {
|
||||
available: boolean;
|
||||
/** 不可用原因(给界面说明,回退截图模式) */
|
||||
reason: string | null;
|
||||
role: DesktopRole;
|
||||
leaseExpiresAt: string | null;
|
||||
/** 桌面客户端相对地址(iframe src) */
|
||||
path: string;
|
||||
/** WebCodecs / 剪贴板只在安全上下文可用:非 HTTPS(且非 localhost)时前端自动回退截图模式 */
|
||||
requiresSecureContext: true;
|
||||
}
|
||||
|
||||
export function registerDesktopRoutes(app: ModuleApp, deps: DesktopDeps): { closeAll(): void } {
|
||||
const doFetch = deps.fetch ?? fetch;
|
||||
const roleCheckMs = deps.roleCheckMs ?? 3000;
|
||||
const probeTtl = deps.probeTtlMs ?? 15_000;
|
||||
const passwords = deps.accessKey ? deriveDesktopPasswords(deps.accessKey) : null;
|
||||
const log = deps.log ?? (() => {});
|
||||
const live = new Set<() => void>();
|
||||
let probe: { at: number; ok: boolean; reason: string | null } | null = null;
|
||||
|
||||
async function probeUpstream(): Promise<{ ok: boolean; reason: string | null }> {
|
||||
if (!deps.upstream)
|
||||
return { ok: false, reason: "未配置浏览器桌面地址(BROWSER_PROVIDER_URL / BROWSER_DESKTOP_URL)" };
|
||||
if (!passwords) return { ok: false, reason: "浏览器服务未设置访问凭据 CAMOFOX_ACCESS_KEY,桌面模式不可用" };
|
||||
if (probe && Date.now() - probe.at < probeTtl) return probe;
|
||||
let ok = false;
|
||||
let reason: string | null = null;
|
||||
try {
|
||||
const r = await doFetch(`${deps.upstream}/`, { signal: AbortSignal.timeout(2500) });
|
||||
await r.body?.cancel();
|
||||
ok = r.ok;
|
||||
if (!ok) reason = `浏览器桌面返回 ${r.status}`;
|
||||
} catch {
|
||||
reason = "浏览器服务没有桌面(旧版 camofox 镜像或桌面未启动),使用截图模式";
|
||||
}
|
||||
probe = { at: Date.now(), ok, reason };
|
||||
return probe;
|
||||
}
|
||||
|
||||
app.get(`/browser/desktop/:sessionId/${STATUS_SEGMENT}`, async (c) => {
|
||||
const { holder } = consoleActor(c);
|
||||
const sessionId = sessionIdOf(c);
|
||||
const s = await requireSession(deps, sessionId, holder);
|
||||
const p = await probeUpstream();
|
||||
const data: DesktopStatus = {
|
||||
available: p.ok,
|
||||
reason: p.reason,
|
||||
role: s.role,
|
||||
leaseExpiresAt: s.leaseExpiresAt,
|
||||
path: `/api/v1/browser/desktop/${sessionId}/`,
|
||||
requiresSecureContext: true,
|
||||
};
|
||||
return c.json({ ok: true, data });
|
||||
});
|
||||
|
||||
// Selkies 客户端用相对路径(静态资源、api/websockets),目录地址必须以 / 结尾
|
||||
app.get("/browser/desktop/:sessionId", (c) => {
|
||||
consoleActor(c);
|
||||
return c.redirect(`/api/v1/browser/desktop/${sessionIdOf(c)}/`, 302);
|
||||
});
|
||||
|
||||
app.all("/browser/desktop/:sessionId/*", async (c) => {
|
||||
const { holder, sessionRowId } = consoleActor(c);
|
||||
const sessionId = sessionIdOf(c);
|
||||
if (!deps.upstream || !passwords) throw new ApiException("upstream_unavailable", "桌面模式未启用");
|
||||
const prefix = `/api/v1/browser/desktop/${sessionId}/`;
|
||||
const url = new URL(c.req.url);
|
||||
const rest = url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) : "";
|
||||
if (rest.split("/").some((seg) => seg === ".." || seg === ".")) throw new ApiException("bad_request", "路径不合法");
|
||||
const target = `${deps.upstream}/${rest}${url.search}`;
|
||||
const isApi = rest === "api" || rest.startsWith("api/");
|
||||
|
||||
if (c.req.header("upgrade")?.toLowerCase() === "websocket") {
|
||||
if (!sameOrigin(c.req.raw)) throw new ApiException("forbidden", "桌面连接必须来自本站页面");
|
||||
const s = await requireSession(deps, sessionId, holder);
|
||||
return bridgeWebSocket(c.req.raw, target.replace(/^http/, "ws"), s.role, {
|
||||
auth: (role) => basicAuth(passwords[role]),
|
||||
recheck: async () => {
|
||||
if (!(await deps.consoleSessionValid(sessionRowId))) return null;
|
||||
const r = await deps.checkSession(sessionId, holder);
|
||||
return r.ok ? r.role : null;
|
||||
},
|
||||
roleCheckMs,
|
||||
live,
|
||||
log,
|
||||
});
|
||||
}
|
||||
|
||||
// HTTP:静态客户端(nginx,无需口令)与 Selkies HTTP 接口(api/*,要口令)
|
||||
let auth: string | null = null;
|
||||
if (isApi) {
|
||||
const s = await requireSession(deps, sessionId, holder);
|
||||
auth = basicAuth(passwords[s.role]);
|
||||
}
|
||||
const method = c.req.method;
|
||||
if (!["GET", "HEAD"].includes(method) && !isApi) throw new ApiException("bad_request", "不支持的请求");
|
||||
let res: Response;
|
||||
try {
|
||||
res = await doFetch(target, {
|
||||
method,
|
||||
headers: upstreamHeaders(c.req.raw, auth),
|
||||
body: ["GET", "HEAD"].includes(method) ? undefined : c.req.raw.body,
|
||||
redirect: "manual",
|
||||
});
|
||||
} catch {
|
||||
throw new ApiException("upstream_unavailable", "浏览器桌面不可达");
|
||||
}
|
||||
if (res.status === 401) {
|
||||
await res.body?.cancel();
|
||||
log("selkies rejected desktop credentials", { status: 401 });
|
||||
throw new ApiException(
|
||||
"upstream_unavailable",
|
||||
"浏览器桌面口令不匹配。检查 app 与浏览器容器的 CAMOFOX_ACCESS_KEY 是否一致",
|
||||
);
|
||||
}
|
||||
return new Response(res.body, { status: res.status, headers: downstreamHeaders(res) });
|
||||
});
|
||||
|
||||
return {
|
||||
closeAll() {
|
||||
for (const close of [...live]) close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface BridgeOpts {
|
||||
auth(role: DesktopRole): string;
|
||||
/** 返回当前应有的角色;null = 不再允许(会话/控制台会话失效) */
|
||||
recheck(): Promise<DesktopRole | null>;
|
||||
roleCheckMs: number;
|
||||
live: Set<() => void>;
|
||||
log(msg: string, extra?: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
/** 浏览器 ⇄ app ⇄ Selkies 的 WebSocket 双向转发;角色变化时断开(客户端重连后按新角色认证) */
|
||||
function bridgeWebSocket(req: Request, target: string, role: DesktopRole, o: BridgeOpts): Response {
|
||||
const protocols = (req.headers.get("sec-websocket-protocol") ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const { socket: client, response } = Deno.upgradeWebSocket(req, protocols[0] ? { protocol: protocols[0] } : {});
|
||||
client.binaryType = "arraybuffer";
|
||||
// Deno 的客户端 WebSocket 支持自定义请求头;不带 Origin(Selkies 对无 Origin 的非浏览器客户端放行)
|
||||
// (tsc 按 DOM 类型只认 protocols 参数;Deno 运行时接受 { headers, protocols })
|
||||
const wsOptions = { headers: { authorization: o.auth(role) }, ...(protocols.length ? { protocols } : {}) };
|
||||
const upstream = new WebSocket(target, wsOptions as unknown as string[]);
|
||||
upstream.binaryType = "arraybuffer";
|
||||
const pending: (string | ArrayBuffer)[] = [];
|
||||
let closed = false;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const close = (code = 1000, reason = "") => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (timer !== undefined) clearInterval(timer);
|
||||
o.live.delete(close);
|
||||
try {
|
||||
if (client.readyState <= WebSocket.OPEN) client.close(code, reason);
|
||||
} catch {
|
||||
/* 已关闭 */
|
||||
}
|
||||
try {
|
||||
if (upstream.readyState <= WebSocket.OPEN) upstream.close(1000);
|
||||
} catch {
|
||||
/* 已关闭 */
|
||||
}
|
||||
};
|
||||
o.live.add(close);
|
||||
|
||||
client.onmessage = (e) => {
|
||||
if (upstream.readyState === WebSocket.OPEN) upstream.send(e.data);
|
||||
else if (upstream.readyState === WebSocket.CONNECTING) pending.push(e.data);
|
||||
};
|
||||
upstream.onopen = () => {
|
||||
for (const m of pending.splice(0)) upstream.send(m);
|
||||
};
|
||||
upstream.onmessage = (e) => {
|
||||
if (client.readyState === WebSocket.OPEN) client.send(e.data);
|
||||
};
|
||||
client.onclose = () => close();
|
||||
upstream.onclose = (e) => close(e.code === 1000 || e.code === 1001 ? 1000 : 1011, "upstream closed");
|
||||
client.onerror = () => close(1011);
|
||||
upstream.onerror = () => {
|
||||
o.log("desktop upstream websocket error");
|
||||
close(1011, "upstream error");
|
||||
};
|
||||
|
||||
timer = setInterval(async () => {
|
||||
try {
|
||||
const next = await o.recheck();
|
||||
if (next === null) close(4401, "session ended");
|
||||
else if (next !== role) close(4000, `role ${next}`);
|
||||
} catch (err) {
|
||||
o.log("desktop role recheck failed", { err: String(err) });
|
||||
}
|
||||
}, o.roleCheckMs);
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ServerContext, ServerModule } from "../types.ts";
|
||||
import { dbSessionChecks, desktopUpstream, registerDesktopRoutes } from "./desktop.ts";
|
||||
|
||||
/**
|
||||
* WT-1 原站视图桌面模式:/api/v1/browser/desktop/{sessionId}/* 反代浏览器桌面容器(Selkies)的 HTTP 与 WebSocket,
|
||||
* 按人工租约注入控制 / 只读口令。只用会话 / 租约表(只读),不改浏览器模块。
|
||||
* 桌面地址:BROWSER_DESKTOP_URL,缺省由 BROWSER_PROVIDER_URL 推出(端口换 3000)。
|
||||
*/
|
||||
export function createDesktopModule(): ServerModule {
|
||||
let closeAll: (() => void) | null = null;
|
||||
return {
|
||||
name: "desktop",
|
||||
registerRoutes(app, ctx: ServerContext) {
|
||||
const r = registerDesktopRoutes(app, {
|
||||
upstream: desktopUpstream({
|
||||
providerUrl: ctx.config?.BROWSER_PROVIDER_URL,
|
||||
desktopUrl: Deno.env.get("BROWSER_DESKTOP_URL") || undefined,
|
||||
}),
|
||||
accessKey: ctx.config?.BROWSER_ACCESS_KEY ?? null,
|
||||
...dbSessionChecks(ctx.db),
|
||||
log: (msg, extra) => ctx.logger.warn({ component: "desktop", ...extra }, msg),
|
||||
});
|
||||
closeAll = r.closeAll;
|
||||
},
|
||||
registerTasks() {},
|
||||
stop() {
|
||||
closeAll?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const desktopModule = createDesktopModule();
|
||||
@@ -3,6 +3,7 @@ import { agentModule } from "./agent/index.ts";
|
||||
import { authSettingsModule } from "./auth-settings/index.ts";
|
||||
import { browserModule } from "./browser/index.ts";
|
||||
import { cacheModule } from "./cache/index.ts";
|
||||
import { desktopModule } from "./desktop/index.ts";
|
||||
import { fileManagerModule } from "./file-manager/index.ts";
|
||||
import { mcpModule } from "./mcp/index.ts";
|
||||
import { piModule } from "./pi/index.ts";
|
||||
@@ -29,6 +30,7 @@ export const SERVER_MODULES: readonly ServerModule[] = [
|
||||
schedulesModule,
|
||||
searchModule,
|
||||
browserModule,
|
||||
desktopModule,
|
||||
mcpModule,
|
||||
webSearchModule,
|
||||
cacheModule,
|
||||
|
||||
Reference in new issue
Block a user