mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: serve plugin http and websockets from one mount instead of ports
This commit is contained in:
1 parent
c496a093ca
commit
8431570ff7
102 files changed
+2894
-2308
No files matched your search
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user