a plugin can serve a websocket
Bun's route table is fixed at serve() and WS_ROUTES is read once before it, so a plugin installed afterwards could never add a socket route. That was a hard ceiling, not a preference: /minesweeper had to live in the platform and plugins/desktop could not stream a display at all. The way out was already in the tree and had one consumer. dev-proxy serves a socket with no WS_ROUTES entry, through a WILDCARD route whose handler calls server.upgrade itself. Nothing has to become dynamic, because a plugin name is a path segment rather than a route — one wildcard declared at boot serves every plugin that will ever be installed. Authorisation is the PLUGIN's own capability, asked as if the socket were a GET against its HTTP prefix. That is the safety argument: there is no second permission to grant, forget, or drift, so granting a plugin grants its socket and revoking it closes it. It is also why plugin-ws is exempt from the boot totality check rather than carrying a registry entry — one entry covering every plugin's socket would mean granting one plugin granted them all. A target is a TCP address and never a URL, which keeps the relay ignorant of what it carries. That ignorance is the only reason one relay can serve an RFB display and a line-oriented sidecar. Loopback only and not configurable: a plugin naming an arbitrary host would be a request-forgery primitive handed to every plugin author. sidecarPort() is new because createSidecarProxy learned announced ports per-proxy and that state was unreachable from anywhere else — a second subscription would have been a second thing to keep in step. Verified: boots past the totality gate, and both an existing and a non-existent plugin refuse 401 before revealing which. NOT yet verified: that it relays a byte. Minesweeper is about to be the first caller, which is what proves it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
import type { ServerWebSocket, Socket } from 'bun';
|
||||
import { resolveAuthToken } from '@@/auth-token';
|
||||
import { isTokenBlacklisted } from 'officerdb';
|
||||
import { isApiRequestAllowed } from '@@/permissions/authorize';
|
||||
import { pluginWsTargetFor } from './target';
|
||||
|
||||
/*
|
||||
One websocket door for every plugin, so a plugin can have a socket at all.
|
||||
|
||||
── Why this exists ──
|
||||
|
||||
Bun's route table is fixed at `serve()`, and `WS_ROUTES` is a static object read at boot. So a plugin
|
||||
installed afterwards had no way to add a socket route, and "a websocket provider is a platform concern"
|
||||
became a hard ceiling rather than a preference: `/minesweeper` had to live in the platform and
|
||||
`plugins/desktop` could not do the same thing at all.
|
||||
|
||||
The way out was already in the tree. `dev-proxy` serves a socket without a `WS_ROUTES` entry, through a
|
||||
WILDCARD route whose handler calls `server.upgrade` itself. Nothing about the route table needs to become
|
||||
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 ──
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
export type PluginWSData = {
|
||||
provider: 'plugin-ws';
|
||||
plugin: string;
|
||||
host: string;
|
||||
port: number;
|
||||
userId: number;
|
||||
};
|
||||
|
||||
type Relay = { socket: Socket | null; queue: Uint8Array[]; done: boolean };
|
||||
const relays = new Map<ServerWebSocket<PluginWSData>, Relay>();
|
||||
|
||||
const closeClient = (ws: ServerWebSocket<PluginWSData>, 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 */
|
||||
}
|
||||
};
|
||||
|
||||
export const pluginWsWebsocket = {
|
||||
async 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.
|
||||
*/
|
||||
const relay: Relay = { socket: null, queue: [], done: false };
|
||||
relays.set(ws, relay);
|
||||
|
||||
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'),
|
||||
},
|
||||
});
|
||||
|
||||
// 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.
|
||||
if (relay.done) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
relay.socket = socket;
|
||||
for (const chunk of relay.queue) socket.write(chunk);
|
||||
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`);
|
||||
}
|
||||
},
|
||||
|
||||
message(ws: ServerWebSocket<PluginWSData>, message: string | Uint8Array) {
|
||||
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);
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<PluginWSData>) {
|
||||
const relay = relays.get(ws);
|
||||
relays.delete(ws);
|
||||
if (relay) relay.done = true;
|
||||
try {
|
||||
relay?.socket?.end();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `/api/plugin-ws/<plugin>` with an upgrade header.
|
||||
*
|
||||
* Authorisation is PER PLUGIN, which is why `plugin-ws` is exempt from the boot-time totality check rather
|
||||
* than carrying a registry entry of its own. One entry covering every plugin's socket would be a hole:
|
||||
* granting one plugin would grant them all.
|
||||
*
|
||||
* So the decision is delegated to the plugin's OWN capability, by asking the same question its HTTP routes
|
||||
* are asked — `isApiRequestAllowed(user, 'GET', '/api/<plugin>')`. That is deliberate and it is the whole
|
||||
* safety argument: there is no second permission to grant, to forget, or to drift out of step. Granting a
|
||||
* plugin grants its socket, and revoking it closes it.
|
||||
*
|
||||
* Async, like `upgradeWs`. `server.upgrade` must be called synchronously with respect to the request, but
|
||||
* Bun accepts a route handler that returns a promise — which is what lets the token resolve, the blacklist
|
||||
* check and the permission decision all happen before anything is upgraded.
|
||||
*/
|
||||
export async function upgradePluginWs(req: Request, server: any): Promise<Response | undefined> {
|
||||
const url = new URL(req.url);
|
||||
const match = url.pathname.match(/^\/api\/plugin-ws\/([a-z0-9-]{1,64})\/?$/);
|
||||
if (!match) return undefined;
|
||||
const plugin = match[1]!;
|
||||
|
||||
// A browser cannot set headers on a WebSocket upgrade, so the token arrives as a query parameter — the
|
||||
// concession every other socket here already makes. Verified through the same resolver as the HTTP doors,
|
||||
// so an API key that works against /api works here too.
|
||||
const token = url.searchParams.get('token');
|
||||
if (!token) return new Response('Unauthorized', { status: 401 });
|
||||
|
||||
try {
|
||||
const user = await resolveAuthToken(token);
|
||||
if (!user) return new Response('Unauthorized', { status: 401 });
|
||||
if (user.jti && (await isTokenBlacklisted(user.jti))) return new Response('Unauthorized', { status: 401 });
|
||||
|
||||
// The plugin's own gate, asked as if this were a GET against its HTTP prefix.
|
||||
if (!(await isApiRequestAllowed(user.id, 'GET', `/api/${plugin}`)).allowed) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const target = await pluginWsTargetFor(plugin);
|
||||
// 404 for both "no such plugin" and "no socket declared", identically. A caller must not be able to
|
||||
// discover which plugins are installed by which refusal it gets — the same reasoning as SendOff's
|
||||
// recipient lookup.
|
||||
if (!target) return new Response('Not found', { status: 404 });
|
||||
|
||||
const ok = server.upgrade(req, {
|
||||
data: {
|
||||
provider: 'plugin-ws',
|
||||
plugin,
|
||||
host: target.host,
|
||||
port: target.port,
|
||||
userId: user.id,
|
||||
} satisfies PluginWSData,
|
||||
});
|
||||
return ok ? undefined : new Response('Upgrade failed', { status: 400 });
|
||||
} catch {
|
||||
// Any failure to establish WHO is calling denies. Never open a socket on a maybe.
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user