UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

587 lines (586 loc) 22.6 kB
import { t as createSubsystemLogger } from "./subsystem-Dy2tqXOS.js"; import { r as parseRfbVersionBanner } from "./rfb-probe-BHZn-QhU.js"; import { t as startWebSocketKeepalive } from "./websocket-keepalive-Jg8mBtsJ.js"; import net from "node:net"; import crypto, { createCipheriv, createHash, randomBytes } from "node:crypto"; import { WebSocket, WebSocketServer } from "ws"; //#region src/gateway/desktop/attachment.ts function connectRfbAttachment(attachment) { return attachment.kind === "unix-socket" ? net.connect(attachment.socketPath) : net.connect(attachment.port, attachment.host); } //#endregion //#region src/gateway/desktop/rfb-preauth.ts const RFB_VERSION_BYTES = 12; const RFB_3_8_VERSION$1 = Buffer.from("RFB 003.008\n", "ascii"); const RFB_SECURITY_NONE = 1; const RFB_SECURITY_VNC = 2; const RFB_SECURITY_ARD = 30; const MAX_ARD_KEY_BYTES = 1024; const MAX_REASON_BYTES = 65536; const DEFAULT_PREAUTH_TIMEOUT_MS = 1e4; var RfbPreauthTimeoutError = class extends Error { constructor() { super("RFB authentication negotiation timed out"); this.name = "RfbPreauthTimeoutError"; } }; function abortReason(signal) { return signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("RFB authentication negotiation aborted"); } /** Exact-byte queue shared by stream and WebSocket handshake adapters. */ var RfbPreauthBuffer = class { constructor() { this.buffered = Buffer.alloc(0); this.waiters = /* @__PURE__ */ new Set(); } push(chunk) { this.buffered = Buffer.concat([this.buffered, chunk]); this.wake(); } fail(error) { this.failure = error; this.wake(); } wake() { for (const waiter of this.waiters) waiter(); this.waiters.clear(); } async waitForData(signal) { if (signal.aborted) throw abortReason(signal); await new Promise((resolve, reject) => { const cleanup = () => { this.waiters.delete(onWake); signal.removeEventListener("abort", onAbort); }; const onWake = () => { cleanup(); resolve(); }; const onAbort = () => { cleanup(); reject(abortReason(signal)); }; this.waiters.add(onWake); signal.addEventListener("abort", onAbort, { once: true }); }); } async readExactly(length, signal) { while (this.buffered.length < length) { if (this.failure) throw this.failure; await this.waitForData(signal); } const value = this.buffered.subarray(0, length); this.buffered = this.buffered.subarray(length); return value; } takeBuffered() { const value = this.buffered; this.buffered = Buffer.alloc(0); return value; } }; var StreamRfbPreauthPeer = class { constructor(stream) { this.stream = stream; this.reader = new RfbPreauthBuffer(); this.onData = (chunk) => this.reader.push(chunk); this.onEnd = () => { this.reader.fail(/* @__PURE__ */ new Error("RFB peer closed during authentication negotiation")); }; this.onError = (error) => { this.reader.fail(error); }; stream.on("data", this.onData); stream.once("end", this.onEnd); stream.once("close", this.onEnd); stream.once("error", this.onError); } async readExactly(length, signal) { return await this.reader.readExactly(length, signal); } async write(buffer, signal) { if (signal.aborted) throw abortReason(signal); await new Promise((resolve, reject) => { const cleanup = () => signal.removeEventListener("abort", onAbort); const onAbort = () => { cleanup(); reject(abortReason(signal)); }; signal.addEventListener("abort", onAbort, { once: true }); this.stream.write(buffer, (error) => { cleanup(); if (error) reject(error); else resolve(); }); }); } dispose() { this.stream.off("data", this.onData); this.stream.off("end", this.onEnd); this.stream.off("close", this.onEnd); this.stream.off("error", this.onError); } }; async function readReason(peer, signal) { const length = (await peer.readExactly(4, signal)).readUInt32BE(0); if (length === 0) return ""; if (length > MAX_REASON_BYTES) throw new Error("RFB failure reason is too large"); return (await peer.readExactly(length, signal)).toString("utf8"); } async function selectSecurityType(params) { if (params.protocolMinor < 7) { const selected = (await params.peer.readExactly(4, params.signal)).readUInt32BE(0); if (selected === 0) { const reason = await readReason(params.peer, params.signal); throw new Error(`RFB server rejected security negotiation${reason ? `: ${reason}` : ""}`); } if (selected !== params.requiredType) throw new Error(`RFB server selected security type ${selected}, want ${params.requiredType}`); return; } const count = (await params.peer.readExactly(1, params.signal))[0] ?? 0; if (count === 0) { const reason = await readReason(params.peer, params.signal); throw new Error(`RFB server rejected security negotiation${reason ? `: ${reason}` : ""}`); } const offered = await params.peer.readExactly(count, params.signal); if (!offered.includes(params.requiredType)) throw new Error(`RFB server did not offer required security type ${params.requiredType} (offered ${[...offered].join(", ")})`); await params.peer.write(Buffer.from([params.requiredType]), params.signal); } function bufferToBigInt(value) { return value.length === 0 ? 0n : BigInt(`0x${value.toString("hex")}`); } function leftPadBigInt(value, length) { const hex = value.toString(16).padStart(2, "0"); let bytes = Buffer.from(hex.length % 2 === 0 ? hex : `0${hex}`, "hex"); if (bytes.length > length) bytes = bytes.subarray(bytes.length - length); const output = Buffer.alloc(length); bytes.copy(output, length - bytes.length); return output; } function modularExponentiation(base, exponent, modulus) { if (modulus <= 0n) throw new Error("invalid ARD Diffie-Hellman modulus"); let result = 1n; let factor = base % modulus; let power = exponent; while (power > 0n) { if ((power & 1n) === 1n) result = result * factor % modulus; factor = factor * factor % modulus; power >>= 1n; } return result; } function buildArdCredentialsBlock(username, password) { const block = randomBytes(128); const usernameBytes = Buffer.from(username, "utf8").subarray(0, 63); const passwordBytes = Buffer.from(password, "utf8").subarray(0, 63); usernameBytes.copy(block, 0); block[usernameBytes.length] = 0; passwordBytes.copy(block, 64); block[64 + passwordBytes.length] = 0; return block; } function encryptAesEcb(key, plaintext) { const cipher = createCipheriv("aes-128-ecb", key, null); cipher.setAutoPadding(false); return Buffer.concat([cipher.update(plaintext), cipher.final()]); } async function negotiateArdAuth(params) { const header = await params.peer.readExactly(4, params.signal); const keyLength = header.readUInt16BE(2); if (keyLength < 1 || keyLength > MAX_ARD_KEY_BYTES) throw new Error(`invalid ARD key length ${keyLength}`); const dhParameters = await params.peer.readExactly(keyLength * 2, params.signal); const generator = bufferToBigInt(header.subarray(0, 2)); const modulus = bufferToBigInt(dhParameters.subarray(0, keyLength)); const serverPublic = bufferToBigInt(dhParameters.subarray(keyLength)); if (generator === 0n || modulus === 0n || serverPublic === 0n) throw new Error("invalid ARD Diffie-Hellman parameters"); const privateKey = bufferToBigInt(randomBytes(keyLength)); const clientPublic = modularExponentiation(generator, privateKey, modulus); const shared = modularExponentiation(serverPublic, privateKey, modulus); const encryptedCredentials = encryptAesEcb(createHash("md5").update(leftPadBigInt(shared, keyLength)).digest(), buildArdCredentialsBlock(params.credentials.username, params.credentials.password)); await params.peer.write(Buffer.concat([encryptedCredentials, leftPadBigInt(clientPublic, keyLength)]), params.signal); } function reverseByteBits(value) { let input = value; let output = 0; for (let index = 0; index < 8; index += 1) { output = output << 1 | input & 1; input >>= 1; } return output; } function buildVncAuthResponse(password, challenge) { const key = Buffer.alloc(8); Buffer.from(password, "utf8").copy(key, 0, 0, 8); for (let index = 0; index < key.length; index += 1) key[index] = reverseByteBits(key[index] ?? 0); const cipher = createCipheriv("des-ede", Buffer.concat([key, key]), null); cipher.setAutoPadding(false); return Buffer.concat([cipher.update(challenge), cipher.final()]); } async function negotiateVncAuth(params) { if (!params.password) throw new Error("VNC password is required"); const challenge = await params.peer.readExactly(16, params.signal); await params.peer.write(buildVncAuthResponse(params.password, challenge), params.signal); } async function readSecurityResult(peer, signal) { const status = (await peer.readExactly(4, signal)).readUInt32BE(0); if (status === 0) return; let reason = ""; try { reason = await readReason(peer, signal); } catch {} throw new Error(reason ? `RFB authentication failed: ${reason}` : `RFB authentication failed with status ${status}`); } async function negotiateServer(params) { if (params.preauth.auth === "ard-account" && (!params.preauth.credentials.username || !params.preauth.credentials.password)) throw new Error("ARD account username and password are required"); const banner = await params.peer.readExactly(RFB_VERSION_BYTES, params.signal); const version = parseRfbVersionBanner(banner); if (version.kind !== "rfb") throw new Error(`unsupported RFB protocol version ${JSON.stringify(version.banner)}`); await params.peer.write(version.reply, params.signal); const requiredType = params.preauth.auth === "ard-account" ? RFB_SECURITY_ARD : RFB_SECURITY_VNC; await selectSecurityType({ peer: params.peer, protocolMinor: version.minor, requiredType, signal: params.signal }); if (params.preauth.auth === "ard-account") await negotiateArdAuth({ peer: params.peer, credentials: params.preauth.credentials, signal: params.signal }); else await negotiateVncAuth({ peer: params.peer, password: params.preauth.credentials.password, signal: params.signal }); await readSecurityResult(params.peer, params.signal); } async function synthesizeBrowserHandshake(browser, signal) { await browser.write(RFB_3_8_VERSION$1, signal); if (!(await browser.readExactly(RFB_VERSION_BYTES, signal)).equals(RFB_3_8_VERSION$1)) throw new Error("RFB browser did not accept protocol version 3.8"); await browser.write(Buffer.from([1, RFB_SECURITY_NONE]), signal); if ((await browser.readExactly(1, signal))[0] !== RFB_SECURITY_NONE) throw new Error("RFB browser did not select no authentication"); await browser.write(Buffer.alloc(4), signal); } /** Authenticates the Gateway to an RFB server, then exposes a synthetic None handshake. */ async function preauthenticateRfb(params) { const server = new StreamRfbPreauthPeer(params.server); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(new RfbPreauthTimeoutError()), params.timeoutMs ?? DEFAULT_PREAUTH_TIMEOUT_MS); timeout.unref?.(); try { await negotiateServer({ peer: server, preauth: params.preauth, signal: controller.signal }); await synthesizeBrowserHandshake(params.browser, controller.signal); } finally { clearTimeout(timeout); server.dispose(); } } //#endregion //#region src/gateway/desktop/rfb-view-only-filter.ts const RFB_3_8_VERSION = Buffer.from("RFB 003.008\n", "ascii"); const MAX_PENDING_BYTES = 65536; const FIXED_PHASE_LENGTHS = { version: RFB_3_8_VERSION.length, security: 1, authResponse: 16, clientInit: 1 }; /** Filters one view-only RFB client byte stream without trusting WebSocket frame boundaries. */ function createRfbClientMessageFilter(options = {}) { let phase = options.startPhase ?? "version"; let pending = Buffer.alloc(0); let failure; const fail = (error) => { failure = error; pending = Buffer.alloc(0); return { error }; }; const pendingTargetLength = () => { if (phase !== "messages") return FIXED_PHASE_LENGTHS[phase]; if (pending.length === 0) return 1; switch (pending[0]) { case 0: return 20; case 2: return pending.length < 4 ? 4 : 4 + pending.readUInt16BE(2) * 4; case 3: return 10; case 4: return 8; case 5: return pending.length < 2 ? 2 : (pending.readUInt8(1) & 128) !== 0 ? 7 : 6; case 6: return pending.length < 8 ? 8 : 8 + Math.abs(pending.readInt32BE(4)); case 150: return 10; case 248: return pending.length < 9 ? 9 : 9 + pending.readUInt8(8); case 251: return 24; case 255: return 12; default: return `unsupported RFB client message type ${pending[0]}`; } }; const routePending = (forwarded) => { if (phase === "version") { if (!pending.equals(RFB_3_8_VERSION)) return "unsupported RFB protocol version"; forwarded.push(pending); phase = "security"; } else if (phase === "security") { const securityType = pending[0]; forwarded.push(pending); if (securityType === 1) phase = "clientInit"; else if (securityType === 2) phase = "authResponse"; else return `unsupported RFB security type ${securityType}`; } else if (phase === "authResponse") { forwarded.push(pending); phase = "clientInit"; } else if (phase === "clientInit") { pending[0] = 1; forwarded.push(pending); phase = "messages"; } else if (pending[0] === 0 || pending[0] === 2 || pending[0] === 3 || pending[0] === 150 || pending[0] === 248) forwarded.push(pending); pending = Buffer.alloc(0); }; return { filter(chunk) { if (failure) return { error: failure }; const forwarded = []; let offset = 0; while (offset < chunk.length) { const target = pendingTargetLength(); if (typeof target === "string") return fail(target); if (target > MAX_PENDING_BYTES) return fail("RFB client message exceeds the 64 KiB buffer limit"); const take = Math.min(target - pending.length, chunk.length - offset); pending = Buffer.concat([pending, chunk.subarray(offset, offset + take)]); offset += take; const completedTarget = pendingTargetLength(); if (typeof completedTarget === "string") return fail(completedTarget); if (completedTarget > MAX_PENDING_BYTES) return fail("RFB client message exceeds the 64 KiB buffer limit"); if (pending.length < completedTarget) continue; const error = routePending(forwarded); if (error) return fail(error); } return { forward: Buffer.concat(forwarded) }; } }; } //#endregion //#region src/gateway/desktop/observe-bridge.ts const DESKTOP_OBSERVE_PATH = "/desktop/observe"; const TOKEN_TTL_MS = 6e4; const TOKEN_PATTERN = /^[a-f0-9]{48}$/u; const MAX_PAYLOAD_BYTES = 1048576; const PAUSE_BUFFERED_BYTES = 4194304; const RESUME_CHECK_MS = 25; const observerLog = createSubsystemLogger("gateway/desktop"); const observerTokens = /* @__PURE__ */ new Map(); const desktopObserverWss = new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD_BYTES }); function deleteDesktopObserverToken(token) { clearTimeout(observerTokens.get(token)?.expiryTimer); observerTokens.delete(token); } function mintDesktopObserverToken(params) { const nowMs = params.nowMs ?? Date.now(); const token = crypto.randomBytes(24).toString("hex"); const expiresAtMs = nowMs + TOKEN_TTL_MS; const expiryTimer = setTimeout(() => deleteDesktopObserverToken(token), TOKEN_TTL_MS); expiryTimer.unref?.(); observerTokens.set(token, { sourceKey: params.sourceKey, ownerEpoch: params.ownerEpoch, control: params.control, attachment: params.attachment, ...params.preauth ? { preauth: params.preauth } : {}, expiresAt: expiresAtMs, expiryTimer }); return { token, expiresAtMs }; } function consumeDesktopObserverToken(token, nowMs = Date.now()) { const normalized = token.trim(); if (!TOKEN_PATTERN.test(normalized)) return; const entry = observerTokens.get(normalized); if (!entry) return; deleteDesktopObserverToken(normalized); return entry.expiresAt > nowMs ? entry : void 0; } function writeUnauthorized(socket) { socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n"); socket.destroy(); } function rawDataBuffer(data) { if (Buffer.isBuffer(data)) return data; if (Array.isArray(data)) return Buffer.concat(data); return Buffer.from(data); } var WebSocketPreauthPeer = class { constructor(ws) { this.ws = ws; this.reader = new RfbPreauthBuffer(); this.onMessage = (data, isBinary) => { if (!isBinary) this.reader.fail(/* @__PURE__ */ new Error("RFB browser sent a non-binary handshake frame")); else this.reader.push(rawDataBuffer(data)); }; this.onClose = () => { this.reader.fail(/* @__PURE__ */ new Error("RFB browser closed during authentication negotiation")); }; this.onError = () => { this.reader.fail(/* @__PURE__ */ new Error("RFB browser failed during authentication negotiation")); }; ws.on("message", this.onMessage); ws.once("close", this.onClose); ws.once("error", this.onError); } async readExactly(length, signal) { return await this.reader.readExactly(length, signal); } async write(buffer, signal) { if (signal.aborted) throw signal.reason; await new Promise((resolve, reject) => { const cleanup = () => signal.removeEventListener("abort", onAbort); const onAbort = () => { cleanup(); reject(signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("RFB authentication negotiation aborted")); }; signal.addEventListener("abort", onAbort, { once: true }); this.ws.send(buffer, { binary: true }, (error) => { cleanup(); if (error) reject(error); else resolve(); }); }); } detach() { this.ws.off("message", this.onMessage); this.ws.off("close", this.onClose); this.ws.off("error", this.onError); return this.reader.takeBuffered(); } }; /** Upgrades one authenticated observer token into a raw bidirectional RFB stream. */ function handleDesktopObserveUpgrade(req, socket, head, deps) { const resource = new URL(req.url ?? "/", "http://127.0.0.1"); if (resource.pathname !== "/desktop/observe") return false; const entry = consumeDesktopObserverToken(resource.searchParams.get("token") ?? ""); if (!entry) { writeUnauthorized(socket); return true; } desktopObserverWss.handleUpgrade(req, socket, head, (ws) => { const claimedStream = entry.attachment.kind === "stream" ? deps.registry.claimStream(entry.sourceKey, entry.attachment) : void 0; if (entry.attachment.kind === "stream" && !claimedStream) { ws.close(1013, "desktop stream unavailable"); return; } const observer = deps.registry.attachObserver(entry.sourceKey, { control: entry.control, ownerEpoch: entry.ownerEpoch, close: (code, reason) => closeBoth(code, reason, "owner-close") }); if (!observer) { claimedStream?.destroy(); ws.close(1013, "desktop observer limit"); return; } const desktopSocket = entry.attachment.kind === "stream" ? claimedStream : connectRfbAttachment(entry.attachment); if (!desktopSocket) { observer.release(); ws.close(1013, "desktop stream unavailable"); return; } let closeCause; let negotiating = Boolean(entry.preauth); let resumeTimer; const stopKeepalive = startWebSocketKeepalive(ws); const resumeWebSocket = () => ws.resume(); desktopSocket.on("drain", resumeWebSocket); const closeBoth = (code, reason, trigger) => { if (closeCause) return; closeCause = { trigger, code }; stopKeepalive(); clearInterval(resumeTimer); resumeTimer = void 0; observer.release(); desktopSocket.off("drain", resumeWebSocket); ws.resume(); desktopSocket.destroy(); if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) ws.close(code, reason); }; const startSplice = (browserRemainder = Buffer.alloc(0), preauthenticated = false) => { const clientMessageFilter = entry.control ? void 0 : createRfbClientMessageFilter({ startPhase: preauthenticated ? "clientInit" : "version" }); const forwardClientChunk = (chunk) => { const result = clientMessageFilter?.filter(chunk); if (result && "error" in result) { closeBoth(1008, "invalid view-only RFB stream", "invalid-view-only-stream"); return; } const forward = result?.forward ?? chunk; if (forward.length > 0 && !desktopSocket.write(forward)) ws.pause(); }; ws.on("message", (data, isBinary) => { if (!isBinary || closeCause) return; forwardClientChunk(rawDataBuffer(data)); }); desktopSocket.on("data", (chunk) => { if (closeCause || ws.readyState !== WebSocket.OPEN) return; ws.send(chunk, { binary: true }); const bufferedAmount = () => deps.getBufferedAmount?.(ws) ?? ws.bufferedAmount; if (bufferedAmount() <= PAUSE_BUFFERED_BYTES || resumeTimer) return; desktopSocket.pause(); resumeTimer = setInterval(() => { if (bufferedAmount() <= PAUSE_BUFFERED_BYTES) { clearInterval(resumeTimer); resumeTimer = void 0; desktopSocket.resume(); } }, RESUME_CHECK_MS); resumeTimer.unref?.(); }); if (browserRemainder.length > 0) forwardClientChunk(browserRemainder); }; ws.once("close", (closeCode) => { closeBoth(1e3, "desktop observer closed", "browser-close"); observerLog.info("desktop observer closed", { sourceKey: entry.sourceKey, ownerEpoch: entry.ownerEpoch, ...entry.attachment.kind === "stream" ? { streamId: entry.attachment.streamId } : {}, trigger: closeCause?.trigger, cleanupCode: closeCause?.code, closeCode }); }); ws.once("error", () => closeBoth(1011, "desktop observer failed", "browser-error")); desktopSocket.once("close", () => closeBoth(1e3, "desktop stream closed", "stream-close")); desktopSocket.once("error", () => closeBoth(negotiating ? 1008 : 1011, negotiating ? "desktop authentication failed" : "desktop stream failed", "stream-error")); if (!entry.preauth) { startSplice(); return; } const preauth = entry.preauth; const browser = new WebSocketPreauthPeer(ws); (async () => { try { await preauthenticateRfb({ server: desktopSocket, browser, preauth }); const remainder = browser.detach(); entry.preauth = void 0; negotiating = false; if (!closeCause) startSplice(remainder, true); } catch (error) { browser.detach(); entry.preauth = void 0; closeBoth(1008, error instanceof RfbPreauthTimeoutError ? "desktop authentication timed out" : `desktop ${preauth.auth === "ard-account" ? "ARD" : "VNC"} authentication failed`, "authentication-failed"); } })(); }); return true; } //#endregion export { handleDesktopObserveUpgrade as n, mintDesktopObserverToken as r, DESKTOP_OBSERVE_PATH as t };