minesweeper leaves the platform; plugin sockets relay ws-to-ws
Minesweeper is now plugins/minesweeper (gitea.officer.dev/plugins/minesweeper). Removes /api/minesweeper, the Dashboard screen, the officerdev panel, the permission registry entry, the App.tsx route and the hand-written websocket provider in server.tsx. Registry entries: 22 -> 21, execution 4 -> 3. The relay changed transport, and that is the part worth reading. A sidecar announces exactly one port. That single announcement is the whole provenance argument in target.ts — the platform watched the port arrive at registration, which is the only thing separating it from a number a manifest asserted. But a raw-TCP relay requires that port to BE a raw TCP listener, so the sidecar cannot also serve HTTP on it, and the plugin's ordinary routes have to fall back to the typed command vocabulary over the registration socket. That is the mechanism the fleet has spent months retiring, and Minesweeper would have been the first thing to go back to it. Relaying websocket-to-websocket dissolves the conflict. The sidecar serves HTTP and /ws on the one port it announced, exactly as the pty sidecar already does; createSidecarProxy handles the HTTP half; and the conversion from frames to whatever the plugin actually speaks happens inside the plugin, which is the only process that knows. The relay is still ignorant of the payload — the ignorance sits one layer up. Also: the caller's JWT is no longer forwarded upstream. A sidecar has no use for it and forwarding one puts a bearer token into whatever that process logs. X-Officer-User carries the only part it needs. (The terminal relay still forwards its token to the pty sidecar; not touched here.) Verified: bunx tsgo clean, bun test 1403 pass / 0 fail, bun run docs:check green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -4,10 +4,9 @@ import { snapshotPlugins } from '@@/plugins/mount';
|
||||
/*
|
||||
Where a plugin's socket actually goes.
|
||||
|
||||
A target is a TCP address and never a URL, and that is what keeps the relay ignorant of the protocol on
|
||||
the other side. The ignorance is the point: it is the only reason one relay can serve an RFB display and a
|
||||
line-oriented sidecar without knowing which is which. The moment this returned a scheme or a path, the
|
||||
relay would have to care what it was carrying.
|
||||
A target is an ADDRESS — a host and a port — never a URL a manifest wrote. The relay turns it into
|
||||
`ws://<host>:<port>/ws`, and `/ws` is a convention rather than a setting for the same reason the host is
|
||||
not configurable: every value here has to be one the platform derived, not one a plugin asserted.
|
||||
|
||||
The target is always a SIDECAR. A fixed-port form was written and removed the same day: nothing stops a
|
||||
manifest naming a port it does not own, so `{ port: 5432 }` plus a grant would be a raw TCP relay onto
|
||||
@@ -15,7 +14,10 @@ import { snapshotPlugins } from '@@/plugins/mount';
|
||||
to the plugin, and only the second question matters.
|
||||
|
||||
A sidecar's port is different in kind, not degree: the platform watched it arrive at registration, so the
|
||||
association is observed rather than asserted.
|
||||
association is observed rather than asserted. It is also the SAME port the sidecar serves its HTTP on —
|
||||
one announcement, one listener, `/ws` alongside the routes. That is what the relay's move to
|
||||
websocket-to-websocket bought, and why a plugin wanting a socket no longer has to choose between having
|
||||
one and having ordinary routes.
|
||||
|
||||
**Host is always loopback and is not configurable.** A plugin socket that could name an arbitrary host
|
||||
would be a request-forgery primitive handed to every plugin author and reachable by anyone the plugin is
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ServerWebSocket, Socket } from 'bun';
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { resolveAuthToken } from '@@/auth-token';
|
||||
import { isTokenBlacklisted } from 'officerdb';
|
||||
import { isApiRequestAllowed } from '@@/permissions/authorize';
|
||||
@@ -19,26 +19,37 @@ import { pluginWsTargetFor } from './target';
|
||||
dynamic — one wildcard declared at boot can serve every plugin that will ever be installed, because the
|
||||
plugin name is a path segment rather than a route.
|
||||
|
||||
── What it does and does not read ──
|
||||
── The upstream is a WEBSOCKET, not a raw TCP socket ──
|
||||
|
||||
Bytes, both ways, without interpreting them. Same as the terminal's relay onto the pty sidecar and the
|
||||
minesweeper relay onto x11vnc: frame boundaries carry no meaning to a stream protocol, so chunks are
|
||||
forwarded exactly as they arrive.
|
||||
This relayed onto a raw TCP address for about a day. It was changed while extracting Minesweeper, and the
|
||||
reason is worth keeping because it is not a preference.
|
||||
|
||||
A plugin's target is a TCP address, never a URL. That is deliberate — it keeps this file ignorant of
|
||||
whatever protocol is on the other side, which is the only reason one relay can serve an RFB display and a
|
||||
line-oriented sidecar without knowing which is which.
|
||||
**A sidecar announces exactly one port.** That is the whole provenance argument for `target.ts` — the
|
||||
platform watched that port arrive at registration, which is the only thing separating it from a number a
|
||||
manifest asserted. But if this relay needs raw TCP, that one port has to BE a raw TCP listener, so the
|
||||
sidecar cannot also serve HTTP on it, and the plugin's ordinary routes have to fall back to the typed
|
||||
command vocabulary over the registration socket — the mechanism the fleet has spent months retiring.
|
||||
|
||||
Relaying websocket-to-websocket dissolves that. The sidecar serves HTTP and `/ws` on the single port it
|
||||
announced, exactly as the pty sidecar already does; `createSidecarProxy` handles the HTTP half; and the
|
||||
conversion from frames to whatever the plugin actually speaks happens INSIDE the plugin, which is the only
|
||||
process that knows what that is. Minesweeper's x11vnc is the worked example: the sidecar owns the RFB
|
||||
stream and the platform never learns that RFB exists.
|
||||
|
||||
This file is still ignorant of the payload — it moves frames without reading them, and a stream protocol's
|
||||
boundaries carry no meaning either way. The ignorance simply sits one layer up.
|
||||
*/
|
||||
|
||||
export type PluginWSData = {
|
||||
provider: 'plugin-ws';
|
||||
plugin: string;
|
||||
host: string;
|
||||
port: number;
|
||||
/** `ws://127.0.0.1:<announced port>/ws`, with the client's query minus its credential. */
|
||||
upstream: string;
|
||||
userId: number;
|
||||
};
|
||||
|
||||
type Relay = { socket: Socket | null; queue: Uint8Array[]; done: boolean };
|
||||
type Frame = string | Uint8Array<ArrayBuffer>;
|
||||
type Relay = { ws: WebSocket | null; queue: Frame[]; done: boolean };
|
||||
const relays = new Map<ServerWebSocket<PluginWSData>, Relay>();
|
||||
|
||||
const closeClient = (ws: ServerWebSocket<PluginWSData>, code: number, reason: string) => {
|
||||
@@ -46,7 +57,7 @@ const closeClient = (ws: ServerWebSocket<PluginWSData>, code: number, reason: st
|
||||
relays.delete(ws);
|
||||
if (relay) relay.done = true;
|
||||
try {
|
||||
relay?.socket?.end();
|
||||
relay?.ws?.close();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
@@ -58,55 +69,63 @@ const closeClient = (ws: ServerWebSocket<PluginWSData>, code: number, reason: st
|
||||
};
|
||||
|
||||
export const pluginWsWebsocket = {
|
||||
async open(ws: ServerWebSocket<PluginWSData>) {
|
||||
open(ws: ServerWebSocket<PluginWSData>) {
|
||||
/*
|
||||
The queue is not an optimisation. Bun requires `server.upgrade` to be synchronous, so the client is
|
||||
already connected and may already be talking by the time `Bun.connect` resolves — and an RFB client
|
||||
sends its protocol version immediately. Dropping those bytes desynchronises the stream in a way that
|
||||
looks like a corrupt display rather than a lost packet. Learned by dev-proxy first.
|
||||
already connected and may already be talking before the upstream is open — and an RFB client sends its
|
||||
protocol version immediately. Dropping those frames desynchronises the stream in a way that looks like
|
||||
a corrupt display rather than a lost packet. Learned by dev-proxy first.
|
||||
*/
|
||||
const relay: Relay = { socket: null, queue: [], done: false };
|
||||
const relay: Relay = { ws: null, queue: [], done: false };
|
||||
relays.set(ws, relay);
|
||||
|
||||
let upstream: WebSocket;
|
||||
try {
|
||||
const socket = await Bun.connect({
|
||||
hostname: ws.data.host,
|
||||
port: ws.data.port,
|
||||
socket: {
|
||||
data: (_s, chunk) => {
|
||||
try {
|
||||
ws.send(chunk);
|
||||
} catch {
|
||||
/* client gone; the close handler tidies up */
|
||||
}
|
||||
},
|
||||
close: () => closeClient(ws, 1011, 'upstream closed'),
|
||||
error: () => closeClient(ws, 1011, 'upstream error'),
|
||||
},
|
||||
});
|
||||
// The sidecar binds loopback and is told who is calling, the same signal every HTTP sidecar trusts:
|
||||
// a request without this header did not come through the platform.
|
||||
upstream = new WebSocket(ws.data.upstream, { headers: { 'X-Officer-User': String(ws.data.userId) } } as never);
|
||||
} catch {
|
||||
closeClient(ws, 1011, `${ws.data.plugin} is not accepting connections`);
|
||||
return;
|
||||
}
|
||||
relay.ws = upstream;
|
||||
upstream.binaryType = 'arraybuffer';
|
||||
|
||||
// Between the await above and here the client may have gone. Writing to a socket nobody is reading
|
||||
// would keep the upstream connection open for as long as the plugin's process lives.
|
||||
upstream.onopen = () => {
|
||||
// Between the upgrade and here the client may have gone. Sending into a socket nobody reads would hold
|
||||
// the upstream connection open for as long as the plugin's process lives.
|
||||
if (relay.done) {
|
||||
socket.end();
|
||||
try {
|
||||
upstream.close();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
return;
|
||||
}
|
||||
relay.socket = socket;
|
||||
for (const chunk of relay.queue) socket.write(chunk);
|
||||
for (const frame of relay.queue) upstream.send(frame);
|
||||
relay.queue.length = 0;
|
||||
} catch {
|
||||
// A plugin whose process is not listening is the ordinary case, not an exception: a sidecar that has
|
||||
// not booted yet, or a plugin whose service is stopped. The client is told, and told which plugin.
|
||||
closeClient(ws, 1011, `${ws.data.plugin} is not accepting connections`);
|
||||
}
|
||||
};
|
||||
|
||||
upstream.onmessage = (ev) => {
|
||||
try {
|
||||
ws.send(typeof ev.data === 'string' ? ev.data : new Uint8Array(ev.data as ArrayBuffer));
|
||||
} catch {
|
||||
/* client gone; the close handler tidies up */
|
||||
}
|
||||
};
|
||||
|
||||
// A plugin whose process is not listening is the ordinary case rather than an exception: a sidecar that
|
||||
// has not booted, or a service the owner stopped. The client is told, and told which plugin.
|
||||
upstream.onerror = () => closeClient(ws, 1011, `${ws.data.plugin} is not accepting connections`);
|
||||
upstream.onclose = () => closeClient(ws, 1011, 'upstream closed');
|
||||
},
|
||||
|
||||
message(ws: ServerWebSocket<PluginWSData>, message: string | Uint8Array) {
|
||||
message(ws: ServerWebSocket<PluginWSData>, message: string | Buffer) {
|
||||
const relay = relays.get(ws);
|
||||
if (!relay) return;
|
||||
const bytes = typeof message === 'string' ? new TextEncoder().encode(message) : message;
|
||||
if (relay.socket) relay.socket.write(bytes);
|
||||
else relay.queue.push(bytes);
|
||||
const frame: Frame = typeof message === 'string' ? message : (message as unknown as Uint8Array<ArrayBuffer>);
|
||||
if (relay.ws && relay.ws.readyState === WebSocket.OPEN) relay.ws.send(frame);
|
||||
else relay.queue.push(frame);
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<PluginWSData>) {
|
||||
@@ -114,7 +133,7 @@ export const pluginWsWebsocket = {
|
||||
relays.delete(ws);
|
||||
if (relay) relay.done = true;
|
||||
try {
|
||||
relay?.socket?.end();
|
||||
relay?.ws?.close();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
@@ -165,12 +184,18 @@ export async function upgradePluginWs(req: Request, server: any): Promise<Respon
|
||||
// recipient lookup.
|
||||
if (!target) return new Response('Not found', { status: 404 });
|
||||
|
||||
// The rest of the query is the plugin's to interpret and is passed through untouched. The CREDENTIAL is
|
||||
// not: a sidecar has no use for the caller's JWT, and forwarding one puts a bearer token into whatever
|
||||
// that process logs. `X-Officer-User` carries the only part it needs.
|
||||
const forwarded = new URLSearchParams(url.search);
|
||||
forwarded.delete('token');
|
||||
const query = forwarded.toString();
|
||||
|
||||
const ok = server.upgrade(req, {
|
||||
data: {
|
||||
provider: 'plugin-ws',
|
||||
plugin,
|
||||
host: target.host,
|
||||
port: target.port,
|
||||
upstream: `ws://${target.host}:${target.port}/ws${query ? `?${query}` : ''}`,
|
||||
userId: user.id,
|
||||
} satisfies PluginWSData,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user