── the stage ── The framebuffer is now the picture. 800x600 whatever the difficulty, the game centred in it by the sidecar, and the panel draws it entire — scaled to fit, centred by its flex parent, never cropped. Changing difficulty moves the board instead of resizing the panel. What this replaces parked the window at the origin and had the client crop to the window's size, polled every four seconds. Two things wrong with it. The panel resized under the player mid-game; and wine puts the window at 3,29 rather than 0,0, so cropping from the origin showed a black strip on top and cut a row of cells off the bottom — the "expert gets cut off" report. Centring runs on a one-second timer because nothing announces a resize: not RFB, which keeps the framebuffer, and not X without a window manager to listen through. One chained `xdotool search … getwindowgeometry` per tick, one process, and a `windowmove` only when the numbers actually changed. The client loses the geometry poll, the offscreen canvas and the crop with it. ── the port ── x11vnc was listening on 0.0.0.0:5900 and [::]:5900 with -nopw. Every account on this machine, every device on the tailnet and everything on the LAN could open it and take the display — which is precisely the hole the 5907 version was replaced to close, reopened wider and documented as impossible. `-unixsock` ADDS a unix socket. It does not remove the TCP one. Three flags do: -rfbport 0 for IPv4, -rfbportv6 0 for IPv6, -noipv6 for outgoing. `-rfbport 0` alone leaves [::]:5900 up, which is how this nearly shipped half-fixed. Found with `ss -lntp` on the running process, and that is the only check that was right about it at any point. Running the same argv by hand showed no IPv6 listener — most likely because the PM2 session still held [::]:5900 and the hand-run copy failed to bind it silently. Comments in session.ts and index.ts that asserted there was no port are corrected rather than deleted. Verified on the live socket: the streamed framebuffer is 447k teal pixels around a 152x214 window at 324,193 — dead centre — and window grey arrives as c0 c0 c0, no TCP listener for the x11vnc pid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
214 lines
8.7 KiB
TypeScript
214 lines
8.7 KiB
TypeScript
import type { ServerWebSocket, Socket } from 'bun';
|
|
import { createSidecarConnector } from '@@/sidecar/connect';
|
|
import { API_URL } from '@@/officer-url.mjs';
|
|
import { VNC_SOCKET, ensureDirs } from './paths';
|
|
import { isRunning, start, status, stop } from './session';
|
|
|
|
/*
|
|
officer-minesweeper — owns the game session, and serves both halves of the plugin from one listener.
|
|
|
|
─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
HTTP CONTRACT — the platform strips its /api/minesweeper mount prefix before forwarding.
|
|
|
|
GET /status { installed, running, window, error? }
|
|
POST /start brings the session up; idempotent, and builds the wine prefix on a cold install
|
|
POST /stop takes it down
|
|
GET /ws the display — an RFB stream, relayed frame-for-byte onto x11vnc's unix socket
|
|
|
|
─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
WHY ONE PORT SERVES BOTH
|
|
|
|
A sidecar announces exactly one port, and that single announcement is the entire reason the platform
|
|
trusts it: the port was observed arriving at registration rather than asserted by a manifest. So `/ws`
|
|
lives here, beside the routes, on the listener that was announced — and the platform's plugin socket
|
|
relay moves frames onto it without knowing that RFB exists.
|
|
|
|
The conversion from frames to a byte stream happens HERE, in the only process that knows what it is
|
|
carrying. That is the whole shape: the platform authenticates and relays; the plugin speaks its protocol.
|
|
|
|
─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
WHAT REACHES x11vnc
|
|
|
|
Nothing but this file. x11vnc binds a socket inside a mode-0700 directory, so the kernel refuses every
|
|
other account before any credential could be checked. That is why `-nopw` is right rather than lazy —
|
|
see the block comment in `session.ts` where it is spawned.
|
|
|
|
This paragraph used to end "and has no port at all", and that was false for as long as it existed:
|
|
`-unixsock` ADDS a unix socket, it does not remove the TCP listener, so x11vnc was on 0.0.0.0:5900 with
|
|
no password. `-rfbport 0` is what makes the sentence true, and it is in `session.ts` for that reason.
|
|
|
|
This listener is loopback and unauthenticated, exactly like every other sidecar in the fleet; the platform
|
|
is the authenticator and `X-Officer-User` is how a request proves it came through one.
|
|
*/
|
|
|
|
const NAME = 'minesweeper';
|
|
const log = (line: string) => console.log(`[${NAME}] ${line}`);
|
|
|
|
ensureDirs();
|
|
|
|
/** An ephemeral free port, grabbed by briefly binding one. Announced at registration; never written down. */
|
|
function getFreePort(): number {
|
|
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
|
const p = probe.port;
|
|
probe.stop(true);
|
|
if (p == null) throw new Error('failed to acquire a free port');
|
|
return p;
|
|
}
|
|
|
|
const port = getFreePort();
|
|
|
|
type WsData = { userId: number };
|
|
type Relay = { socket: Socket | null; queue: Uint8Array[]; done: boolean };
|
|
const relays = new Map<ServerWebSocket<WsData>, Relay>();
|
|
|
|
const teardown = (ws: ServerWebSocket<WsData>, 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 closing */
|
|
}
|
|
};
|
|
|
|
const server = Bun.serve({
|
|
port,
|
|
hostname: '127.0.0.1',
|
|
|
|
async fetch(req, srv) {
|
|
const url = new URL(req.url);
|
|
|
|
// Loopback plus this header is the trust signal every sidecar here uses: a request without it did not
|
|
// come through the platform, and the platform is what decided the caller may play.
|
|
const officerUser = req.headers.get('X-Officer-User');
|
|
const userId = Number(officerUser);
|
|
if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
|
|
return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
|
|
}
|
|
|
|
if (url.pathname === '/ws') {
|
|
if (srv.upgrade(req, { data: { userId } satisfies WsData })) return undefined as unknown as Response;
|
|
return new Response('expected a websocket upgrade', { status: 426 });
|
|
}
|
|
|
|
try {
|
|
if (req.method === 'GET' && url.pathname === '/status') return Response.json(await status());
|
|
if (req.method === 'POST' && url.pathname === '/start') {
|
|
const result = await start(log);
|
|
// 503 rather than 500: the session is unavailable and the reason is in the body, which is what the
|
|
// panel shows instead of an empty canvas.
|
|
return Response.json(result, { status: result.running ? 200 : 503 });
|
|
}
|
|
if (req.method === 'POST' && url.pathname === '/stop') return Response.json(await stop());
|
|
} catch (err) {
|
|
console.error(`[${NAME}] ${req.method} ${url.pathname} failed`, err);
|
|
return Response.json({ error: 'internal error' }, { status: 500 });
|
|
}
|
|
|
|
return Response.json({ error: 'not found' }, { status: 404 });
|
|
},
|
|
|
|
websocket: {
|
|
open(ws: ServerWebSocket<WsData>) {
|
|
/*
|
|
The queue is not an optimisation. The client is connected the moment the upgrade returns and an RFB
|
|
client sends its protocol version immediately, while `Bun.connect` is still resolving. Dropping
|
|
those bytes desynchronises the handshake in a way that renders as a corrupt display rather than as
|
|
a lost packet.
|
|
*/
|
|
const relay: Relay = { socket: null, queue: [], done: false };
|
|
relays.set(ws, relay);
|
|
|
|
if (!isRunning()) {
|
|
teardown(ws, 1011, 'the session is not running');
|
|
return;
|
|
}
|
|
|
|
void Bun.connect({
|
|
unix: VNC_SOCKET,
|
|
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 {
|
|
/* viewer gone; close tidies up */
|
|
}
|
|
},
|
|
close: () => teardown(ws, 1000, 'display closed'),
|
|
error: () => teardown(ws, 1011, 'display error'),
|
|
},
|
|
}).catch(() => teardown(ws, 1011, 'could not reach the display'));
|
|
},
|
|
|
|
message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
|
|
const relay = relays.get(ws);
|
|
if (!relay) return;
|
|
// RFB is binary both ways; 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<WsData>) {
|
|
const relay = relays.get(ws);
|
|
relays.delete(ws);
|
|
if (relay) relay.done = true;
|
|
try {
|
|
relay?.socket?.end();
|
|
} catch {
|
|
/* already gone */
|
|
}
|
|
},
|
|
},
|
|
});
|
|
|
|
log(`listening on 127.0.0.1:${port}`);
|
|
|
|
const connection = createSidecarConnector({
|
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
|
name: NAME,
|
|
handles: [NAME],
|
|
onCommand(cmd, reply) {
|
|
const c = cmd as { type: string; id?: string };
|
|
if (c.type === 'ping') reply({ type: 'pong', id: c.id } as never);
|
|
else reply({ type: 'error', id: c.id, error: `Unknown command type: ${c.type}` } as never);
|
|
},
|
|
onConnected() {
|
|
// The announcement the plugin websocket relay resolves against. One port, both halves.
|
|
connection.send({ type: `${NAME}:server`, port } as never);
|
|
log(`reported server port ${port} to the platform`);
|
|
},
|
|
});
|
|
|
|
async function shutdown(signal: string) {
|
|
log(`${signal} received, shutting down`);
|
|
try {
|
|
server.stop(true);
|
|
} catch {
|
|
/* already stopped */
|
|
}
|
|
// The game is not left running. It is a child of this process, so PM2 would reach it anyway — but wine's
|
|
// own daemons are not, and leaving those holding the prefix is what makes the NEXT start hang.
|
|
await stop().catch(() => undefined);
|
|
connection.destroy();
|
|
process.exit(0);
|
|
}
|
|
|
|
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
process.on('SIGINT', () => void shutdown('SIGINT'));
|