openclaw/src/gateway/server/ws-connection/message-handler.ts

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;
}
}