From 36fa3c3cd34a79d07b86364a35cdfa2821fc11f4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 22:17:59 +0000 Subject: [PATCH] fix: improve ws close diagnostics --- src/gateway/auth.test.ts | 80 +++++++++++++++++++++++++++++ src/gateway/auth.ts | 40 ++++++++++----- src/gateway/server.ts | 105 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 13 deletions(-) diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 705b59f18..ad7c91c0e 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -12,4 +12,84 @@ describe("gateway auth", () => { }); expect(res.ok).toBe(true); }); + + it("reports missing and mismatched token reasons", async () => { + const missing = await authorizeGatewayConnect({ + auth: { mode: "token", token: "secret", allowTailscale: false }, + connectAuth: null, + }); + expect(missing.ok).toBe(false); + expect(missing.reason).toBe("token_missing"); + + const mismatch = await authorizeGatewayConnect({ + auth: { mode: "token", token: "secret", allowTailscale: false }, + connectAuth: { token: "wrong" }, + }); + expect(mismatch.ok).toBe(false); + expect(mismatch.reason).toBe("token_mismatch"); + }); + + it("reports missing token config reason", async () => { + const res = await authorizeGatewayConnect({ + auth: { mode: "token", allowTailscale: false }, + connectAuth: { token: "anything" }, + }); + expect(res.ok).toBe(false); + expect(res.reason).toBe("token_missing_config"); + }); + + it("reports missing and mismatched password reasons", async () => { + const missing = await authorizeGatewayConnect({ + auth: { mode: "password", password: "secret", allowTailscale: false }, + connectAuth: null, + }); + expect(missing.ok).toBe(false); + expect(missing.reason).toBe("password_missing"); + + const mismatch = await authorizeGatewayConnect({ + auth: { mode: "password", password: "secret", allowTailscale: false }, + connectAuth: { password: "wrong" }, + }); + expect(mismatch.ok).toBe(false); + expect(mismatch.reason).toBe("password_mismatch"); + }); + + it("reports missing password config reason", async () => { + const res = await authorizeGatewayConnect({ + auth: { mode: "password", allowTailscale: false }, + connectAuth: { password: "secret" }, + }); + expect(res.ok).toBe(false); + expect(res.reason).toBe("password_missing_config"); + }); + + it("reports tailscale auth reasons when required", async () => { + const reqBase = { + socket: { remoteAddress: "100.100.100.100" }, + headers: { host: "gateway.local" }, + }; + + const missingUser = await authorizeGatewayConnect({ + auth: { mode: "none", allowTailscale: true }, + connectAuth: null, + req: reqBase as never, + }); + expect(missingUser.ok).toBe(false); + expect(missingUser.reason).toBe("tailscale_user_missing"); + + const missingProxy = await authorizeGatewayConnect({ + auth: { mode: "none", allowTailscale: true }, + connectAuth: null, + req: { + ...reqBase, + headers: { + host: "gateway.local", + "tailscale-user-login": "peter", + "tailscale-user-name": "Peter", + }, + } as never, + }); + expect(missingProxy.ok).toBe(false); + expect(missingProxy.reason).toBe("tailscale_proxy_missing"); + }); }); diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 1aecd1348..91577a342 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -150,10 +150,10 @@ export async function authorizeGatewayConnect(params: { if (auth.allowTailscale && !localDirect) { const tailscaleUser = getTailscaleUser(req); if (!tailscaleUser) { - return { ok: false, reason: "unauthorized" }; + return { ok: false, reason: "tailscale_user_missing" }; } if (!isTailscaleProxyRequest(req)) { - return { ok: false, reason: "unauthorized" }; + return { ok: false, reason: "tailscale_proxy_missing" }; } return { ok: true, @@ -165,31 +165,45 @@ export async function authorizeGatewayConnect(params: { } if (auth.mode === "token") { - if (auth.token && connectAuth?.token === auth.token) { - return { ok: true, method: "token" }; + if (!auth.token) { + return { ok: false, reason: "token_missing_config" }; } + if (!connectAuth?.token) { + return { ok: false, reason: "token_missing" }; + } + if (connectAuth.token !== auth.token) { + return { ok: false, reason: "token_mismatch" }; + } + return { ok: true, method: "token" }; } if (auth.mode === "password") { const password = connectAuth?.password; - if (!password || !auth.password) { - return { ok: false, reason: "unauthorized" }; + if (!auth.password) { + return { ok: false, reason: "password_missing_config" }; + } + if (!password) { + return { ok: false, reason: "password_missing" }; } if (!safeEqual(password, auth.password)) { - return { ok: false, reason: "unauthorized" }; + return { ok: false, reason: "password_mismatch" }; } return { ok: true, method: "password" }; } if (auth.allowTailscale) { const tailscaleUser = getTailscaleUser(req); - if (tailscaleUser && isTailscaleProxyRequest(req)) { - return { - ok: true, - method: "tailscale", - user: tailscaleUser.login, - }; + if (!tailscaleUser) { + return { ok: false, reason: "tailscale_user_missing" }; } + if (!isTailscaleProxyRequest(req)) { + return { ok: false, reason: "tailscale_proxy_missing" }; + } + return { + ok: true, + method: "tailscale", + user: tailscaleUser.login, + }; } return { ok: false, reason: "unauthorized" }; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index caf5696b2..340887ddf 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -1202,10 +1202,17 @@ export async function startGatewayServer( wss.on("connection", (socket, upgradeReq) => { let client: Client | null = null; let closed = false; + const openedAt = Date.now(); const connId = randomUUID(); const remoteAddr = ( socket as WebSocket & { _socket?: { remoteAddress?: string } } )._socket?.remoteAddress; + const headerValue = (value: string | string[] | undefined) => + Array.isArray(value) ? value[0] : value; + const requestHost = headerValue(upgradeReq.headers.host); + const requestOrigin = headerValue(upgradeReq.headers.origin); + const requestUserAgent = headerValue(upgradeReq.headers["user-agent"]); + const forwardedFor = headerValue(upgradeReq.headers["x-forwarded-for"]); const canvasHostPortForWs = canvasHostServer?.port ?? (canvasHost ? port : undefined); const canvasHostOverride = @@ -1223,6 +1230,19 @@ export async function startGatewayServer( const isWebchatConnect = (params: ConnectParams | null | undefined) => params?.client?.mode === "webchat" || params?.client?.name === "webchat-ui"; + let handshakeState: "pending" | "connected" | "failed" = "pending"; + let closeCause: string | undefined; + let closeMeta: Record = {}; + let lastFrameType: string | undefined; + let lastFrameMethod: string | undefined; + let lastFrameId: string | undefined; + + const setCloseCause = (cause: string, meta?: Record) => { + if (!closeCause) closeCause = cause; + if (meta && Object.keys(meta).length > 0) { + closeMeta = { ...closeMeta, ...meta }; + } + }; const send = (obj: unknown) => { try { @@ -1251,9 +1271,24 @@ export async function startGatewayServer( close(); }); socket.once("close", (code, reason) => { + const durationMs = Date.now() - openedAt; + const closeContext = { + cause: closeCause, + handshake: handshakeState, + durationMs, + lastFrameType, + lastFrameMethod, + lastFrameId, + host: requestHost, + origin: requestOrigin, + userAgent: requestUserAgent, + forwardedFor, + ...closeMeta, + }; if (!client) { logWsControl.warn( `closed before connect conn=${connId} remote=${remoteAddr ?? "?"} code=${code ?? "n/a"} reason=${reason?.toString() || "n/a"}`, + closeContext, ); } if (client && isWebchatConnect(client.connect)) { @@ -1280,12 +1315,22 @@ export async function startGatewayServer( connId, code, reason: reason?.toString(), + durationMs, + cause: closeCause, + handshake: handshakeState, + lastFrameType, + lastFrameMethod, + lastFrameId, }); close(); }); const handshakeTimer = setTimeout(() => { if (!client) { + handshakeState = "failed"; + setCloseCause("handshake-timeout", { + handshakeMs: Date.now() - openedAt, + }); logWsControl.warn( `handshake timeout conn=${connId} remote=${remoteAddr ?? "?"}`, ); @@ -1298,6 +1343,29 @@ export async function startGatewayServer( 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) { + lastFrameType = frameType; + lastFrameMethod = frameMethod; + lastFrameId = frameId; + } if (!client) { // Handshake must be a normal request: // { type:"req", method:"connect", params: ConnectParams }. @@ -1324,6 +1392,18 @@ export async function startGatewayServer( `invalid handshake conn=${connId} remote=${remoteAddr ?? "?"}`, ); } + handshakeState = "failed"; + const handshakeError = validateRequestFrame(parsed) + ? (parsed as RequestFrame).method === "connect" + ? `invalid connect params: ${formatValidationErrors(validateConnectParams.errors)}` + : "invalid handshake: first request must be connect" + : "invalid request frame"; + setCloseCause("invalid-handshake", { + frameType, + frameMethod, + frameId, + handshakeError, + }); socket.close(1008, "invalid handshake"); close(); return; @@ -1338,9 +1418,18 @@ export async function startGatewayServer( maxProtocol < PROTOCOL_VERSION || minProtocol > PROTOCOL_VERSION ) { + handshakeState = "failed"; logWsControl.warn( `protocol mismatch conn=${connId} remote=${remoteAddr ?? "?"} client=${connectParams.client.name} ${connectParams.client.mode} v${connectParams.client.version}`, ); + setCloseCause("protocol-mismatch", { + minProtocol, + maxProtocol, + expectedProtocol: PROTOCOL_VERSION, + client: connectParams.client.name, + mode: connectParams.client.mode, + version: connectParams.client.version, + }); send({ type: "res", id: frame.id, @@ -1364,9 +1453,24 @@ export async function startGatewayServer( req: upgradeReq, }); if (!authResult.ok) { + handshakeState = "failed"; logWsControl.warn( `unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${connectParams.client.name} ${connectParams.client.mode} v${connectParams.client.version}`, ); + const authProvided = connectParams.auth?.token + ? "token" + : connectParams.auth?.password + ? "password" + : "none"; + setCloseCause("unauthorized", { + authMode: resolvedAuth.mode, + authProvided, + authReason: authResult.reason, + allowTailscale: resolvedAuth.allowTailscale, + client: connectParams.client.name, + mode: connectParams.client.mode, + version: connectParams.client.version, + }); send({ type: "res", id: frame.id, @@ -1444,6 +1548,7 @@ export async function startGatewayServer( clearTimeout(handshakeTimer); client = { socket, connect: connectParams, connId, presenceKey }; + handshakeState = "connected"; logWs("out", "hello-ok", { connId,