Close an edge socket that never authenticates

The edge protocol carries its token in the first message rather than the
handshake, which is the right call for a WebSocket - a browser cannot set
headers, and a query parameter puts a bearer credential into every access
and proxy log. But the cost of that choice is a window in which an open
socket belongs to nobody, and nothing was closing it.

Every other message type already refuses to act without a `farmId`, so
such a socket could do nothing. It did sit in `connections` forever
though, and that map is keyed by whoever can reach the port.

Ten seconds, which is generous for a round trip a real gateway makes
immediately on open; a tighter bound would only drop gateways on slow
links. The timer is cleared after the token verifies and the farm
matches, so a client retrying a bad token on one socket still gets
dropped rather than being handed an unbounded number of attempts.

85 tests, `bunx tsc --noEmit` clean.
This commit is contained in:
2026-10-16 06:42:56 +00:00
parent 4de0d86d43
commit 7a0e8ba6ef
+39 -2
View File
@@ -48,6 +48,19 @@ interface EdgeConn {
* after the gateway disconnected or moved. Undefined means none advertised. * after the gateway disconnected or moved. Undefined means none advertised.
*/ */
streamBaseUrl?: string; streamBaseUrl?: string;
/**
* Closes this socket if it never authenticates. Cleared by a successful
* `hello` and on close.
*
* The cost of carrying the token in the first message rather than the
* handshake is exactly this: there is a window in which an open socket
* belongs to nobody. Every other message type already refuses to act
* without a `farmId`, so such a socket can do nothing - but it does sit in
* `connections` forever, and that map is keyed by whoever can reach the
* port. A silent connection is not a slow one: a real gateway sends
* `hello` as its first frame.
*/
handshakeTimer?: ReturnType<typeof setTimeout>;
send: (message: EdgeServerMessage) => void; send: (message: EdgeServerMessage) => void;
/** /**
* Gateway printer id -> our `printers.id`. Filled on the first telemetry * Gateway printer id -> our `printers.id`. Filled on the first telemetry
@@ -136,6 +149,14 @@ export function listUnclaimedPrinters(farmId: string): UnclaimedPrinter[] {
/** How long to wait for a gateway to answer a command before giving up. */ /** How long to wait for a gateway to answer a command before giving up. */
const COMMAND_TIMEOUT_MS = 10_000; const COMMAND_TIMEOUT_MS = 10_000;
/**
* How long a socket may stay open without authenticating. Generous compared
* with the round trip it covers - the gateway sends `hello` immediately after
* the socket opens - because the only thing a tight bound would buy is
* dropping gateways on slow links.
*/
const HANDSHAKE_TIMEOUT_MS = 10_000;
/** /**
* Maps a printer's own state string onto the narrowed `printer_status` enum. * Maps a printer's own state string onto the narrowed `printer_status` enum.
* *
@@ -318,7 +339,7 @@ export const edgeWsRoute = new Elysia()
open(ws) { open(ws) {
// No farmId yet - set by `hello`, and every other message type refuses // No farmId yet - set by `hello`, and every other message type refuses
// to do anything until it is. // to do anything until it is.
connections.set(ws.id, { const conn: EdgeConn = {
farmId: "", farmId: "",
gatewayId: "", gatewayId: "",
sessionId: crypto.randomUUID(), sessionId: crypto.randomUUID(),
@@ -326,7 +347,15 @@ export const edgeWsRoute = new Elysia()
send: (message) => ws.send(message), send: (message) => ws.send(message),
printerIds: new Map(), printerIds: new Map(),
pending: new Map(), pending: new Map(),
}); };
conn.handshakeTimer = setTimeout(() => {
if (conn.farmId) return;
console.warn(`[edge] closing socket ${ws.id}: no hello within ${HANDSHAKE_TIMEOUT_MS}ms`);
ws.close();
}, HANDSHAKE_TIMEOUT_MS);
connections.set(ws.id, conn);
}, },
async message(ws, message) { async message(ws, message) {
@@ -354,6 +383,13 @@ export const edgeWsRoute = new Elysia()
return; return;
} }
// Authenticated, so it is no longer a socket belonging to nobody.
// Cleared only here, after the token verified and the farm matched
// - a rejected hello leaves the timer running, so a client that
// retries a bad token on one socket still gets dropped.
clearTimeout(conn.handshakeTimer);
conn.handshakeTimer = undefined;
conn.farmId = farmId; conn.farmId = farmId;
conn.gatewayId = message.gateway_id; conn.gatewayId = message.gateway_id;
// `?? undefined`: absent and explicit null both mean "no streams". // `?? undefined`: absent and explicit null both mean "no streams".
@@ -447,6 +483,7 @@ export const edgeWsRoute = new Elysia()
close(ws) { close(ws) {
const conn = connections.get(ws.id); const conn = connections.get(ws.id);
if (conn) { if (conn) {
clearTimeout(conn.handshakeTimer);
// Anything still waiting on this gateway will never be answered now. // Anything still waiting on this gateway will never be answered now.
// Failing them immediately beats making every caller wait out the // Failing them immediately beats making every caller wait out the
// full timeout for a socket that is already gone. // full timeout for a socket that is already gone.