fix(gateway): improve websocket auth logging

This commit is contained in:
Peter Steinberger
2026-04-10 12:38:31 +01:00
parent d350280fc2
commit bf40baaa4d
4 changed files with 113 additions and 8 deletions

View File

@@ -27,6 +27,7 @@ let lastClientOptions: {
token?: string;
password?: string;
tlsFingerprint?: string;
clientDisplayName?: string;
scopes?: string[];
deviceIdentity?: unknown;
onHelloOk?: (hello: { features?: { methods?: string[] } }) => void | Promise<void>;
@@ -58,6 +59,7 @@ vi.mock("./client.js", () => ({
url?: string;
token?: string;
password?: string;
clientDisplayName?: string;
scopes?: string[];
onHelloOk?: (hello: { features?: { methods?: string[] } }) => void | Promise<void>;
onClose?: (code: number, reason: string) => void;
@@ -95,6 +97,7 @@ class StubGatewayClient {
url?: string;
token?: string;
password?: string;
clientDisplayName?: string;
scopes?: string[];
onHelloOk?: (hello: { features?: { methods?: string[] } }) => void | Promise<void>;
onClose?: (code: number, reason: string) => void;
@@ -452,6 +455,22 @@ describe("callGateway url resolution", () => {
expect(lastClientOptions?.scopes).toEqual([]);
});
it("labels default backend calls with the requested method", async () => {
setLocalLoopbackGatewayConfig();
await callGateway({ method: "sessions.delete" });
expect(lastClientOptions?.clientDisplayName).toBe("gateway:sessions.delete");
});
it("does not synthesize display names for CLI calls", async () => {
setLocalLoopbackGatewayConfig();
await callGatewayCli({ method: "health" });
expect(lastClientOptions?.clientDisplayName).toBeUndefined();
});
it("yields one event-loop turn before starting CLI pairing requests", async () => {
setLocalLoopbackGatewayConfig();

View File

@@ -108,6 +108,19 @@ const gatewayCallDeps = {
...defaultGatewayCallDeps,
};
function resolveGatewayClientDisplayName(opts: CallGatewayBaseOptions): string | undefined {
if (opts.clientDisplayName) {
return opts.clientDisplayName;
}
const clientName = opts.clientName ?? GATEWAY_CLIENT_NAMES.CLI;
const mode = opts.mode ?? GATEWAY_CLIENT_MODES.CLI;
if (mode !== GATEWAY_CLIENT_MODES.BACKEND && clientName !== GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT) {
return undefined;
}
const method = opts.method.trim();
return method ? `gateway:${method}` : "gateway:request";
}
function loadGatewayConfig(): OpenClawConfig {
const loadConfigFn =
typeof gatewayCallDeps.loadConfig === "function"
@@ -745,7 +758,7 @@ async function executeGatewayRequestWithScopes<T>(params: {
tlsFingerprint,
instanceId: opts.instanceId ?? randomUUID(),
clientName: opts.clientName ?? GATEWAY_CLIENT_NAMES.CLI,
clientDisplayName: opts.clientDisplayName,
clientDisplayName: resolveGatewayClientDisplayName(opts),
clientVersion: opts.clientVersion ?? VERSION,
platform: opts.platform,
mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI,

View File

@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import type { Socket } from "node:net";
import type { WebSocket, WebSocketServer } from "ws";
import { resolveCanvasHostUrl } from "../../infra/canvas-host-url.js";
import { removeRemoteNodeInfo } from "../../infra/skills-remote.js";
@@ -61,6 +62,45 @@ const sanitizeLogValue = (value: string | undefined): string | undefined => {
return truncateUtf16Safe(cleaned, LOG_HEADER_MAX_LEN);
};
function formatSocketEndpoint(
address: string | undefined,
port: number | undefined,
): string | undefined {
if (!address) {
return undefined;
}
if (port === undefined) {
return address;
}
return address.includes(":") ? `[${address}]:${port}` : `${address}:${port}`;
}
function resolveSocketAddress(socket: WebSocket): {
remoteAddr?: string;
remotePort?: number;
localAddr?: string;
localPort?: number;
endpoint?: string;
} {
const rawSocket = (socket as WebSocket & { _socket?: Socket })._socket;
const remoteAddr = rawSocket?.remoteAddress;
const remotePort = rawSocket?.remotePort;
const localAddr = rawSocket?.localAddress;
const localPort = rawSocket?.localPort;
const remoteEndpoint = formatSocketEndpoint(remoteAddr, remotePort);
const localEndpoint = formatSocketEndpoint(localAddr, localPort);
return {
remoteAddr,
remotePort,
localAddr,
localPort,
endpoint:
remoteEndpoint && localEndpoint
? `${remoteEndpoint}->${localEndpoint}`
: (remoteEndpoint ?? localEndpoint),
};
}
export type GatewayWsSharedHandlerParams = {
wss: WebSocketServer;
clients: Set<GatewayWsClient>;
@@ -127,8 +167,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
let closed = false;
const openedAt = Date.now();
const connId = randomUUID();
const remoteAddr = (socket as WebSocket & { _socket?: { remoteAddress?: string } })._socket
?.remoteAddress;
const { remoteAddr, remotePort, localAddr, localPort, endpoint } = resolveSocketAddress(socket);
const preauthBudgetKey = (
socket as WebSocket & {
__openclawPreauthBudgetClaimed?: boolean;
@@ -159,7 +198,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
localAddress: upgradeReq.socket?.localAddress,
});
logWs("in", "open", { connId, remoteAddr });
logWs("in", "open", { connId, remoteAddr, remotePort, localAddr, localPort, endpoint });
let handshakeState: "pending" | "connected" | "failed" = "pending";
let holdsPreauthBudget = true;
let closeCause: string | undefined;
@@ -254,6 +293,11 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
origin: logOrigin,
userAgent: logUserAgent,
forwardedFor: logForwardedFor,
remoteAddr,
remotePort,
localAddr,
localPort,
endpoint,
...closeMeta,
};
if (!client) {
@@ -261,7 +305,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
? logWsControl.debug
: logWsControl.warn;
logFn(
`closed before connect conn=${connId} remote=${remoteAddr ?? "?"} fwd=${logForwardedFor || "n/a"} origin=${logOrigin || "n/a"} host=${logHost || "n/a"} ua=${logUserAgent || "n/a"} code=${code ?? "n/a"} reason=${logReason || "n/a"}`,
`closed before connect conn=${connId} peer=${endpoint ?? "n/a"} remote=${remoteAddr ?? "?"} fwd=${logForwardedFor || "n/a"} origin=${logOrigin || "n/a"} host=${logHost || "n/a"} ua=${logUserAgent || "n/a"} code=${code ?? "n/a"} reason=${logReason || "n/a"}`,
closeContext,
);
}
@@ -293,6 +337,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
lastFrameType,
lastFrameMethod,
lastFrameId,
endpoint,
});
close();
});
@@ -303,8 +348,11 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
handshakeState = "failed";
setCloseCause("handshake-timeout", {
handshakeMs: Date.now() - openedAt,
endpoint,
});
logWsControl.warn(`handshake timeout conn=${connId} remote=${remoteAddr ?? "?"}`);
logWsControl.warn(
`handshake timeout conn=${connId} peer=${endpoint ?? "n/a"} remote=${remoteAddr ?? "?"}`,
);
close();
}
}, handshakeTimeoutMs);
@@ -314,6 +362,10 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
upgradeReq,
connId,
remoteAddr,
remotePort,
localAddr,
localPort,
endpoint,
forwardedFor,
realIp,
requestHost,

View File

@@ -161,6 +161,10 @@ export function attachGatewayWsMessageHandler(params: {
upgradeReq: IncomingMessage;
connId: string;
remoteAddr?: string;
remotePort?: number;
localAddr?: string;
localPort?: number;
endpoint?: string;
forwardedFor?: string;
realIp?: string;
requestHost?: string;
@@ -197,6 +201,10 @@ export function attachGatewayWsMessageHandler(params: {
upgradeReq,
connId,
remoteAddr,
remotePort,
localAddr,
localPort,
endpoint,
forwardedFor,
realIp,
requestHost,
@@ -248,6 +256,7 @@ export function attachGatewayWsMessageHandler(params: {
trustedProxies,
allowRealIpFallback,
});
const peerLabel = endpoint ?? remoteAddr ?? "n/a";
// 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
@@ -369,7 +378,7 @@ export function attachGatewayWsMessageHandler(params: {
});
} 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"}`,
`invalid handshake conn=${connId} peer=${formatForLog(peerLabel)} remote=${remoteAddr ?? "?"} fwd=${formatForLog(forwardedFor ?? "n/a")} origin=${formatForLog(requestOrigin ?? "n/a")} host=${formatForLog(requestHost ?? "n/a")} ua=${formatForLog(requestUserAgent ?? "n/a")}`,
);
}
const closeReason = truncateCloseReason(handshakeError || "invalid handshake");
@@ -389,6 +398,10 @@ export function attachGatewayWsMessageHandler(params: {
clientDisplayName: connectParams.client.displayName,
mode: connectParams.client.mode,
version: connectParams.client.version,
platform: connectParams.client.platform,
deviceFamily: connectParams.client.deviceFamily,
modelIdentifier: connectParams.client.modelIdentifier,
instanceId: connectParams.client.instanceId,
};
const markHandshakeFailure = (cause: string, meta?: Record<string, unknown>) => {
setHandshakeState("failed");
@@ -529,9 +542,17 @@ export function attachGatewayWsMessageHandler(params: {
authProvided,
authReason: failedAuth.reason,
allowTailscale: resolvedAuth.allowTailscale,
peer: peerLabel,
remoteAddr,
remotePort,
localAddr,
localPort,
role,
scopeCount: scopes.length,
hasDeviceIdentity: Boolean(device),
});
logWsControl.warn(
`unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version} reason=${failedAuth.reason ?? "unknown"}`,
`unauthorized conn=${connId} peer=${formatForLog(peerLabel)} remote=${remoteAddr ?? "?"} client=${formatForLog(clientLabel)} ${connectParams.client.mode} v${formatForLog(connectParams.client.version)} role=${role} scopes=${scopes.length} auth=${authProvided} device=${device ? "yes" : "no"} platform=${formatForLog(connectParams.client.platform)} instance=${formatForLog(connectParams.client.instanceId ?? "n/a")} host=${formatForLog(requestHost ?? "n/a")} origin=${formatForLog(requestOrigin ?? "n/a")} ua=${formatForLog(requestUserAgent ?? "n/a")} reason=${failedAuth.reason ?? "unknown"}`,
);
const authMessage = formatGatewayAuthFailureMessage({
authMode: resolvedAuth.mode,