fix: improve ws close diagnostics

This commit is contained in:
Peter Steinberger
2026-01-08 22:17:59 +00:00
parent 1cf8503017
commit 36fa3c3cd3
3 changed files with 212 additions and 13 deletions

View File

@@ -12,4 +12,84 @@ describe("gateway auth", () => {
}); });
expect(res.ok).toBe(true); 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");
});
}); });

View File

@@ -150,10 +150,10 @@ export async function authorizeGatewayConnect(params: {
if (auth.allowTailscale && !localDirect) { if (auth.allowTailscale && !localDirect) {
const tailscaleUser = getTailscaleUser(req); const tailscaleUser = getTailscaleUser(req);
if (!tailscaleUser) { if (!tailscaleUser) {
return { ok: false, reason: "unauthorized" }; return { ok: false, reason: "tailscale_user_missing" };
} }
if (!isTailscaleProxyRequest(req)) { if (!isTailscaleProxyRequest(req)) {
return { ok: false, reason: "unauthorized" }; return { ok: false, reason: "tailscale_proxy_missing" };
} }
return { return {
ok: true, ok: true,
@@ -165,32 +165,46 @@ export async function authorizeGatewayConnect(params: {
} }
if (auth.mode === "token") { if (auth.mode === "token") {
if (auth.token && connectAuth?.token === auth.token) { if (!auth.token) {
return { ok: true, method: "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") { if (auth.mode === "password") {
const password = connectAuth?.password; const password = connectAuth?.password;
if (!password || !auth.password) { if (!auth.password) {
return { ok: false, reason: "unauthorized" }; return { ok: false, reason: "password_missing_config" };
}
if (!password) {
return { ok: false, reason: "password_missing" };
} }
if (!safeEqual(password, auth.password)) { if (!safeEqual(password, auth.password)) {
return { ok: false, reason: "unauthorized" }; return { ok: false, reason: "password_mismatch" };
} }
return { ok: true, method: "password" }; return { ok: true, method: "password" };
} }
if (auth.allowTailscale) { if (auth.allowTailscale) {
const tailscaleUser = getTailscaleUser(req); const tailscaleUser = getTailscaleUser(req);
if (tailscaleUser && isTailscaleProxyRequest(req)) { if (!tailscaleUser) {
return { ok: false, reason: "tailscale_user_missing" };
}
if (!isTailscaleProxyRequest(req)) {
return { ok: false, reason: "tailscale_proxy_missing" };
}
return { return {
ok: true, ok: true,
method: "tailscale", method: "tailscale",
user: tailscaleUser.login, user: tailscaleUser.login,
}; };
} }
}
return { ok: false, reason: "unauthorized" }; return { ok: false, reason: "unauthorized" };
} }

View File

@@ -1202,10 +1202,17 @@ export async function startGatewayServer(
wss.on("connection", (socket, upgradeReq) => { wss.on("connection", (socket, upgradeReq) => {
let client: Client | null = null; let client: Client | null = null;
let closed = false; let closed = false;
const openedAt = Date.now();
const connId = randomUUID(); const connId = randomUUID();
const remoteAddr = ( const remoteAddr = (
socket as WebSocket & { _socket?: { remoteAddress?: string } } socket as WebSocket & { _socket?: { remoteAddress?: string } }
)._socket?.remoteAddress; )._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 = const canvasHostPortForWs =
canvasHostServer?.port ?? (canvasHost ? port : undefined); canvasHostServer?.port ?? (canvasHost ? port : undefined);
const canvasHostOverride = const canvasHostOverride =
@@ -1223,6 +1230,19 @@ export async function startGatewayServer(
const isWebchatConnect = (params: ConnectParams | null | undefined) => const isWebchatConnect = (params: ConnectParams | null | undefined) =>
params?.client?.mode === "webchat" || params?.client?.mode === "webchat" ||
params?.client?.name === "webchat-ui"; params?.client?.name === "webchat-ui";
let handshakeState: "pending" | "connected" | "failed" = "pending";
let closeCause: string | undefined;
let closeMeta: Record<string, unknown> = {};
let lastFrameType: string | undefined;
let lastFrameMethod: string | undefined;
let lastFrameId: string | undefined;
const setCloseCause = (cause: string, meta?: Record<string, unknown>) => {
if (!closeCause) closeCause = cause;
if (meta && Object.keys(meta).length > 0) {
closeMeta = { ...closeMeta, ...meta };
}
};
const send = (obj: unknown) => { const send = (obj: unknown) => {
try { try {
@@ -1251,9 +1271,24 @@ export async function startGatewayServer(
close(); close();
}); });
socket.once("close", (code, reason) => { 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) { if (!client) {
logWsControl.warn( logWsControl.warn(
`closed before connect conn=${connId} remote=${remoteAddr ?? "?"} code=${code ?? "n/a"} reason=${reason?.toString() || "n/a"}`, `closed before connect conn=${connId} remote=${remoteAddr ?? "?"} code=${code ?? "n/a"} reason=${reason?.toString() || "n/a"}`,
closeContext,
); );
} }
if (client && isWebchatConnect(client.connect)) { if (client && isWebchatConnect(client.connect)) {
@@ -1280,12 +1315,22 @@ export async function startGatewayServer(
connId, connId,
code, code,
reason: reason?.toString(), reason: reason?.toString(),
durationMs,
cause: closeCause,
handshake: handshakeState,
lastFrameType,
lastFrameMethod,
lastFrameId,
}); });
close(); close();
}); });
const handshakeTimer = setTimeout(() => { const handshakeTimer = setTimeout(() => {
if (!client) { if (!client) {
handshakeState = "failed";
setCloseCause("handshake-timeout", {
handshakeMs: Date.now() - openedAt,
});
logWsControl.warn( logWsControl.warn(
`handshake timeout conn=${connId} remote=${remoteAddr ?? "?"}`, `handshake timeout conn=${connId} remote=${remoteAddr ?? "?"}`,
); );
@@ -1298,6 +1343,29 @@ export async function startGatewayServer(
const text = rawDataToString(data); const text = rawDataToString(data);
try { try {
const parsed = JSON.parse(text); 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) { if (!client) {
// Handshake must be a normal request: // Handshake must be a normal request:
// { type:"req", method:"connect", params: ConnectParams }. // { type:"req", method:"connect", params: ConnectParams }.
@@ -1324,6 +1392,18 @@ export async function startGatewayServer(
`invalid handshake conn=${connId} remote=${remoteAddr ?? "?"}`, `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"); socket.close(1008, "invalid handshake");
close(); close();
return; return;
@@ -1338,9 +1418,18 @@ export async function startGatewayServer(
maxProtocol < PROTOCOL_VERSION || maxProtocol < PROTOCOL_VERSION ||
minProtocol > PROTOCOL_VERSION minProtocol > PROTOCOL_VERSION
) { ) {
handshakeState = "failed";
logWsControl.warn( logWsControl.warn(
`protocol mismatch conn=${connId} remote=${remoteAddr ?? "?"} client=${connectParams.client.name} ${connectParams.client.mode} v${connectParams.client.version}`, `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({ send({
type: "res", type: "res",
id: frame.id, id: frame.id,
@@ -1364,9 +1453,24 @@ export async function startGatewayServer(
req: upgradeReq, req: upgradeReq,
}); });
if (!authResult.ok) { if (!authResult.ok) {
handshakeState = "failed";
logWsControl.warn( logWsControl.warn(
`unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${connectParams.client.name} ${connectParams.client.mode} v${connectParams.client.version}`, `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({ send({
type: "res", type: "res",
id: frame.id, id: frame.id,
@@ -1444,6 +1548,7 @@ export async function startGatewayServer(
clearTimeout(handshakeTimer); clearTimeout(handshakeTimer);
client = { socket, connect: connectParams, connId, presenceKey }; client = { socket, connect: connectParams, connId, presenceKey };
handshakeState = "connected";
logWs("out", "hello-ok", { logWs("out", "hello-ok", {
connId, connId,