fix: improve ws close diagnostics
This commit is contained in:
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user