feat: serve plugin http and websockets from one mount instead of ports

This commit is contained in:
LukeGus committed 2026-09-22 16:39:41 -05:00
1 parent c496a093ca
commit 8431570ff7
102 files changed
+2894 -2308

No files matched your search

+2 -2
View File
@@ -15,7 +15,7 @@ import {
// src/backend/database/routes/guacamole-dispatch.ts and that plugin's
// README -- this is an accepted, temporary coupling until collab becomes a
// plugin of its own.
import { createGuacamoleJoinToken } from "../../database/routes/guacamole-dispatch.js";
import { createGuacamoleJoinToken } from "../guacamole-sessions.js";
import { collabRoomHub } from "./room-hub.js";
import { getStageController, setStageController } from "./stage-control.js";
import { isCollabGuestRateLimited } from "./guest-rate-limit.js";
@@ -1091,7 +1091,7 @@ router.get("/guest/:token", async (req: Request, res: Response) => {
shareId: share.id,
...(protocol === "ssh"
? {
wsPath: `/terminal/ws?roomGuestToken=${encodeURIComponent(token)}`,
wsPath: `/plugin-ws/ssh-terminal/terminal?roomGuestToken=${encodeURIComponent(token)}`,
}
: {
connectParams: {
+79
View File
@@ -0,0 +1,79 @@
/**
* What core needs from the Remote Desktop plugin, resolved through the plugin
* registry rather than an import.
*
* collab, session-sharing and the admin guacd setting all need three things
* from a running Guacamole server: mint a join token, look up a live session,
* restart after a settings change. None of them can import the plugin -- core
* cannot statically import something outside its rootDir -- and the plugin
* cannot hand core a module reference without becoming core again.
*
* So the plugin publishes "remote-desktop.sessions" through ctx.registry on
* activate, the registry revokes it on deactivate, and the helpers below
* degrade the way each caller needs when nothing is published:
*
* - a lookup returns null, because "no such live session" and "the plugin is
* off" mean the same thing to a caller asking whether a session is live;
* - a restart is a no-op, because resurrecting a server the plugin does not
* own would be worse than doing nothing;
* - minting a token throws, because a caller about to hand a viewer a join
* link needs to know it failed rather than get an unusable value.
*
* This replaces the guacamole-dispatch bridge that used to live in
* database/routes/.
*/
import { consume } from "../plugins/registry.js";
export const GUACAMOLE_SESSIONS_KEY = "remote-desktop.sessions";
export interface GuacamoleSessionInfo {
guacamoleConnectionId: string;
hostId: number;
ownerUserId: string;
protocol: string;
openedAt: number;
}
export interface GuacamoleSessionProvider {
restart: () => Promise<void>;
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) => string;
getSessionInfo: (
guacamoleConnectionId: string,
) => GuacamoleSessionInfo | null;
}
function provider(): GuacamoleSessionProvider | undefined {
return consume<GuacamoleSessionProvider>(GUACAMOLE_SESSIONS_KEY);
}
/**
* Restarts the plugin's guacd WebSocket server, e.g. after an admin edits the
* guacd URL setting. A no-op while the plugin is disabled.
*/
export async function restartGuacamoleService(): Promise<void> {
await provider()?.restart();
}
/**
* Mints a read-only or writable join token for a live Guacamole session, for
* collab and session-sharing to hand an invited viewer. Throws when the plugin
* is not running.
*/
export function createGuacamoleJoinToken(
guacamoleConnectionId: string,
readOnly: boolean,
): string {
const active = provider();
if (!active) {
throw new Error("Remote Desktop plugin is not available");
}
return active.createJoinToken(guacamoleConnectionId, readOnly);
}
/** Looks up a live Guacamole session by guacd's own connection id. */
export function getGuacamoleSessionInfo(
guacamoleConnectionId: string,
): GuacamoleSessionInfo | null {
return provider()?.getSessionInfo(guacamoleConnectionId) ?? null;
}
+7 -4
View File
@@ -544,10 +544,13 @@ function handleOPKSSHOutput(requestId: string, output: string): void {
session.localPort = actualPort;
const baseUrl = session.remoteRedirectUri
.replace(/\/host\/opkssh-callback$/, "")
// In direct dev mode the WS server (30002) is separate from the HTTP API (30001)
.replace(/:30002\b/, ":30001");
// No port rewrite any more: the terminal socket used to live on 30002,
// separate from the HTTP API, so a redirect URI derived from it pointed at
// the wrong server in direct dev mode. The terminal rides 30001 now.
const baseUrl = session.remoteRedirectUri.replace(
/\/host\/opkssh-callback$/,
"",
);
const proxiedChooserUrl = `${baseUrl}/host/opkssh-chooser/${requestId}`;
session.status = "waiting_for_auth";
@@ -6,7 +6,7 @@ import { sessionManager } from "../terminal/session-manager.js";
// src/backend/database/routes/guacamole-dispatch.ts and that plugin's
// README -- this is an accepted, temporary coupling until collab/
// session-sharing themselves become plugins.
import { getGuacamoleSessionInfo as getGuacSessionInfo } from "../../database/routes/guacamole-dispatch.js";
import { getGuacamoleSessionInfo as getGuacSessionInfo } from "../guacamole-sessions.js";
import {
createCurrentHostResolutionRepository,
createCurrentSettingsRepository,
+6 -3
View File
@@ -17,7 +17,7 @@ import {
// src/backend/database/routes/guacamole-dispatch.ts and that plugin's
// README -- this is an accepted, temporary coupling until session-sharing
// becomes a plugin of its own.
import { createGuacamoleJoinToken } from "../../database/routes/guacamole-dispatch.js";
import { createGuacamoleJoinToken } from "../guacamole-sessions.js";
import { createCurrentSessionShareRepository } from "../../database/repositories/factory.js";
const router = express.Router();
@@ -405,10 +405,13 @@ router.get("/resolve/:linkToken", async (req: Request, res: Response) => {
} = {
protocol,
permissionLevel: share.permissionLevel as PermissionLevel,
// Advisory only: the client builds its own socket URL per deployment
// (see pluginWsUrl). Kept accurate rather than left pointing at the
// ports these two moved off in A4.
wsPath:
protocol === "ssh"
? `/terminal/ws?shareToken=${encodeURIComponent(linkToken)}`
: "/guacamole/websocket/",
? `/plugin-ws/ssh-terminal/terminal?shareToken=${encodeURIComponent(linkToken)}`
: "/plugin-ws/remote-desktop/display",
};
if (protocol !== "ssh") {
+23 -43
View File
@@ -139,68 +139,49 @@ const TAILSCALE_CHECK_TIMEOUT_MS = 1_800_000;
const userConnections = new Map<string, Set<WebSocket>>();
export const TERMINAL_WS_PORT = 30002;
// Re-exported so the ssh-terminal plugin can publish it on the service
// registry. session-manager itself stays in core: six modules outside the
// terminal import it directly.
export { sessionManager };
/**
* The server is created but not listening until startTerminalServer() runs.
* The ssh-terminal plugin owns that lifecycle, so disabling the plugin has to
* actually free the port rather than just stop routing to it.
* Serves /plugin-ws/ssh-terminal/terminal through the main backend server.
*
* `noServer` avoids binding at import time; listen() below is what opens the
* port. Handlers are attached at module scope as before, so none of the
* `noServer` because core owns the listening socket now: the ssh-terminal
* plugin registers handleTerminalUpgrade below through ctx.ws and there is no
* terminal port any more. Handlers stay at module scope, so none of the
* connection logic below had to change.
*/
const wss = new WebSocketServer({ noServer: true });
let httpServer: import("http").Server | null = null;
wss.on("error", (error) => {
sshLogger.error("WebSocket server error", error, {
operation: "wss_error",
});
});
export async function startTerminalServer(
port: number = TERMINAL_WS_PORT,
): Promise<void> {
if (httpServer) return;
const { createServer } = await import("http");
const server = createServer();
server.on("upgrade", (req, socket, head) => {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
httpServer = server;
sshLogger.info(`Terminal WebSocket server listening on ${port}`, {
operation: "terminal_ws_start",
/**
* Hands one upgrade to the terminal's own WebSocketServer.
*
* The connection handler below authenticates each socket itself, because a
* share-link guest arrives with a share token rather than a session. So this
* is registered as a public ctx.ws route and the auth stays where it already
* is, rather than being split across two places.
*/
export function handleTerminalUpgrade(
req: import("http").IncomingMessage,
socket: import("stream").Duplex,
head: Buffer,
): void {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
}
export async function stopTerminalServer(): Promise<void> {
if (!httpServer) return;
const server = httpServer;
httpServer = null;
// Close live sockets first: server.close() waits for connections to drain,
// and a terminal session would otherwise hold the port open indefinitely.
// No port to free any more: the route is unmounted by the runtime. The live
// sockets still have to be closed, because a terminal session would
// otherwise outlive the plugin that owns it.
for (const ws of wss.clients) {
try {
ws.close(1001, "Terminal plugin disabled");
@@ -209,8 +190,7 @@ export async function stopTerminalServer(): Promise<void> {
}
}
await new Promise<void>((resolve) => server.close(() => resolve()));
sshLogger.info("Terminal WebSocket server stopped", {
sshLogger.info("Terminal WebSocket sessions closed", {
operation: "terminal_ws_stop",
});
}
-4
View File
@@ -21,7 +21,6 @@ import {
} from "./c2s-relay.js";
import { registerTunnelRoutes } from "./routes.js";
import webEndpointDispatch from "./web-endpoint-dispatch.js";
import { initializeAutoStartTunnels } from "./manager.js";
import { attachServicePortConflictHandler } from "../../utils/service-listen.js";
@@ -39,9 +38,6 @@ app.use((_req, res, next) => {
});
registerTunnelRoutes(app);
// Always mounted; the Web Endpoint plugin registers its router behind this on
// activate and clears it on deactivate. See web-endpoint-dispatch.ts.
app.use("/ssh/tunnel/web-endpoint", webEndpointDispatch);
const PORT = 30003;
const server = createServer(app);
@@ -1,55 +0,0 @@
// Mounted at /ssh/tunnel/web-endpoint on the tunnel service's app. Web
// Endpoint ships as a first-party in-process plugin (plugins/web-endpoint)
// rather than a route file imported directly by index.ts, so this module
// exists to keep /ssh/tunnel/web-endpoint/* working at its original path
// without handing the plugin a reference to the shared app instance. The
// plugin runtime registers its router via registerWebEndpointRouter when it
// activates, and clears it on deactivate, so a disabled plugin falls back to
// the 404 below.
//
// This is the only dispatcher that lives outside database.ts: the route is
// served by the tunnel service on port 30003, and nginx already proxies the
// whole /ssh/tunnel/ prefix there. Both that service and the plugin runtime
// are loaded into the same backend process (starter.ts imports
// hosts/tunnel/index.js), so the module-level slot below is shared state
// between them, exactly as it is for the dispatchers in database.ts.
//
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
// to exactly one first-party router (the Web Endpoint plugin's own), and every
// route in that router already applies authenticateJWT itself, the same as
// every other route registered alongside it here. /plugin-api gates at the
// mount point because it forwards to arbitrary third-party plugin code that
// cannot be assumed to gate itself; that reasoning does not apply to a single
// first-party router that already does.
import type { Request, Response, NextFunction, Router } from "express";
import { tunnelLogger } from "../../utils/logger.js";
let activeRouter: Router | null = null;
export function registerWebEndpointRouter(router: Router): void {
activeRouter = router;
tunnelLogger.info("Registered Web Endpoint plugin router", {
operation: "web_endpoint_dispatch_register",
});
}
export function unregisterWebEndpointRouter(): void {
if (!activeRouter) return;
activeRouter = null;
tunnelLogger.info("Unregistered Web Endpoint plugin router", {
operation: "web_endpoint_dispatch_register",
});
}
export default function webEndpointDispatch(
req: Request,
res: Response,
next: NextFunction,
): void {
if (!activeRouter) {
res.status(404).json({ error: "Web endpoints are not available" });
return;
}
activeRouter(req, res, next);
}