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:
+39
-2
@@ -48,6 +48,19 @@ interface EdgeConn {
|
||||
* after the gateway disconnected or moved. Undefined means none advertised.
|
||||
*/
|
||||
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;
|
||||
/**
|
||||
* 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. */
|
||||
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.
|
||||
*
|
||||
@@ -318,7 +339,7 @@ export const edgeWsRoute = new Elysia()
|
||||
open(ws) {
|
||||
// No farmId yet - set by `hello`, and every other message type refuses
|
||||
// to do anything until it is.
|
||||
connections.set(ws.id, {
|
||||
const conn: EdgeConn = {
|
||||
farmId: "",
|
||||
gatewayId: "",
|
||||
sessionId: crypto.randomUUID(),
|
||||
@@ -326,7 +347,15 @@ export const edgeWsRoute = new Elysia()
|
||||
send: (message) => ws.send(message),
|
||||
printerIds: 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) {
|
||||
@@ -354,6 +383,13 @@ export const edgeWsRoute = new Elysia()
|
||||
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.gatewayId = message.gateway_id;
|
||||
// `?? undefined`: absent and explicit null both mean "no streams".
|
||||
@@ -447,6 +483,7 @@ export const edgeWsRoute = new Elysia()
|
||||
close(ws) {
|
||||
const conn = connections.get(ws.id);
|
||||
if (conn) {
|
||||
clearTimeout(conn.handshakeTimer);
|
||||
// Anything still waiting on this gateway will never be answered now.
|
||||
// Failing them immediately beats making every caller wait out the
|
||||
// full timeout for a socket that is already gone.
|
||||
|
||||
Reference in New Issue
Block a user