a websocket byte relay onto x11vnc's tcp socket, the same shape as the terminal's relay onto the pty sidecar. this is the piece plugins/desktop could not have: a plugin cannot claim a websocket provider. the rfb client is ours rather than vendored novnc — raw and copyrect, one pixel format we dictate so a rectangle memcpys into ImageData. we control the server, so the other eight encodings are surface we would carry and never run. execution, so owner-only: wine runs as the owner, which makes the game's menus file dialogs over the owner's home. two bugs worth keeping. the token key is BEARER_TOKEN, not token, so the upgrade 401'd before any rfb byte moved. and the /status health check opened a tcp connection to prove liveness — libvncserver blocks its accept loop peeking for a websocket handshake, so a polled health check filled the backlog and locked out the real viewer. a liveness check must not be indistinguishable from a client; it reads /proc/net/tcp now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
96 lines
3.2 KiB
TypeScript
96 lines
3.2 KiB
TypeScript
import type { ServerWebSocket, Socket } from 'bun';
|
|
import { VNC_HOST, VNC_PORT } from './session';
|
|
|
|
/**
|
|
* A byte relay between the browser and x11vnc, in the same spirit as the terminal's relay onto the pty
|
|
* sidecar — this file authenticates the upgrade (upgradeWs, in server.tsx) and moves bytes without
|
|
* reading them.
|
|
*
|
|
* One difference decides the shape: x11vnc serves raw TCP, not a WebSocket. So this is the job
|
|
* websockify would otherwise do, done in-process with `Bun.connect` rather than by adding a second
|
|
* daemon and a second port to protect. RFB is a stream protocol, so frame boundaries carry no meaning
|
|
* and chunks are forwarded exactly as they arrive.
|
|
*/
|
|
|
|
export type MinesweeperWSData = { provider: 'minesweeper'; search: string };
|
|
|
|
type Relay = { socket: Socket | null; queue: Uint8Array[]; done: boolean };
|
|
|
|
const relays = new Map<ServerWebSocket<MinesweeperWSData>, Relay>();
|
|
|
|
const closeClient = (ws: ServerWebSocket<MinesweeperWSData>, code: number, reason: string) => {
|
|
const relay = relays.get(ws);
|
|
relays.delete(ws);
|
|
if (relay) relay.done = true;
|
|
try {
|
|
relay?.socket?.end();
|
|
} catch {
|
|
/* already gone */
|
|
}
|
|
try {
|
|
ws.close(code, reason);
|
|
} catch {
|
|
/* already closed */
|
|
}
|
|
};
|
|
|
|
export const minesweeperWebsocket = {
|
|
open(ws: ServerWebSocket<MinesweeperWSData>) {
|
|
// Registered synchronously, so bytes arriving before the TCP connection is up are queued rather
|
|
// than dropped. The RFB handshake starts with the SERVER talking, but a client that pipelines its
|
|
// version string would otherwise lose it.
|
|
const relay: Relay = { socket: null, queue: [], done: false };
|
|
relays.set(ws, relay);
|
|
|
|
void Bun.connect({
|
|
hostname: VNC_HOST,
|
|
port: VNC_PORT,
|
|
socket: {
|
|
open(socket) {
|
|
if (relay.done) {
|
|
socket.end();
|
|
return;
|
|
}
|
|
relay.socket = socket;
|
|
for (const chunk of relay.queue) socket.write(chunk);
|
|
relay.queue.length = 0;
|
|
},
|
|
data(_socket, chunk) {
|
|
try {
|
|
ws.send(chunk);
|
|
} catch {
|
|
/* client gone; the close handler tears down the socket */
|
|
}
|
|
},
|
|
close: () => closeClient(ws, 1000, 'vnc closed'),
|
|
error: () => closeClient(ws, 1011, 'vnc error'),
|
|
},
|
|
}).catch(() => {
|
|
// No x11vnc listening — the session is not running. 1011 rather than a silent close, so the
|
|
// panel can say so instead of showing an empty canvas.
|
|
closeClient(ws, 1011, 'minesweeper session is not running');
|
|
});
|
|
},
|
|
|
|
message(ws: ServerWebSocket<MinesweeperWSData>, raw: string | Buffer) {
|
|
const relay = relays.get(ws);
|
|
if (!relay) return;
|
|
|
|
// RFB is binary in both directions; a text frame is a client that is not speaking it.
|
|
const bytes = typeof raw === 'string' ? new TextEncoder().encode(raw) : new Uint8Array(raw);
|
|
if (relay.socket) relay.socket.write(bytes);
|
|
else relay.queue.push(bytes);
|
|
},
|
|
|
|
close(ws: ServerWebSocket<MinesweeperWSData>) {
|
|
const relay = relays.get(ws);
|
|
relays.delete(ws);
|
|
if (relay) relay.done = true;
|
|
try {
|
|
relay?.socket?.end();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
},
|
|
};
|