| import { constants as fsConstants } from "node:fs"; |
| import fs from "node:fs/promises"; |
| import { isRecord } from "@openclaw/normalization-core/record-coerce"; |
| import type { CloudflareAccessCredentials } from "../../packages/gateway-client/src/cloudflare-access.js"; |
| import type { DesktopHostConfig } from "../config/types.desktop.js"; |
| import { classifyRfbSecurity, connectRfbServer } from "../gateway/desktop/rfb-probe.js"; |
| import { registerSecretValueForRedaction } from "../logging/secret-redaction-registry.js"; |
| import { NODE_DESKTOP_ATTACH_PATH } from "../shared/node-desktop-stream.js"; |
| import { parseNodeWorkerDesktopStreamInput } from "../worker/node-desktop-protocol.js"; |
| import { runNodeStreamTransport } from "./node-stream-transport.js"; |
|
|
| const DEFAULT_DESKTOP_PORT = 5900; |
| const PROBE_TIMEOUT_MS = 1_500; |
| const TICKET_PATTERN = /^[a-f0-9]{48}$/u; |
| const MAX_VNC_PASSWORD_BYTES = 4 * 1024; |
|
|
| type NodeDesktopStreamCommandParams = { |
| ticket: string; |
| attachPath: string; |
| }; |
|
|
| type NodeDesktopStreamTarget = { |
| host: string; |
| port: number; |
| }; |
|
|
| function decodeDesktopStreamParams(raw?: string | null): NodeDesktopStreamCommandParams { |
| let value: unknown; |
| try { |
| value = raw ? JSON.parse(raw) : undefined; |
| } catch { |
| throw new Error("INVALID_REQUEST: desktop stream params malformed JSON"); |
| } |
| if (!isRecord(value)) { |
| throw new Error("INVALID_REQUEST: desktop stream params required"); |
| } |
| const ticket = typeof value.ticket === "string" ? value.ticket.trim() : ""; |
| const attachPath = typeof value.attachPath === "string" ? value.attachPath.trim() : ""; |
| if ( |
| !TICKET_PATTERN.test(ticket) || |
| attachPath !== `${NODE_DESKTOP_ATTACH_PATH}?ticket=${ticket}` |
| ) { |
| throw new Error("INVALID_REQUEST: desktop stream ticket and attachPath required"); |
| } |
| const attachUrl = new URL(attachPath, "http://127.0.0.1"); |
| if (attachUrl.searchParams.get("ticket") !== ticket) { |
| throw new Error("INVALID_REQUEST: desktop stream ticket does not match attachPath"); |
| } |
| if (Object.keys(value).some((key) => key !== "ticket" && key !== "attachPath")) { |
| throw new Error("INVALID_REQUEST: desktop stream params contain unsupported fields"); |
| } |
| return { ticket, attachPath }; |
| } |
|
|
| async function readVncPassword( |
| passwordFile: string | undefined, |
| signal: AbortSignal, |
| ): Promise<string | undefined> { |
| if (!passwordFile) { |
| return undefined; |
| } |
| signal.throwIfAborted(); |
| const handle = await fs.open(passwordFile, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK); |
| const buffer = Buffer.alloc(MAX_VNC_PASSWORD_BYTES + 1); |
| try { |
| const stat = await handle.stat(); |
| if (!stat.isFile()) { |
| throw new Error("desktop password file must be a regular file"); |
| } |
| if (stat.size > MAX_VNC_PASSWORD_BYTES) { |
| throw new Error("desktop password file is too large"); |
| } |
| signal.throwIfAborted(); |
| const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); |
| signal.throwIfAborted(); |
| if (bytesRead > MAX_VNC_PASSWORD_BYTES) { |
| throw new Error("desktop password file is too large"); |
| } |
| const password = buffer |
| .subarray(0, bytesRead) |
| .toString("utf8") |
| .replace(/[\r\n]+$/u, ""); |
| if (!password) { |
| throw new Error("desktop password file is empty"); |
| } |
| registerSecretValueForRedaction(password); |
| return password; |
| } finally { |
| buffer.fill(0); |
| await handle.close(); |
| } |
| } |
|
|
| |
| async function runNodeDesktopStreamCommand(params: { |
| command: NodeDesktopStreamCommandParams; |
| gatewayUrl: string; |
| gatewayTlsFingerprint?: string; |
| gatewayCloudflareAccess?: CloudflareAccessCredentials; |
| target: NodeDesktopStreamTarget; |
| passwordFile?: string; |
| signal: AbortSignal; |
| emitStatus?: (status: string) => Promise<void>; |
| }): Promise<void> { |
| if (params.target.host !== "127.0.0.1") { |
| throw new Error("desktop stream target must be loopback"); |
| } |
| if ( |
| !Number.isInteger(params.target.port) || |
| params.target.port < 1 || |
| params.target.port > 65535 |
| ) { |
| throw new Error("desktop stream target port is invalid"); |
| } |
| void params.emitStatus?.("probing local RFB server\n").catch(() => undefined); |
| const probe = await connectRfbServer({ |
| host: "127.0.0.1", |
| port: params.target.port, |
| timeoutMs: PROBE_TIMEOUT_MS, |
| signal: params.signal, |
| }); |
| if (probe.kind !== "rfb") { |
| throw new Error( |
| probe.kind === "not-rfb" |
| ? "desktop stream target is not an RFB server" |
| : "desktop stream loopback RFB server is unavailable", |
| ); |
| } |
| try { |
| const auth = classifyRfbSecurity(probe.securityTypes); |
| if (auth === "none") { |
| throw new Error("refusing unauthenticated loopback RFB server"); |
| } |
| if (auth === "unsupported") { |
| throw new Error("loopback RFB server security is unsupported"); |
| } |
| const vncPassword = |
| auth === "vnc-password" |
| ? await readVncPassword(params.passwordFile, params.signal) |
| : undefined; |
| if (params.signal.aborted) { |
| return; |
| } |
|
|
| await runNodeStreamTransport({ |
| gatewayUrl: params.gatewayUrl, |
| gatewayTlsFingerprint: params.gatewayTlsFingerprint, |
| gatewayCloudflareAccess: params.gatewayCloudflareAccess, |
| attachPath: params.command.attachPath, |
| expectedAttachPath: NODE_DESKTOP_ATTACH_PATH, |
| target: { stream: probe.stream }, |
| metadata: { auth, ...(vncPassword ? { vncPassword } : {}) }, |
| streamName: "desktop", |
| signal: params.signal, |
| emitStatus: params.emitStatus, |
| }); |
| } finally { |
| probe.stream.destroy(); |
| } |
| } |
|
|
| |
| export async function invokeNodeDesktopStream(params: { |
| paramsJSON?: string | null; |
| gatewayUrl?: string; |
| gatewayTlsFingerprint?: string; |
| gatewayCloudflareAccess?: CloudflareAccessCredentials; |
| config?: DesktopHostConfig; |
| signal?: AbortSignal; |
| emitStatus?: (status: string) => Promise<void>; |
| }): Promise<void> { |
| if (!params.gatewayUrl || !params.signal) { |
| throw new Error("desktop stream gateway connection is unavailable"); |
| } |
| if (params.config?.enabled !== true) { |
| throw new Error("desktop host streaming is disabled on this node"); |
| } |
| const command = decodeDesktopStreamParams(params.paramsJSON); |
| await runNodeDesktopStreamCommand({ |
| command, |
| gatewayUrl: params.gatewayUrl, |
| ...(params.gatewayTlsFingerprint |
| ? { gatewayTlsFingerprint: params.gatewayTlsFingerprint } |
| : {}), |
| ...(params.gatewayCloudflareAccess |
| ? { gatewayCloudflareAccess: params.gatewayCloudflareAccess } |
| : {}), |
| target: { |
| host: "127.0.0.1", |
| port: params.config.port ?? DEFAULT_DESKTOP_PORT, |
| }, |
| ...(params.config.passwordFile ? { passwordFile: params.config.passwordFile } : {}), |
| signal: params.signal, |
| ...(params.emitStatus ? { emitStatus: params.emitStatus } : {}), |
| }); |
| } |
|
|
| |
| export async function invokeNodeWorkerDesktopStream(params: { |
| paramsJSON?: string | null; |
| gatewayUrl?: string; |
| gatewayTlsFingerprint?: string; |
| gatewayCloudflareAccess?: CloudflareAccessCredentials; |
| signal?: AbortSignal; |
| }): Promise<void> { |
| if (!params.gatewayUrl || !params.signal) { |
| throw new Error("node worker desktop gateway connection is unavailable"); |
| } |
| const command = parseNodeWorkerDesktopStreamInput(params.paramsJSON); |
| await runNodeDesktopStreamCommand({ |
| command, |
| gatewayUrl: params.gatewayUrl, |
| ...(params.gatewayTlsFingerprint |
| ? { gatewayTlsFingerprint: params.gatewayTlsFingerprint } |
| : {}), |
| ...(params.gatewayCloudflareAccess |
| ? { gatewayCloudflareAccess: params.gatewayCloudflareAccess } |
| : {}), |
| target: { host: "127.0.0.1", port: command.port }, |
| ...(command.passwordFilePath ? { passwordFile: command.passwordFilePath } : {}), |
| signal: params.signal, |
| }); |
| } |
|
|