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; 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) => 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) => { setHandshakeState("failed"); setCloseCause(cause, { ...meta, ...clientMeta }); }; const sendHandshakeErrorResponse = ( code: Parameters[0], message: string, options?: Parameters[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 ""; } const out = new Set(); for (const item of items) { const trimmed = item.trim(); if (trimmed) { out.add(trimmed); } } if (out.size === 0) { return ""; } 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 ?? ""} pinnedPlatform=${pairedPlatform ?? ""} claimedDeviceFamily=${claimedDeviceFamily ?? ""} pinnedDeviceFamily=${pairedDeviceFamily ?? ""} 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([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, ) => { 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; } }