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.
|
* 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user