mirror of https://github.com/openclaw/openclaw.git
1158 lines
43 KiB
TypeScript
1158 lines
43 KiB
TypeScript
import type { IncomingMessage } from "node:http";
|
|
import os from "node:os";
|
|
import type { WebSocket } from "ws";
|
|
import { loadConfig } from "../../../config/config.js";
|
|
import { verifyDeviceBootstrapToken } from "../../../infra/device-bootstrap.js";
|
|
import {
|
|
deriveDeviceIdFromPublicKey,
|
|
normalizeDevicePublicKeyBase64Url,
|
|
} from "../../../infra/device-identity.js";
|
|
import {
|
|
approveDevicePairing,
|
|
ensureDeviceToken,
|
|
getPairedDevice,
|
|
requestDevicePairing,
|
|
updatePairedDeviceMetadata,
|
|
verifyDeviceToken,
|
|
} from "../../../infra/device-pairing.js";
|
|
import { updatePairedNodeMetadata } from "../../../infra/node-pairing.js";
|
|
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../infra/skills-remote.js";
|
|
import { upsertPresence } from "../../../infra/system-presence.js";
|
|
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
|
|
import { rawDataToString } from "../../../infra/ws.js";
|
|
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
|
|
import { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
|
|
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
|
|
import { resolveRuntimeServiceVersion } from "../../../version.js";
|
|
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
|
|
import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js";
|
|
import { isLocalDirectRequest } from "../../auth.js";
|
|
import {
|
|
buildCanvasScopedHostUrl,
|
|
CANVAS_CAPABILITY_TTL_MS,
|
|
mintCanvasCapabilityToken,
|
|
} from "../../canvas-capability.js";
|
|
import { normalizeDeviceMetadataForAuth } from "../../device-auth.js";
|
|
import {
|
|
isLocalishHost,
|
|
isLoopbackAddress,
|
|
isTrustedProxyAddress,
|
|
resolveClientIp,
|
|
} from "../../net.js";
|
|
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
|
|
import { checkBrowserOrigin } from "../../origin-check.js";
|
|
import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js";
|
|
import {
|
|
ConnectErrorDetailCodes,
|
|
resolveDeviceAuthConnectErrorDetailCode,
|
|
resolveAuthConnectErrorDetailCode,
|
|
} from "../../protocol/connect-error-details.js";
|
|
import {
|
|
type ConnectParams,
|
|
ErrorCodes,
|
|
type ErrorShape,
|
|
errorShape,
|
|
formatValidationErrors,
|
|
PROTOCOL_VERSION,
|
|
validateConnectParams,
|
|
validateRequestFrame,
|
|
} from "../../protocol/index.js";
|
|
import { parseGatewayRole } from "../../role-policy.js";
|
|
import {
|
|
MAX_BUFFERED_BYTES,
|
|
MAX_PAYLOAD_BYTES,
|
|
MAX_PREAUTH_PAYLOAD_BYTES,
|
|
TICK_INTERVAL_MS,
|
|
} from "../../server-constants.js";
|
|
import { handleGatewayRequest } from "../../server-methods.js";
|
|
import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js";
|
|
import { formatError } from "../../server-utils.js";
|
|
import { formatForLog, logWs } from "../../ws-log.js";
|
|
import { truncateCloseReason } from "../close-reason.js";
|
|
import {
|
|
buildGatewaySnapshot,
|
|
getHealthCache,
|
|
getHealthVersion,
|
|
incrementPresenceVersion,
|
|
refreshGatewayHealthSnapshot,
|
|
} from "../health-state.js";
|
|
import type { GatewayWsClient } from "../ws-types.js";
|
|
import { resolveConnectAuthDecision, resolveConnectAuthState } from "./auth-context.js";
|
|
import { formatGatewayAuthFailureMessage } from "./auth-messages.js";
|
|
import {
|
|
evaluateMissingDeviceIdentity,
|
|
isTrustedProxyControlUiOperatorAuth,
|
|
resolveControlUiAuthPolicy,
|
|
shouldSkipControlUiPairing,
|
|
} from "./connect-policy.js";
|
|
import {
|
|
resolveDeviceSignaturePayloadVersion,
|
|
resolveHandshakeBrowserSecurityContext,
|
|
resolveUnauthorizedHandshakeContext,
|
|
shouldAllowSilentLocalPairing,
|
|
shouldSkipBackendSelfPairing,
|
|
} from "./handshake-auth-helpers.js";
|
|
import { isUnauthorizedRoleError, UnauthorizedFloodGuard } from "./unauthorized-flood-guard.js";
|
|
|
|
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
|
|
|
const DEVICE_SIGNATURE_SKEW_MS = 2 * 60 * 1000;
|
|
|
|
export type WsOriginCheckMetrics = {
|
|
hostHeaderFallbackAccepted: number;
|
|
};
|
|
|
|
function resolvePinnedClientMetadata(params: {
|
|
claimedPlatform?: string;
|
|
claimedDeviceFamily?: string;
|
|
pairedPlatform?: string;
|
|
pairedDeviceFamily?: string;
|
|
}): {
|
|
platformMismatch: boolean;
|
|
deviceFamilyMismatch: boolean;
|
|
pinnedPlatform?: string;
|
|
pinnedDeviceFamily?: string;
|
|
} {
|
|
const claimedPlatform = normalizeDeviceMetadataForAuth(params.claimedPlatform);
|
|
const claimedDeviceFamily = normalizeDeviceMetadataForAuth(params.claimedDeviceFamily);
|
|
const pairedPlatform = normalizeDeviceMetadataForAuth(params.pairedPlatform);
|
|
const pairedDeviceFamily = normalizeDeviceMetadataForAuth(params.pairedDeviceFamily);
|
|
const hasPinnedPlatform = pairedPlatform !== "";
|
|
const hasPinnedDeviceFamily = pairedDeviceFamily !== "";
|
|
const platformMismatch = hasPinnedPlatform && claimedPlatform !== pairedPlatform;
|
|
const deviceFamilyMismatch = hasPinnedDeviceFamily && claimedDeviceFamily !== pairedDeviceFamily;
|
|
return {
|
|
platformMismatch,
|
|
deviceFamilyMismatch,
|
|
pinnedPlatform: hasPinnedPlatform ? params.pairedPlatform : undefined,
|
|
pinnedDeviceFamily: hasPinnedDeviceFamily ? params.pairedDeviceFamily : undefined,
|
|
};
|
|
}
|
|
|
|
export function attachGatewayWsMessageHandler(params: {
|
|
socket: WebSocket;
|
|
upgradeReq: IncomingMessage;
|
|
connId: string;
|
|
remoteAddr?: string;
|
|
forwardedFor?: string;
|
|
realIp?: string;
|
|
requestHost?: string;
|
|
requestOrigin?: string;
|
|
requestUserAgent?: string;
|
|
canvasHostUrl?: string;
|
|
connectNonce: string;
|
|
resolvedAuth: ResolvedGatewayAuth;
|
|
/** Optional rate limiter for auth brute-force protection. */
|
|
rateLimiter?: AuthRateLimiter;
|
|
/** Browser-origin fallback limiter (loopback is never exempt). */
|
|
browserRateLimiter?: AuthRateLimiter;
|
|
gatewayMethods: string[];
|
|
events: string[];
|
|
extraHandlers: GatewayRequestHandlers;
|
|
buildRequestContext: () => GatewayRequestContext;
|
|
send: (obj: unknown) => void;
|
|
close: (code?: number, reason?: string) => void;
|
|
isClosed: () => boolean;
|
|
clearHandshakeTimer: () => void;
|
|
getClient: () => GatewayWsClient | null;
|
|
setClient: (next: GatewayWsClient) => void;
|
|
setHandshakeState: (state: "pending" | "connected" | "failed") => void;
|
|
setCloseCause: (cause: string, meta?: Record<string, unknown>) => void;
|
|
setLastFrameMeta: (meta: { type?: string; method?: string; id?: string }) => void;
|
|
originCheckMetrics: WsOriginCheckMetrics;
|
|
logGateway: SubsystemLogger;
|
|
logHealth: SubsystemLogger;
|
|
logWsControl: SubsystemLogger;
|
|
}) {
|
|
const {
|
|
socket,
|
|
upgradeReq,
|
|
connId,
|
|
remoteAddr,
|
|
forwardedFor,
|
|
realIp,
|
|
requestHost,
|
|
requestOrigin,
|
|
requestUserAgent,
|
|
canvasHostUrl,
|
|
connectNonce,
|
|
resolvedAuth,
|
|
rateLimiter,
|
|
browserRateLimiter,
|
|
gatewayMethods,
|
|
events,
|
|
extraHandlers,
|
|
buildRequestContext,
|
|
send,
|
|
close,
|
|
isClosed,
|
|
clearHandshakeTimer,
|
|
getClient,
|
|
setClient,
|
|
setHandshakeState,
|
|
setCloseCause,
|
|
setLastFrameMeta,
|
|
originCheckMetrics,
|
|
logGateway,
|
|
logHealth,
|
|
logWsControl,
|
|
} = params;
|
|
|
|
const configSnapshot = loadConfig();
|
|
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
|
|
const allowRealIpFallback = configSnapshot.gateway?.allowRealIpFallback === true;
|
|
const clientIp = resolveClientIp({
|
|
remoteAddr,
|
|
forwardedFor,
|
|
realIp,
|
|
trustedProxies,
|
|
allowRealIpFallback,
|
|
});
|
|
|
|
// If proxy headers are present but the remote address isn't trusted, don't treat
|
|
// the connection as local. This prevents auth bypass when running behind a reverse
|
|
// proxy without proper configuration - the proxy's loopback connection would otherwise
|
|
// cause all external requests to be treated as trusted local clients.
|
|
const hasProxyHeaders = Boolean(forwardedFor || realIp);
|
|
const remoteIsTrustedProxy = isTrustedProxyAddress(remoteAddr, trustedProxies);
|
|
const hasUntrustedProxyHeaders = hasProxyHeaders && !remoteIsTrustedProxy;
|
|
const hostIsLocalish = isLocalishHost(requestHost);
|
|
const isLocalClient = isLocalDirectRequest(upgradeReq, trustedProxies, allowRealIpFallback);
|
|
const reportedClientIp =
|
|
isLocalClient || hasUntrustedProxyHeaders
|
|
? undefined
|
|
: clientIp && !isLoopbackAddress(clientIp)
|
|
? clientIp
|
|
: undefined;
|
|
|
|
if (hasUntrustedProxyHeaders) {
|
|
logWsControl.warn(
|
|
"Proxy headers detected from untrusted address. " +
|
|
"Connection will not be treated as local. " +
|
|
"Configure gateway.trustedProxies to restore local client detection behind your proxy.",
|
|
);
|
|
}
|
|
if (!hostIsLocalish && isLoopbackAddress(remoteAddr) && !hasProxyHeaders) {
|
|
logWsControl.warn(
|
|
"Loopback connection with non-local Host header. " +
|
|
"Treating it as remote. If you're behind a reverse proxy, " +
|
|
"set gateway.trustedProxies and forward X-Forwarded-For/X-Real-IP.",
|
|
);
|
|
}
|
|
|
|
const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client);
|
|
const unauthorizedFloodGuard = new UnauthorizedFloodGuard();
|
|
const browserSecurity = resolveHandshakeBrowserSecurityContext({
|
|
requestOrigin,
|
|
clientIp,
|
|
rateLimiter,
|
|
browserRateLimiter,
|
|
});
|
|
const {
|
|
hasBrowserOriginHeader,
|
|
enforceOriginCheckForAnyClient,
|
|
rateLimitClientIp: browserRateLimitClientIp,
|
|
authRateLimiter,
|
|
} = browserSecurity;
|
|
|
|
socket.on("message", async (data) => {
|
|
if (isClosed()) {
|
|
return;
|
|
}
|
|
|
|
const preauthPayloadBytes = !getClient() ? getRawDataByteLength(data) : undefined;
|
|
if (preauthPayloadBytes !== undefined && preauthPayloadBytes > MAX_PREAUTH_PAYLOAD_BYTES) {
|
|
setHandshakeState("failed");
|
|
setCloseCause("preauth-payload-too-large", {
|
|
payloadBytes: preauthPayloadBytes,
|
|
limitBytes: MAX_PREAUTH_PAYLOAD_BYTES,
|
|
});
|
|
close(1009, "preauth payload too large");
|
|
return;
|
|
}
|
|
|
|
const text = rawDataToString(data);
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
const frameType =
|
|
parsed && typeof parsed === "object" && "type" in parsed
|
|
? typeof (parsed as { type?: unknown }).type === "string"
|
|
? String((parsed as { type?: unknown }).type)
|
|
: undefined
|
|
: undefined;
|
|
const frameMethod =
|
|
parsed && typeof parsed === "object" && "method" in parsed
|
|
? typeof (parsed as { method?: unknown }).method === "string"
|
|
? String((parsed as { method?: unknown }).method)
|
|
: undefined
|
|
: undefined;
|
|
const frameId =
|
|
parsed && typeof parsed === "object" && "id" in parsed
|
|
? typeof (parsed as { id?: unknown }).id === "string"
|
|
? String((parsed as { id?: unknown }).id)
|
|
: undefined
|
|
: undefined;
|
|
if (frameType || frameMethod || frameId) {
|
|
setLastFrameMeta({ type: frameType, method: frameMethod, id: frameId });
|
|
}
|
|
|
|
const client = getClient();
|
|
if (!client) {
|
|
// Handshake must be a normal request:
|
|
// { type:"req", method:"connect", params: ConnectParams }.
|
|
const isRequestFrame = validateRequestFrame(parsed);
|
|
if (
|
|
!isRequestFrame ||
|
|
parsed.method !== "connect" ||
|
|
!validateConnectParams(parsed.params)
|
|
) {
|
|
const handshakeError = isRequestFrame
|
|
? parsed.method === "connect"
|
|
? `invalid connect params: ${formatValidationErrors(validateConnectParams.errors)}`
|
|
: "invalid handshake: first request must be connect"
|
|
: "invalid request frame";
|
|
setHandshakeState("failed");
|
|
setCloseCause("invalid-handshake", {
|
|
frameType,
|
|
frameMethod,
|
|
frameId,
|
|
handshakeError,
|
|
});
|
|
if (isRequestFrame) {
|
|
const req = parsed;
|
|
send({
|
|
type: "res",
|
|
id: req.id,
|
|
ok: false,
|
|
error: errorShape(ErrorCodes.INVALID_REQUEST, handshakeError),
|
|
});
|
|
} else {
|
|
logWsControl.warn(
|
|
`invalid handshake conn=${connId} remote=${remoteAddr ?? "?"} fwd=${forwardedFor ?? "n/a"} origin=${requestOrigin ?? "n/a"} host=${requestHost ?? "n/a"} ua=${requestUserAgent ?? "n/a"}`,
|
|
);
|
|
}
|
|
const closeReason = truncateCloseReason(handshakeError || "invalid handshake");
|
|
if (isRequestFrame) {
|
|
queueMicrotask(() => close(1008, closeReason));
|
|
} else {
|
|
close(1008, closeReason);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const frame = parsed;
|
|
const connectParams = frame.params as ConnectParams;
|
|
const clientLabel = connectParams.client.displayName ?? connectParams.client.id;
|
|
const clientMeta = {
|
|
client: connectParams.client.id,
|
|
clientDisplayName: connectParams.client.displayName,
|
|
mode: connectParams.client.mode,
|
|
version: connectParams.client.version,
|
|
};
|
|
const markHandshakeFailure = (cause: string, meta?: Record<string, unknown>) => {
|
|
setHandshakeState("failed");
|
|
setCloseCause(cause, { ...meta, ...clientMeta });
|
|
};
|
|
const sendHandshakeErrorResponse = (
|
|
code: Parameters<typeof errorShape>[0],
|
|
message: string,
|
|
options?: Parameters<typeof errorShape>[2],
|
|
) => {
|
|
send({
|
|
type: "res",
|
|
id: frame.id,
|
|
ok: false,
|
|
error: errorShape(code, message, options),
|
|
});
|
|
};
|
|
|
|
// protocol negotiation
|
|
const { minProtocol, maxProtocol } = connectParams;
|
|
if (maxProtocol < PROTOCOL_VERSION || minProtocol > PROTOCOL_VERSION) {
|
|
markHandshakeFailure("protocol-mismatch", {
|
|
minProtocol,
|
|
maxProtocol,
|
|
expectedProtocol: PROTOCOL_VERSION,
|
|
});
|
|
logWsControl.warn(
|
|
`protocol mismatch conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version}`,
|
|
);
|
|
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, "protocol mismatch", {
|
|
details: { expectedProtocol: PROTOCOL_VERSION },
|
|
});
|
|
close(1002, "protocol mismatch");
|
|
return;
|
|
}
|
|
|
|
const roleRaw = connectParams.role ?? "operator";
|
|
const role = parseGatewayRole(roleRaw);
|
|
if (!role) {
|
|
markHandshakeFailure("invalid-role", {
|
|
role: roleRaw,
|
|
});
|
|
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, "invalid role");
|
|
close(1008, "invalid role");
|
|
return;
|
|
}
|
|
// Default-deny: scopes must be explicit. Empty/missing scopes means no permissions.
|
|
// Note: If the client does not present a device identity, we can't bind scopes to a paired
|
|
// device/token, so we will clear scopes after auth to avoid self-declared permissions.
|
|
let scopes = Array.isArray(connectParams.scopes) ? connectParams.scopes : [];
|
|
connectParams.role = role;
|
|
connectParams.scopes = scopes;
|
|
|
|
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
|
|
const isWebchat = isWebchatConnect(connectParams);
|
|
if (enforceOriginCheckForAnyClient || isControlUi || isWebchat) {
|
|
const hostHeaderOriginFallbackEnabled =
|
|
configSnapshot.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true;
|
|
const originCheck = checkBrowserOrigin({
|
|
requestHost,
|
|
origin: requestOrigin,
|
|
allowedOrigins: configSnapshot.gateway?.controlUi?.allowedOrigins,
|
|
allowHostHeaderOriginFallback: hostHeaderOriginFallbackEnabled,
|
|
isLocalClient,
|
|
});
|
|
if (!originCheck.ok) {
|
|
const errorMessage =
|
|
"origin not allowed (open the Control UI from the gateway host or allow it in gateway.controlUi.allowedOrigins)";
|
|
markHandshakeFailure("origin-mismatch", {
|
|
origin: requestOrigin ?? "n/a",
|
|
host: requestHost ?? "n/a",
|
|
reason: originCheck.reason,
|
|
});
|
|
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, errorMessage, {
|
|
details: {
|
|
code: ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED,
|
|
reason: originCheck.reason,
|
|
},
|
|
});
|
|
close(1008, truncateCloseReason(errorMessage));
|
|
return;
|
|
}
|
|
if (originCheck.matchedBy === "host-header-fallback") {
|
|
originCheckMetrics.hostHeaderFallbackAccepted += 1;
|
|
logWsControl.warn(
|
|
`security warning: websocket origin accepted via Host-header fallback conn=${connId} count=${originCheckMetrics.hostHeaderFallbackAccepted} host=${requestHost ?? "n/a"} origin=${requestOrigin ?? "n/a"}`,
|
|
);
|
|
if (hostHeaderOriginFallbackEnabled) {
|
|
logGateway.warn(
|
|
"security metric: gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback accepted a websocket connect request",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const deviceRaw = connectParams.device;
|
|
let devicePublicKey: string | null = null;
|
|
let deviceAuthPayloadVersion: "v2" | "v3" | null = null;
|
|
const hasTokenAuth = Boolean(connectParams.auth?.token);
|
|
const hasPasswordAuth = Boolean(connectParams.auth?.password);
|
|
const hasSharedAuth = hasTokenAuth || hasPasswordAuth;
|
|
const controlUiAuthPolicy = resolveControlUiAuthPolicy({
|
|
isControlUi,
|
|
controlUiConfig: configSnapshot.gateway?.controlUi,
|
|
deviceRaw,
|
|
});
|
|
const device = controlUiAuthPolicy.device;
|
|
|
|
let {
|
|
authResult,
|
|
authOk,
|
|
authMethod,
|
|
sharedAuthOk,
|
|
bootstrapTokenCandidate,
|
|
deviceTokenCandidate,
|
|
deviceTokenCandidateSource,
|
|
} = await resolveConnectAuthState({
|
|
resolvedAuth,
|
|
connectAuth: connectParams.auth,
|
|
hasDeviceIdentity: Boolean(device),
|
|
req: upgradeReq,
|
|
trustedProxies,
|
|
allowRealIpFallback,
|
|
rateLimiter: authRateLimiter,
|
|
clientIp: browserRateLimitClientIp,
|
|
});
|
|
const rejectUnauthorized = (failedAuth: GatewayAuthResult) => {
|
|
const { authProvided, canRetryWithDeviceToken, recommendedNextStep } =
|
|
resolveUnauthorizedHandshakeContext({
|
|
connectAuth: connectParams.auth,
|
|
failedAuth,
|
|
hasDeviceIdentity: Boolean(device),
|
|
});
|
|
markHandshakeFailure("unauthorized", {
|
|
authMode: resolvedAuth.mode,
|
|
authProvided,
|
|
authReason: failedAuth.reason,
|
|
allowTailscale: resolvedAuth.allowTailscale,
|
|
});
|
|
logWsControl.warn(
|
|
`unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version} reason=${failedAuth.reason ?? "unknown"}`,
|
|
);
|
|
const authMessage = formatGatewayAuthFailureMessage({
|
|
authMode: resolvedAuth.mode,
|
|
authProvided,
|
|
reason: failedAuth.reason,
|
|
client: connectParams.client,
|
|
});
|
|
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, authMessage, {
|
|
details: {
|
|
code: resolveAuthConnectErrorDetailCode(failedAuth.reason),
|
|
authReason: failedAuth.reason,
|
|
canRetryWithDeviceToken,
|
|
recommendedNextStep,
|
|
},
|
|
});
|
|
close(1008, truncateCloseReason(authMessage));
|
|
};
|
|
const clearUnboundScopes = () => {
|
|
if (scopes.length > 0) {
|
|
scopes = [];
|
|
connectParams.scopes = scopes;
|
|
}
|
|
};
|
|
const handleMissingDeviceIdentity = (): boolean => {
|
|
const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
|
|
isControlUi,
|
|
role,
|
|
authMode: resolvedAuth.mode,
|
|
authOk,
|
|
authMethod,
|
|
});
|
|
const decision = evaluateMissingDeviceIdentity({
|
|
hasDeviceIdentity: Boolean(device),
|
|
role,
|
|
isControlUi,
|
|
controlUiAuthPolicy,
|
|
trustedProxyAuthOk,
|
|
sharedAuthOk,
|
|
authOk,
|
|
hasSharedAuth,
|
|
isLocalClient,
|
|
});
|
|
// Shared token/password auth can bypass pairing for trusted operators, but
|
|
// device-less backend clients must not self-declare scopes. Control UI
|
|
// keeps its explicitly allowed device-less scopes on the allow path.
|
|
if (!device && (!isControlUi || decision.kind !== "allow")) {
|
|
clearUnboundScopes();
|
|
}
|
|
if (decision.kind === "allow") {
|
|
return true;
|
|
}
|
|
|
|
if (decision.kind === "reject-control-ui-insecure-auth") {
|
|
const errorMessage =
|
|
"control ui requires device identity (use HTTPS or localhost secure context)";
|
|
markHandshakeFailure("control-ui-insecure-auth", {
|
|
insecureAuthConfigured: controlUiAuthPolicy.allowInsecureAuthConfigured,
|
|
});
|
|
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, errorMessage, {
|
|
details: { code: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED },
|
|
});
|
|
close(1008, errorMessage);
|
|
return false;
|
|
}
|
|
|
|
if (decision.kind === "reject-unauthorized") {
|
|
rejectUnauthorized(authResult);
|
|
return false;
|
|
}
|
|
|
|
markHandshakeFailure("device-required");
|
|
sendHandshakeErrorResponse(ErrorCodes.NOT_PAIRED, "device identity required", {
|
|
details: { code: ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED },
|
|
});
|
|
close(1008, "device identity required");
|
|
return false;
|
|
};
|
|
if (!handleMissingDeviceIdentity()) {
|
|
return;
|
|
}
|
|
if (device) {
|
|
const rejectDeviceAuthInvalid = (reason: string, message: string) => {
|
|
setHandshakeState("failed");
|
|
setCloseCause("device-auth-invalid", {
|
|
reason,
|
|
client: connectParams.client.id,
|
|
deviceId: device.id,
|
|
});
|
|
send({
|
|
type: "res",
|
|
id: frame.id,
|
|
ok: false,
|
|
error: errorShape(ErrorCodes.INVALID_REQUEST, message, {
|
|
details: {
|
|
code: resolveDeviceAuthConnectErrorDetailCode(reason),
|
|
reason,
|
|
},
|
|
}),
|
|
});
|
|
close(1008, message);
|
|
};
|
|
const derivedId = deriveDeviceIdFromPublicKey(device.publicKey);
|
|
if (!derivedId || derivedId !== device.id) {
|
|
rejectDeviceAuthInvalid("device-id-mismatch", "device identity mismatch");
|
|
return;
|
|
}
|
|
const signedAt = device.signedAt;
|
|
if (
|
|
typeof signedAt !== "number" ||
|
|
Math.abs(Date.now() - signedAt) > DEVICE_SIGNATURE_SKEW_MS
|
|
) {
|
|
rejectDeviceAuthInvalid("device-signature-stale", "device signature expired");
|
|
return;
|
|
}
|
|
const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : "";
|
|
if (!providedNonce) {
|
|
rejectDeviceAuthInvalid("device-nonce-missing", "device nonce required");
|
|
return;
|
|
}
|
|
if (providedNonce !== connectNonce) {
|
|
rejectDeviceAuthInvalid("device-nonce-mismatch", "device nonce mismatch");
|
|
return;
|
|
}
|
|
const rejectDeviceSignatureInvalid = () =>
|
|
rejectDeviceAuthInvalid("device-signature", "device signature invalid");
|
|
const payloadVersion = resolveDeviceSignaturePayloadVersion({
|
|
device,
|
|
connectParams,
|
|
role,
|
|
scopes,
|
|
signedAtMs: signedAt,
|
|
nonce: providedNonce,
|
|
});
|
|
if (!payloadVersion) {
|
|
rejectDeviceSignatureInvalid();
|
|
return;
|
|
}
|
|
deviceAuthPayloadVersion = payloadVersion;
|
|
devicePublicKey = normalizeDevicePublicKeyBase64Url(device.publicKey);
|
|
if (!devicePublicKey) {
|
|
rejectDeviceAuthInvalid("device-public-key", "device public key invalid");
|
|
return;
|
|
}
|
|
}
|
|
|
|
({ authResult, authOk, authMethod } = await resolveConnectAuthDecision({
|
|
state: {
|
|
authResult,
|
|
authOk,
|
|
authMethod,
|
|
sharedAuthOk,
|
|
sharedAuthProvided: hasSharedAuth,
|
|
bootstrapTokenCandidate,
|
|
deviceTokenCandidate,
|
|
deviceTokenCandidateSource,
|
|
},
|
|
hasDeviceIdentity: Boolean(device),
|
|
deviceId: device?.id,
|
|
publicKey: device?.publicKey,
|
|
role,
|
|
scopes,
|
|
rateLimiter: authRateLimiter,
|
|
clientIp: browserRateLimitClientIp,
|
|
verifyBootstrapToken: async ({ deviceId, publicKey, token, role, scopes }) =>
|
|
await verifyDeviceBootstrapToken({
|
|
deviceId,
|
|
publicKey,
|
|
token,
|
|
role,
|
|
scopes,
|
|
}),
|
|
verifyDeviceToken,
|
|
}));
|
|
if (!authOk) {
|
|
rejectUnauthorized(authResult);
|
|
return;
|
|
}
|
|
|
|
const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
|
|
isControlUi,
|
|
role,
|
|
authMode: resolvedAuth.mode,
|
|
authOk,
|
|
authMethod,
|
|
});
|
|
const skipPairing =
|
|
shouldSkipBackendSelfPairing({
|
|
connectParams,
|
|
isLocalClient,
|
|
hasBrowserOriginHeader,
|
|
sharedAuthOk,
|
|
authMethod,
|
|
}) || shouldSkipControlUiPairing(controlUiAuthPolicy, role, trustedProxyAuthOk);
|
|
if (device && devicePublicKey && !skipPairing) {
|
|
const formatAuditList = (items: string[] | undefined): string => {
|
|
if (!items || items.length === 0) {
|
|
return "<none>";
|
|
}
|
|
const out = new Set<string>();
|
|
for (const item of items) {
|
|
const trimmed = item.trim();
|
|
if (trimmed) {
|
|
out.add(trimmed);
|
|
}
|
|
}
|
|
if (out.size === 0) {
|
|
return "<none>";
|
|
}
|
|
return [...out].toSorted().join(",");
|
|
};
|
|
const logUpgradeAudit = (
|
|
reason: "role-upgrade" | "scope-upgrade",
|
|
currentRoles: string[] | undefined,
|
|
currentScopes: string[] | undefined,
|
|
) => {
|
|
logGateway.warn(
|
|
`security audit: device access upgrade requested reason=${reason} device=${device.id} ip=${reportedClientIp ?? "unknown-ip"} auth=${authMethod} roleFrom=${formatAuditList(currentRoles)} roleTo=${role} scopesFrom=${formatAuditList(currentScopes)} scopesTo=${formatAuditList(scopes)} client=${connectParams.client.id} conn=${connId}`,
|
|
);
|
|
};
|
|
const clientPairingMetadata = {
|
|
displayName: connectParams.client.displayName,
|
|
platform: connectParams.client.platform,
|
|
deviceFamily: connectParams.client.deviceFamily,
|
|
clientId: connectParams.client.id,
|
|
clientMode: connectParams.client.mode,
|
|
role,
|
|
scopes,
|
|
remoteIp: reportedClientIp,
|
|
};
|
|
const clientAccessMetadata = {
|
|
displayName: connectParams.client.displayName,
|
|
clientId: connectParams.client.id,
|
|
clientMode: connectParams.client.mode,
|
|
role,
|
|
scopes,
|
|
remoteIp: reportedClientIp,
|
|
};
|
|
const requirePairing = async (
|
|
reason: "not-paired" | "role-upgrade" | "scope-upgrade" | "metadata-upgrade",
|
|
) => {
|
|
const allowSilentLocalPairing = shouldAllowSilentLocalPairing({
|
|
isLocalClient,
|
|
hasBrowserOriginHeader,
|
|
isControlUi,
|
|
isWebchat,
|
|
reason,
|
|
});
|
|
const pairing = await requestDevicePairing({
|
|
deviceId: device.id,
|
|
publicKey: devicePublicKey,
|
|
...clientPairingMetadata,
|
|
silent: allowSilentLocalPairing,
|
|
});
|
|
const context = buildRequestContext();
|
|
if (pairing.request.silent === true) {
|
|
const approved = await approveDevicePairing(pairing.request.requestId);
|
|
if (approved) {
|
|
logGateway.info(
|
|
`device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
|
|
);
|
|
context.broadcast(
|
|
"device.pair.resolved",
|
|
{
|
|
requestId: pairing.request.requestId,
|
|
deviceId: approved.device.deviceId,
|
|
decision: "approved",
|
|
ts: Date.now(),
|
|
},
|
|
{ dropIfSlow: true },
|
|
);
|
|
}
|
|
} else if (pairing.created) {
|
|
context.broadcast("device.pair.requested", pairing.request, { dropIfSlow: true });
|
|
}
|
|
if (pairing.request.silent !== true) {
|
|
setHandshakeState("failed");
|
|
setCloseCause("pairing-required", {
|
|
deviceId: device.id,
|
|
requestId: pairing.request.requestId,
|
|
reason,
|
|
});
|
|
send({
|
|
type: "res",
|
|
id: frame.id,
|
|
ok: false,
|
|
error: errorShape(ErrorCodes.NOT_PAIRED, "pairing required", {
|
|
details: {
|
|
code: ConnectErrorDetailCodes.PAIRING_REQUIRED,
|
|
requestId: pairing.request.requestId,
|
|
reason,
|
|
},
|
|
}),
|
|
});
|
|
close(1008, "pairing required");
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const paired = await getPairedDevice(device.id);
|
|
const isPaired = paired?.publicKey === devicePublicKey;
|
|
if (!isPaired) {
|
|
const ok = await requirePairing("not-paired");
|
|
if (!ok) {
|
|
return;
|
|
}
|
|
} else {
|
|
const claimedPlatform = connectParams.client.platform;
|
|
const pairedPlatform = paired.platform;
|
|
const claimedDeviceFamily = connectParams.client.deviceFamily;
|
|
const pairedDeviceFamily = paired.deviceFamily;
|
|
const metadataPinning = resolvePinnedClientMetadata({
|
|
claimedPlatform,
|
|
claimedDeviceFamily,
|
|
pairedPlatform,
|
|
pairedDeviceFamily,
|
|
});
|
|
const { platformMismatch, deviceFamilyMismatch } = metadataPinning;
|
|
if (platformMismatch || deviceFamilyMismatch) {
|
|
logGateway.warn(
|
|
`security audit: device metadata upgrade requested reason=metadata-upgrade device=${device.id} ip=${reportedClientIp ?? "unknown-ip"} auth=${authMethod} payload=${deviceAuthPayloadVersion ?? "unknown"} claimedPlatform=${claimedPlatform ?? "<none>"} pinnedPlatform=${pairedPlatform ?? "<none>"} claimedDeviceFamily=${claimedDeviceFamily ?? "<none>"} pinnedDeviceFamily=${pairedDeviceFamily ?? "<none>"} client=${connectParams.client.id} conn=${connId}`,
|
|
);
|
|
const ok = await requirePairing("metadata-upgrade");
|
|
if (!ok) {
|
|
return;
|
|
}
|
|
} else {
|
|
if (metadataPinning.pinnedPlatform) {
|
|
connectParams.client.platform = metadataPinning.pinnedPlatform;
|
|
}
|
|
if (metadataPinning.pinnedDeviceFamily) {
|
|
connectParams.client.deviceFamily = metadataPinning.pinnedDeviceFamily;
|
|
}
|
|
}
|
|
const pairedRoles = Array.isArray(paired.roles)
|
|
? paired.roles
|
|
: paired.role
|
|
? [paired.role]
|
|
: [];
|
|
const pairedScopes = Array.isArray(paired.scopes)
|
|
? paired.scopes
|
|
: Array.isArray(paired.approvedScopes)
|
|
? paired.approvedScopes
|
|
: [];
|
|
const allowedRoles = new Set(pairedRoles);
|
|
if (allowedRoles.size === 0) {
|
|
logUpgradeAudit("role-upgrade", pairedRoles, pairedScopes);
|
|
const ok = await requirePairing("role-upgrade");
|
|
if (!ok) {
|
|
return;
|
|
}
|
|
} else if (!allowedRoles.has(role)) {
|
|
logUpgradeAudit("role-upgrade", pairedRoles, pairedScopes);
|
|
const ok = await requirePairing("role-upgrade");
|
|
if (!ok) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (scopes.length > 0) {
|
|
if (pairedScopes.length === 0) {
|
|
logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes);
|
|
const ok = await requirePairing("scope-upgrade");
|
|
if (!ok) {
|
|
return;
|
|
}
|
|
} else {
|
|
const scopesAllowed = roleScopesAllow({
|
|
role,
|
|
requestedScopes: scopes,
|
|
allowedScopes: pairedScopes,
|
|
});
|
|
if (!scopesAllowed) {
|
|
logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes);
|
|
const ok = await requirePairing("scope-upgrade");
|
|
if (!ok) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Metadata pinning is approval-bound. Reconnects can update access metadata,
|
|
// but platform/device family must stay on the approved pairing record.
|
|
await updatePairedDeviceMetadata(device.id, clientAccessMetadata);
|
|
}
|
|
}
|
|
|
|
const deviceToken = device
|
|
? await ensureDeviceToken({ deviceId: device.id, role, scopes })
|
|
: null;
|
|
|
|
if (role === "node") {
|
|
const cfg = loadConfig();
|
|
const allowlist = resolveNodeCommandAllowlist(cfg, {
|
|
platform: connectParams.client.platform,
|
|
deviceFamily: connectParams.client.deviceFamily,
|
|
});
|
|
const declared = Array.isArray(connectParams.commands) ? connectParams.commands : [];
|
|
const filtered = declared
|
|
.map((cmd) => cmd.trim())
|
|
.filter((cmd) => cmd.length > 0 && allowlist.has(cmd));
|
|
connectParams.commands = filtered;
|
|
}
|
|
|
|
const shouldTrackPresence = !isGatewayCliClient(connectParams.client);
|
|
const clientId = connectParams.client.id;
|
|
const instanceId = connectParams.client.instanceId;
|
|
const presenceKey = shouldTrackPresence ? (device?.id ?? instanceId ?? connId) : undefined;
|
|
|
|
logWs("in", "connect", {
|
|
connId,
|
|
client: connectParams.client.id,
|
|
clientDisplayName: connectParams.client.displayName,
|
|
version: connectParams.client.version,
|
|
mode: connectParams.client.mode,
|
|
clientId,
|
|
platform: connectParams.client.platform,
|
|
auth: authMethod,
|
|
});
|
|
|
|
if (isWebchatConnect(connectParams)) {
|
|
logWsControl.info(
|
|
`webchat connected conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version}`,
|
|
);
|
|
}
|
|
|
|
if (presenceKey) {
|
|
upsertPresence(presenceKey, {
|
|
host: connectParams.client.displayName ?? connectParams.client.id ?? os.hostname(),
|
|
ip: isLocalClient ? undefined : reportedClientIp,
|
|
version: connectParams.client.version,
|
|
platform: connectParams.client.platform,
|
|
deviceFamily: connectParams.client.deviceFamily,
|
|
modelIdentifier: connectParams.client.modelIdentifier,
|
|
mode: connectParams.client.mode,
|
|
deviceId: device?.id,
|
|
roles: [role],
|
|
scopes,
|
|
instanceId: device?.id ?? instanceId,
|
|
reason: "connect",
|
|
});
|
|
incrementPresenceVersion();
|
|
}
|
|
|
|
const snapshot = buildGatewaySnapshot();
|
|
const cachedHealth = getHealthCache();
|
|
if (cachedHealth) {
|
|
snapshot.health = cachedHealth;
|
|
snapshot.stateVersion.health = getHealthVersion();
|
|
}
|
|
const canvasCapability =
|
|
role === "node" && canvasHostUrl ? mintCanvasCapabilityToken() : undefined;
|
|
const canvasCapabilityExpiresAtMs = canvasCapability
|
|
? Date.now() + CANVAS_CAPABILITY_TTL_MS
|
|
: undefined;
|
|
const scopedCanvasHostUrl =
|
|
canvasHostUrl && canvasCapability
|
|
? (buildCanvasScopedHostUrl(canvasHostUrl, canvasCapability) ?? canvasHostUrl)
|
|
: canvasHostUrl;
|
|
const helloOk = {
|
|
type: "hello-ok",
|
|
protocol: PROTOCOL_VERSION,
|
|
server: {
|
|
version: resolveRuntimeServiceVersion(process.env),
|
|
connId,
|
|
},
|
|
features: { methods: gatewayMethods, events },
|
|
snapshot,
|
|
canvasHostUrl: scopedCanvasHostUrl,
|
|
auth: deviceToken
|
|
? {
|
|
deviceToken: deviceToken.token,
|
|
role: deviceToken.role,
|
|
scopes: deviceToken.scopes,
|
|
issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs,
|
|
}
|
|
: undefined,
|
|
policy: {
|
|
maxPayload: MAX_PAYLOAD_BYTES,
|
|
maxBufferedBytes: MAX_BUFFERED_BYTES,
|
|
tickIntervalMs: TICK_INTERVAL_MS,
|
|
},
|
|
};
|
|
|
|
clearHandshakeTimer();
|
|
const nextClient: GatewayWsClient = {
|
|
socket,
|
|
connect: connectParams,
|
|
connId,
|
|
presenceKey,
|
|
clientIp: reportedClientIp,
|
|
canvasHostUrl,
|
|
canvasCapability,
|
|
canvasCapabilityExpiresAtMs,
|
|
};
|
|
setSocketMaxPayload(socket, MAX_PAYLOAD_BYTES);
|
|
setClient(nextClient);
|
|
setHandshakeState("connected");
|
|
if (role === "node") {
|
|
const context = buildRequestContext();
|
|
const nodeSession = context.nodeRegistry.register(nextClient, {
|
|
remoteIp: reportedClientIp,
|
|
});
|
|
const instanceIdRaw = connectParams.client.instanceId;
|
|
const instanceId = typeof instanceIdRaw === "string" ? instanceIdRaw.trim() : "";
|
|
const nodeIdsForPairing = new Set<string>([nodeSession.nodeId]);
|
|
if (instanceId) {
|
|
nodeIdsForPairing.add(instanceId);
|
|
}
|
|
for (const nodeId of nodeIdsForPairing) {
|
|
void updatePairedNodeMetadata(nodeId, {
|
|
lastConnectedAtMs: nodeSession.connectedAtMs,
|
|
}).catch((err) =>
|
|
logGateway.warn(`failed to record last connect for ${nodeId}: ${formatForLog(err)}`),
|
|
);
|
|
}
|
|
recordRemoteNodeInfo({
|
|
nodeId: nodeSession.nodeId,
|
|
displayName: nodeSession.displayName,
|
|
platform: nodeSession.platform,
|
|
deviceFamily: nodeSession.deviceFamily,
|
|
commands: nodeSession.commands,
|
|
remoteIp: nodeSession.remoteIp,
|
|
});
|
|
void refreshRemoteNodeBins({
|
|
nodeId: nodeSession.nodeId,
|
|
platform: nodeSession.platform,
|
|
deviceFamily: nodeSession.deviceFamily,
|
|
commands: nodeSession.commands,
|
|
cfg: loadConfig(),
|
|
}).catch((err) =>
|
|
logGateway.warn(
|
|
`remote bin probe failed for ${nodeSession.nodeId}: ${formatForLog(err)}`,
|
|
),
|
|
);
|
|
void loadVoiceWakeConfig()
|
|
.then((cfg) => {
|
|
context.nodeRegistry.sendEvent(nodeSession.nodeId, "voicewake.changed", {
|
|
triggers: cfg.triggers,
|
|
});
|
|
})
|
|
.catch((err) =>
|
|
logGateway.warn(
|
|
`voicewake snapshot failed for ${nodeSession.nodeId}: ${formatForLog(err)}`,
|
|
),
|
|
);
|
|
}
|
|
|
|
logWs("out", "hello-ok", {
|
|
connId,
|
|
methods: gatewayMethods.length,
|
|
events: events.length,
|
|
presence: snapshot.presence.length,
|
|
stateVersion: snapshot.stateVersion.presence,
|
|
});
|
|
|
|
send({ type: "res", id: frame.id, ok: true, payload: helloOk });
|
|
void refreshGatewayHealthSnapshot({ probe: true }).catch((err) =>
|
|
logHealth.error(`post-connect health refresh failed: ${formatError(err)}`),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// After handshake, accept only req frames
|
|
if (!validateRequestFrame(parsed)) {
|
|
send({
|
|
type: "res",
|
|
id: (parsed as { id?: unknown })?.id ?? "invalid",
|
|
ok: false,
|
|
error: errorShape(
|
|
ErrorCodes.INVALID_REQUEST,
|
|
`invalid request frame: ${formatValidationErrors(validateRequestFrame.errors)}`,
|
|
),
|
|
});
|
|
return;
|
|
}
|
|
const req = parsed;
|
|
logWs("in", "req", { connId, id: req.id, method: req.method });
|
|
const respond = (
|
|
ok: boolean,
|
|
payload?: unknown,
|
|
error?: ErrorShape,
|
|
meta?: Record<string, unknown>,
|
|
) => {
|
|
send({ type: "res", id: req.id, ok, payload, error });
|
|
const unauthorizedRoleError = isUnauthorizedRoleError(error);
|
|
let logMeta = meta;
|
|
if (unauthorizedRoleError) {
|
|
const unauthorizedDecision = unauthorizedFloodGuard.registerUnauthorized();
|
|
if (unauthorizedDecision.suppressedSinceLastLog > 0) {
|
|
logMeta = {
|
|
...logMeta,
|
|
suppressedUnauthorizedResponses: unauthorizedDecision.suppressedSinceLastLog,
|
|
};
|
|
}
|
|
if (!unauthorizedDecision.shouldLog) {
|
|
return;
|
|
}
|
|
if (unauthorizedDecision.shouldClose) {
|
|
setCloseCause("repeated-unauthorized-requests", {
|
|
unauthorizedCount: unauthorizedDecision.count,
|
|
method: req.method,
|
|
});
|
|
queueMicrotask(() => close(1008, "repeated unauthorized calls"));
|
|
}
|
|
logMeta = {
|
|
...logMeta,
|
|
unauthorizedCount: unauthorizedDecision.count,
|
|
};
|
|
} else {
|
|
unauthorizedFloodGuard.reset();
|
|
}
|
|
logWs("out", "res", {
|
|
connId,
|
|
id: req.id,
|
|
ok,
|
|
method: req.method,
|
|
errorCode: error?.code,
|
|
errorMessage: error?.message,
|
|
...logMeta,
|
|
});
|
|
};
|
|
|
|
void (async () => {
|
|
await handleGatewayRequest({
|
|
req,
|
|
respond,
|
|
client,
|
|
isWebchatConnect,
|
|
extraHandlers,
|
|
context: buildRequestContext(),
|
|
});
|
|
})().catch((err) => {
|
|
logGateway.error(`request handler failed: ${formatForLog(err)}`);
|
|
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
|
|
});
|
|
} catch (err) {
|
|
logGateway.error(`parse/handle error: ${String(err)}`);
|
|
logWs("out", "parse-error", { connId, error: formatForLog(err) });
|
|
if (!getClient()) {
|
|
close();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function getRawDataByteLength(data: unknown): number {
|
|
if (Buffer.isBuffer(data)) {
|
|
return data.byteLength;
|
|
}
|
|
if (Array.isArray(data)) {
|
|
return data.reduce((total, chunk) => total + chunk.byteLength, 0);
|
|
}
|
|
if (data instanceof ArrayBuffer) {
|
|
return data.byteLength;
|
|
}
|
|
return Buffer.byteLength(String(data));
|
|
}
|
|
|
|
function setSocketMaxPayload(socket: WebSocket, maxPayload: number): void {
|
|
const receiver = (socket as { _receiver?: { _maxPayload?: number } })._receiver;
|
|
if (receiver) {
|
|
receiver._maxPayload = maxPayload;
|
|
}
|
|
}
|