278 lines
8.3 KiB
TypeScript
278 lines
8.3 KiB
TypeScript
import crypto from "node:crypto";
|
|
import type { NodeSession } from "../node-registry.js";
|
|
import type { GatewayRequestHandlers } from "./types.js";
|
|
import {
|
|
createBrowserControlContext,
|
|
startBrowserControlServiceFromConfig,
|
|
} from "../../browser/control-service.js";
|
|
import { createBrowserRouteDispatcher } from "../../browser/routes/dispatcher.js";
|
|
import { loadConfig } from "../../config/config.js";
|
|
import { saveMediaBuffer } from "../../media/store.js";
|
|
import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js";
|
|
import { ErrorCodes, errorShape } from "../protocol/index.js";
|
|
import { safeParseJson } from "./nodes.helpers.js";
|
|
|
|
type BrowserRequestParams = {
|
|
method?: string;
|
|
path?: string;
|
|
query?: Record<string, unknown>;
|
|
body?: unknown;
|
|
timeoutMs?: number;
|
|
};
|
|
|
|
type BrowserProxyFile = {
|
|
path: string;
|
|
base64: string;
|
|
mimeType?: string;
|
|
};
|
|
|
|
type BrowserProxyResult = {
|
|
result: unknown;
|
|
files?: BrowserProxyFile[];
|
|
};
|
|
|
|
function isBrowserNode(node: NodeSession) {
|
|
const caps = Array.isArray(node.caps) ? node.caps : [];
|
|
const commands = Array.isArray(node.commands) ? node.commands : [];
|
|
return caps.includes("browser") || commands.includes("browser.proxy");
|
|
}
|
|
|
|
function normalizeNodeKey(value: string) {
|
|
return value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "");
|
|
}
|
|
|
|
function resolveBrowserNode(nodes: NodeSession[], query: string): NodeSession | null {
|
|
const q = query.trim();
|
|
if (!q) {
|
|
return null;
|
|
}
|
|
const qNorm = normalizeNodeKey(q);
|
|
const matches = nodes.filter((node) => {
|
|
if (node.nodeId === q) {
|
|
return true;
|
|
}
|
|
if (typeof node.remoteIp === "string" && node.remoteIp === q) {
|
|
return true;
|
|
}
|
|
const name = typeof node.displayName === "string" ? node.displayName : "";
|
|
if (name && normalizeNodeKey(name) === qNorm) {
|
|
return true;
|
|
}
|
|
if (q.length >= 6 && node.nodeId.startsWith(q)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
if (matches.length === 1) {
|
|
return matches[0] ?? null;
|
|
}
|
|
if (matches.length === 0) {
|
|
return null;
|
|
}
|
|
throw new Error(
|
|
`ambiguous node: ${q} (matches: ${matches
|
|
.map((node) => node.displayName || node.remoteIp || node.nodeId)
|
|
.join(", ")})`,
|
|
);
|
|
}
|
|
|
|
function resolveBrowserNodeTarget(params: {
|
|
cfg: ReturnType<typeof loadConfig>;
|
|
nodes: NodeSession[];
|
|
}): NodeSession | null {
|
|
const policy = params.cfg.gateway?.nodes?.browser;
|
|
const mode = policy?.mode ?? "auto";
|
|
if (mode === "off") {
|
|
return null;
|
|
}
|
|
const browserNodes = params.nodes.filter((node) => isBrowserNode(node));
|
|
if (browserNodes.length === 0) {
|
|
if (policy?.node?.trim()) {
|
|
throw new Error("No connected browser-capable nodes.");
|
|
}
|
|
return null;
|
|
}
|
|
const requested = policy?.node?.trim() || "";
|
|
if (requested) {
|
|
const resolved = resolveBrowserNode(browserNodes, requested);
|
|
if (!resolved) {
|
|
throw new Error(`Configured browser node not connected: ${requested}`);
|
|
}
|
|
return resolved;
|
|
}
|
|
if (mode === "manual") {
|
|
return null;
|
|
}
|
|
if (browserNodes.length === 1) {
|
|
return browserNodes[0] ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function persistProxyFiles(files: BrowserProxyFile[] | undefined) {
|
|
if (!files || files.length === 0) {
|
|
return new Map<string, string>();
|
|
}
|
|
const mapping = new Map<string, string>();
|
|
for (const file of files) {
|
|
const buffer = Buffer.from(file.base64, "base64");
|
|
const saved = await saveMediaBuffer(buffer, file.mimeType, "browser", buffer.byteLength);
|
|
mapping.set(file.path, saved.path);
|
|
}
|
|
return mapping;
|
|
}
|
|
|
|
function applyProxyPaths(result: unknown, mapping: Map<string, string>) {
|
|
if (!result || typeof result !== "object") {
|
|
return;
|
|
}
|
|
const obj = result as Record<string, unknown>;
|
|
if (typeof obj.path === "string" && mapping.has(obj.path)) {
|
|
obj.path = mapping.get(obj.path);
|
|
}
|
|
if (typeof obj.imagePath === "string" && mapping.has(obj.imagePath)) {
|
|
obj.imagePath = mapping.get(obj.imagePath);
|
|
}
|
|
const download = obj.download;
|
|
if (download && typeof download === "object") {
|
|
const d = download as Record<string, unknown>;
|
|
if (typeof d.path === "string" && mapping.has(d.path)) {
|
|
d.path = mapping.get(d.path);
|
|
}
|
|
}
|
|
}
|
|
|
|
export const browserHandlers: GatewayRequestHandlers = {
|
|
"browser.request": async ({ params, respond, context }) => {
|
|
const typed = params as BrowserRequestParams;
|
|
const methodRaw = typeof typed.method === "string" ? typed.method.trim().toUpperCase() : "";
|
|
const path = typeof typed.path === "string" ? typed.path.trim() : "";
|
|
const query = typed.query && typeof typed.query === "object" ? typed.query : undefined;
|
|
const body = typed.body;
|
|
const timeoutMs =
|
|
typeof typed.timeoutMs === "number" && Number.isFinite(typed.timeoutMs)
|
|
? Math.max(1, Math.floor(typed.timeoutMs))
|
|
: undefined;
|
|
|
|
if (!methodRaw || !path) {
|
|
respond(
|
|
false,
|
|
undefined,
|
|
errorShape(ErrorCodes.INVALID_REQUEST, "method and path are required"),
|
|
);
|
|
return;
|
|
}
|
|
if (methodRaw !== "GET" && methodRaw !== "POST" && methodRaw !== "DELETE") {
|
|
respond(
|
|
false,
|
|
undefined,
|
|
errorShape(ErrorCodes.INVALID_REQUEST, "method must be GET, POST, or DELETE"),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const cfg = loadConfig();
|
|
let nodeTarget: NodeSession | null = null;
|
|
try {
|
|
nodeTarget = resolveBrowserNodeTarget({
|
|
cfg,
|
|
nodes: context.nodeRegistry.listConnected(),
|
|
});
|
|
} catch (err) {
|
|
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
|
|
return;
|
|
}
|
|
|
|
if (nodeTarget) {
|
|
const allowlist = resolveNodeCommandAllowlist(cfg, nodeTarget);
|
|
const allowed = isNodeCommandAllowed({
|
|
command: "browser.proxy",
|
|
declaredCommands: nodeTarget.commands,
|
|
allowlist,
|
|
});
|
|
if (!allowed.ok) {
|
|
respond(
|
|
false,
|
|
undefined,
|
|
errorShape(ErrorCodes.INVALID_REQUEST, "node command not allowed", {
|
|
details: { reason: allowed.reason, command: "browser.proxy" },
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const proxyParams = {
|
|
method: methodRaw,
|
|
path,
|
|
query,
|
|
body,
|
|
timeoutMs,
|
|
profile: typeof query?.profile === "string" ? query.profile : undefined,
|
|
};
|
|
const res = await context.nodeRegistry.invoke({
|
|
nodeId: nodeTarget.nodeId,
|
|
command: "browser.proxy",
|
|
params: proxyParams,
|
|
timeoutMs,
|
|
idempotencyKey: crypto.randomUUID(),
|
|
});
|
|
if (!res.ok) {
|
|
respond(
|
|
false,
|
|
undefined,
|
|
errorShape(ErrorCodes.UNAVAILABLE, res.error?.message ?? "node invoke failed", {
|
|
details: { nodeError: res.error ?? null },
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
const payload = res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload;
|
|
const proxy = payload && typeof payload === "object" ? (payload as BrowserProxyResult) : null;
|
|
if (!proxy || !("result" in proxy)) {
|
|
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "browser proxy failed"));
|
|
return;
|
|
}
|
|
const mapping = await persistProxyFiles(proxy.files);
|
|
applyProxyPaths(proxy.result, mapping);
|
|
respond(true, proxy.result);
|
|
return;
|
|
}
|
|
|
|
const ready = await startBrowserControlServiceFromConfig();
|
|
if (!ready) {
|
|
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "browser control is disabled"));
|
|
return;
|
|
}
|
|
|
|
let dispatcher;
|
|
try {
|
|
dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
|
|
} catch (err) {
|
|
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
|
|
return;
|
|
}
|
|
|
|
const result = await dispatcher.dispatch({
|
|
method: methodRaw,
|
|
path,
|
|
query,
|
|
body,
|
|
});
|
|
|
|
if (result.status >= 400) {
|
|
const message =
|
|
result.body && typeof result.body === "object" && "error" in result.body
|
|
? String((result.body as { error?: unknown }).error)
|
|
: `browser request failed (${result.status})`;
|
|
const code = result.status >= 500 ? ErrorCodes.UNAVAILABLE : ErrorCodes.INVALID_REQUEST;
|
|
respond(false, undefined, errorShape(code, message, { details: result.body }));
|
|
return;
|
|
}
|
|
|
|
respond(true, result.body);
|
|
},
|
|
};
|