UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

391 lines (390 loc) 15 kB
import { a as normalizeLowercaseStringOrEmpty } from "./string-coerce-mnp54Vah.js"; import { y as truncateUtf16Safe } from "./utils-CCC-BEJH.js"; import { r as isLoopbackAddress } from "./net-DTe7AQiu.js"; import { i as getRuntimeConfig } from "./io-Gi7-pyU-.js"; import { a as resolvePreauthHandshakeTimeoutMs } from "./timeouts-DdTImbzl.js"; import "./startup-unavailable-CRTM-3cy.js"; import { o as isWebchatClient } from "./message-channel-BiOeMu0l.js"; import { s as removeRemoteNodeInfo } from "./remote-DoOmoEi1.js"; import { n as logRejectedLargePayload } from "./diagnostic-payload-DELJ7QXt.js"; import { a as MAX_PAYLOAD_BYTES, i as MAX_BUFFERED_BYTES, o as MAX_PREAUTH_PAYLOAD_BYTES } from "./server-constants-BGwLM6XN.js"; import { t as resolveHostedPluginSurfaceUrl } from "./hosted-plugin-surface-url-Dco7NxxG.js"; import { n as logWs } from "./ws-log-DlzuStZb.js"; import { t as resolveSharedGatewaySessionGeneration } from "./ws-shared-generation-Bp5l7wzu.js"; import { t as formatError } from "./server-utils-Co-s4JxP.js"; import { r as upsertPresence } from "./system-presence-DylNVezb.js"; import { a as incrementPresenceVersion, r as getHealthVersion } from "./health-state-Cha0Tz7F.js"; import { r as clearNodeWakeState } from "./nodes-wake-state-D3Lnk3Xv.js"; import { t as broadcastPresenceSnapshot } from "./presence-events-xNhbqa5e.js"; import { n as buildHandshakeAuthLogKey, r as shouldLimitMissingCredentialAuthLog, t as HandshakeAuthLogLimiter } from "./handshake-auth-log-limiter-C9RTAsnH.js"; import { randomUUID } from "node:crypto"; //#region src/gateway/server/ws-connection.ts const LOG_HEADER_MAX_LEN = 300; const LOG_HEADER_FORMAT_REGEX = /\p{Cf}/gu; const MAX_QUEUED_MESSAGE_HANDLER_FRAMES = 16; const unauthorizedCloseBeforeConnectLogLimiter = new HandshakeAuthLogLimiter(); function replaceControlChars(value) { let cleaned = ""; for (const char of value) { const codePoint = char.codePointAt(0); if (codePoint !== void 0 && (codePoint <= 31 || codePoint >= 127 && codePoint <= 159)) { cleaned += " "; continue; } cleaned += char; } return cleaned; } function stringMetaValue(meta, key) { const value = meta[key]; return typeof value === "string" && value.trim().length > 0 ? value : void 0; } const sanitizeLogValue = (value) => { if (!value) return; const cleaned = replaceControlChars(value).replace(LOG_HEADER_FORMAT_REGEX, " ").replace(/\s+/g, " ").trim(); if (!cleaned) return; if (cleaned.length <= LOG_HEADER_MAX_LEN) return cleaned; return truncateUtf16Safe(cleaned, LOG_HEADER_MAX_LEN); }; function formatSocketEndpoint(address, port) { if (!address) return; if (port === void 0) return address; return address.includes(":") ? `[${address}]:${port}` : `${address}:${port}`; } function resolveSocketAddress(socket) { const rawSocket = socket["_socket"]; const remoteAddr = rawSocket?.remoteAddress; const remotePort = rawSocket?.remotePort; const localAddr = rawSocket?.localAddress; const localPort = rawSocket?.localPort; const remoteEndpoint = formatSocketEndpoint(remoteAddr, remotePort); const localEndpoint = formatSocketEndpoint(localAddr, localPort); return { remoteAddr, remotePort, localAddr, localPort, endpoint: remoteEndpoint && localEndpoint ? `${remoteEndpoint}->${localEndpoint}` : remoteEndpoint ?? localEndpoint }; } function isWsPayloadLimitError(err) { if (!err || typeof err !== "object") return false; if (err.code === "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH") return true; const message = err.message; return typeof message === "string" && /max payload size exceeded/i.test(message); } function attachGatewayWsMessageHandlerOnDemand(params) { const queued = []; const queueMessage = (data) => { if (queued.length >= MAX_QUEUED_MESSAGE_HANDLER_FRAMES) { params.setCloseCause("message-handler-loading-overflow", { queuedFrames: queued.length }); params.close(1008, "gateway message handler loading"); return; } queued.push(data); }; params.socket.on("message", queueMessage); import("./message-handler-Cu13uhfp.js").then(({ attachGatewayWsMessageHandler }) => { params.socket.off("message", queueMessage); if (params.isClosed()) return; attachGatewayWsMessageHandler(params); for (const data of queued) params.socket.emit("message", data); }).catch((error) => { params.socket.off("message", queueMessage); params.setCloseCause("message-handler-load-failed", { error: formatError(error) }); params.logWsControl.warn(`failed to load ws message handler conn=${params.connId}: ${formatError(error)}`); params.close(1011, "gateway message handler unavailable"); }); } function attachGatewayWsConnectionHandler(params) { const { wss, clients, preauthConnectionBudget, port, pluginSurfaceScheme, getPluginNodeCapabilities, resolvedAuth, getResolvedAuth = () => resolvedAuth, getRequiredSharedGatewaySessionGeneration = () => resolveSharedGatewaySessionGeneration(getResolvedAuth(), getRuntimeConfig().gateway?.trustedProxies), rateLimiter, browserRateLimiter, isStartupPending, gatewayMethods, events, refreshHealthSnapshot, logGateway, logHealth, logWsControl, extraHandlers, getMethodRegistry, broadcast, buildRequestContext } = params; const originCheckMetrics = { hostHeaderFallbackAccepted: 0 }; wss.on("connection", (socket, upgradeReq) => { let client = null; let closed = false; const openedAt = Date.now(); const connId = randomUUID(); const { remoteAddr, remotePort, localAddr, localPort, endpoint } = resolveSocketAddress(socket); const preauthBudgetKey = socket["__openclawPreauthBudgetKey"]; socket["__openclawPreauthBudgetClaimed"] = true; const headerValue = (value) => Array.isArray(value) ? value[0] : value; const requestHost = headerValue(upgradeReq.headers.host); const requestOrigin = headerValue(upgradeReq.headers.origin); const requestUserAgent = headerValue(upgradeReq.headers["user-agent"]); const forwardedFor = headerValue(upgradeReq.headers["x-forwarded-for"]); const realIp = headerValue(upgradeReq.headers["x-real-ip"]); const pluginNodeCapabilities = getPluginNodeCapabilities?.() ?? []; const pluginSurfaceBaseUrl = pluginNodeCapabilities.length > 0 ? resolveHostedPluginSurfaceUrl({ port, forwardedHost: upgradeReq.headers["x-forwarded-host"], requestHost: upgradeReq.headers.host, forwardedProto: upgradeReq.headers["x-forwarded-proto"], localAddress: upgradeReq.socket?.localAddress, scheme: pluginSurfaceScheme }) : void 0; logWs("in", "open", { connId, remoteAddr, remotePort, localAddr, localPort, endpoint }); let handshakeState = "pending"; let holdsPreauthBudget = true; let closeCause; let closeMeta = {}; let lastFrameType; let lastFrameMethod; let lastFrameId; const setCloseCause = (cause, meta) => { if (!closeCause) closeCause = cause; if (meta && Object.keys(meta).length > 0) closeMeta = { ...closeMeta, ...meta }; }; const releasePreauthBudget = () => { if (!holdsPreauthBudget) return; holdsPreauthBudget = false; preauthConnectionBudget.release(preauthBudgetKey); }; const setLastFrameMeta = (meta) => { if (meta.type || meta.method || meta.id) { lastFrameType = meta.type ?? lastFrameType; lastFrameMethod = meta.method ?? lastFrameMethod; lastFrameId = meta.id ?? lastFrameId; } }; let pingTimer; const handshakeTimeoutMs = resolvePreauthHandshakeTimeoutMs({ configuredTimeoutMs: params.preauthHandshakeTimeoutMs }); const handshakeTimer = setTimeout(() => { if (!client) { handshakeState = "failed"; setCloseCause("handshake-timeout", { handshakeMs: Date.now() - openedAt, endpoint }); logWsControl.warn(`handshake timeout conn=${connId} peer=${endpoint ?? "n/a"} remote=${remoteAddr ?? "?"}`); close(); } }, handshakeTimeoutMs); const close = (code = 1e3, reason) => { if (closed) return; closed = true; clearTimeout(handshakeTimer); if (pingTimer !== void 0) clearInterval(pingTimer); releasePreauthBudget(); if (client) clients.delete(client); try { socket.close(code, reason); } catch {} }; const send = (obj) => { if (closed) return; if (socket.bufferedAmount > 52428800) { logRejectedLargePayload({ surface: "gateway.ws.outbound_buffer", bytes: socket.bufferedAmount, limitBytes: MAX_BUFFERED_BYTES, reason: "ws_send_buffer_close" }); setCloseCause("outbound-buffer-exceeded", { bytes: socket.bufferedAmount, limitBytes: MAX_BUFFERED_BYTES }); close(1008, "slow consumer"); return; } try { socket.send(JSON.stringify(obj)); } catch {} }; const connectNonce = randomUUID(); send({ type: "event", event: "connect.challenge", payload: { nonce: connectNonce, ts: Date.now() } }); socket.once("error", (err) => { if (isWsPayloadLimitError(err)) logRejectedLargePayload({ surface: client ? "gateway.ws.frame" : "gateway.ws.preauth", limitBytes: client ? MAX_PAYLOAD_BYTES : MAX_PREAUTH_PAYLOAD_BYTES, reason: client ? "ws_frame_limit" : "preauth_frame_limit" }); logWsControl.warn(`error conn=${connId} remote=${remoteAddr ?? "?"}: ${formatError(err)}`); close(); }); const isNoisySwiftPmHelperClose = (userAgent, remote) => normalizeLowercaseStringOrEmpty(userAgent).includes("swiftpm-testing-helper") && isLoopbackAddress(remote); socket.once("close", (code, reason) => { const durationMs = Date.now() - openedAt; const logForwardedFor = sanitizeLogValue(forwardedFor); const logOrigin = sanitizeLogValue(requestOrigin); const logHost = sanitizeLogValue(requestHost); const logUserAgent = sanitizeLogValue(requestUserAgent); const logReason = sanitizeLogValue(reason?.toString()); const closeContext = { cause: closeCause, handshake: handshakeState, durationMs, lastFrameType, lastFrameMethod, lastFrameId, host: logHost, origin: logOrigin, userAgent: logUserAgent, forwardedFor: logForwardedFor, remoteAddr, remotePort, localAddr, localPort, endpoint, ...closeMeta }; if (!client) { const isExpectedStartupRetryClose = closeCause === "startup-sidecars-pending" && code === 1013; const logFn = isNoisySwiftPmHelperClose(requestUserAgent, remoteAddr) || isExpectedStartupRetryClose ? logWsControl.debug : logWsControl.warn; const authReason = stringMetaValue(closeMeta, "authReason"); const closeLogDecision = closeCause === "unauthorized" && shouldLimitMissingCredentialAuthLog({ reason: authReason, authProvided: "none" }) ? unauthorizedCloseBeforeConnectLogLimiter.register(buildHandshakeAuthLogKey({ reason: authReason, remoteAddr, client: stringMetaValue(closeMeta, "clientDisplayName") ?? stringMetaValue(closeMeta, "client"), mode: stringMetaValue(closeMeta, "mode"), authProvided: "none" })) : { shouldLog: true, suppressedSinceLastLog: 0 }; if (closeLogDecision.shouldLog) { const suppressedText = closeLogDecision.suppressedSinceLastLog > 0 ? ` suppressed=${closeLogDecision.suppressedSinceLastLog}` : ""; logFn(`closed before connect conn=${connId} peer=${endpoint ?? "n/a"} remote=${remoteAddr ?? "?"} fwd=${logForwardedFor || "n/a"} origin=${logOrigin || "n/a"} host=${logHost || "n/a"} ua=${logUserAgent || "n/a"} code=${code ?? "n/a"} reason=${logReason || "n/a"}${suppressedText}`, closeContext); } } if (client && isWebchatClient(client.connect.client)) logWsControl.info(`webchat disconnected code=${code} reason=${logReason || "n/a"} conn=${connId}`); const context = buildRequestContext(); context.unsubscribeAllSessionEvents(connId); let currentDisconnectedNodeId = null; if (client?.connect?.role === "node") currentDisconnectedNodeId = context.nodeRegistry.unregister(connId); if (client?.presenceKey && (client.connect.role !== "node" || currentDisconnectedNodeId !== null)) { upsertPresence(client.presenceKey, { reason: "disconnect" }); broadcastPresenceSnapshot({ broadcast, incrementPresenceVersion, getHealthVersion }); } if (currentDisconnectedNodeId) { removeRemoteNodeInfo(currentDisconnectedNodeId); context.nodeUnsubscribeAll(currentDisconnectedNodeId); clearNodeWakeState(currentDisconnectedNodeId); } logWs("out", "close", { connId, code, reason: logReason, durationMs, cause: closeCause, handshake: handshakeState, lastFrameType, lastFrameMethod, lastFrameId, endpoint }); close(); }); attachGatewayWsMessageHandlerOnDemand({ socket, upgradeReq, connId, remoteAddr, remotePort, localAddr, localPort, endpoint, forwardedFor, realIp, requestHost, requestOrigin, requestUserAgent, pluginSurfaceBaseUrl, pluginNodeCapabilities, connectNonce, getResolvedAuth, getRequiredSharedGatewaySessionGeneration, rateLimiter, browserRateLimiter, isStartupPending, gatewayMethods, events, extraHandlers, getMethodRegistry, buildRequestContext, refreshHealthSnapshot, send, close, isClosed: () => closed, clearHandshakeTimer: () => clearTimeout(handshakeTimer), getClient: () => client, setClient: (next) => { if (closed) return false; releasePreauthBudget(); client = next; clients.add(next); pingTimer = setInterval(() => { try { socket.ping(); } catch {} }, 25e3); return true; }, setHandshakeState: (next) => { handshakeState = next; }, setCloseCause, setLastFrameMeta, originCheckMetrics, logGateway, logHealth, logWsControl }); }); } //#endregion //#region src/gateway/server-ws-runtime.ts /** Attaches websocket handlers for an already-created gateway request context. */ function attachGatewayWsHandlers(params) { attachGatewayWsConnectionHandler({ wss: params.wss, clients: params.clients, preauthConnectionBudget: params.preauthConnectionBudget, port: params.port, gatewayHost: params.gatewayHost, pluginSurfaceScheme: params.pluginSurfaceScheme, getPluginNodeCapabilities: params.getPluginNodeCapabilities, resolvedAuth: params.resolvedAuth, getResolvedAuth: params.getResolvedAuth, getRequiredSharedGatewaySessionGeneration: params.getRequiredSharedGatewaySessionGeneration, rateLimiter: params.rateLimiter, browserRateLimiter: params.browserRateLimiter, preauthHandshakeTimeoutMs: params.preauthHandshakeTimeoutMs, isStartupPending: params.isStartupPending, gatewayMethods: params.gatewayMethods, events: params.events, refreshHealthSnapshot: params.context.refreshHealthSnapshot, logGateway: params.logGateway, logHealth: params.logHealth, logWsControl: params.logWsControl, extraHandlers: params.extraHandlers, getMethodRegistry: params.getMethodRegistry, broadcast: params.broadcast, buildRequestContext: () => params.context }); } //#endregion export { attachGatewayWsHandlers };