mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
446 lines
13 KiB
TypeScript
446 lines
13 KiB
TypeScript
/**
|
|
* WebSockets for plugins: one upgrade handler, no ports.
|
|
*
|
|
* A plugin registers /plugin-ws/<id>/<path> and core serves it from the main
|
|
* server's upgrade event. Auth is the same as every other socket in Termix
|
|
* (utils/ws-auth.ts), the actor is set for the handler, and every socket a
|
|
* plugin opened is closed when it deactivates.
|
|
*
|
|
* Two shapes, because two kinds of caller exist:
|
|
*
|
|
* - route(): core owns the WebSocketServer and hands the plugin a live
|
|
* socket. What a plugin writing its own protocol wants.
|
|
* - upgrade(): core hands over the raw upgrade after authenticating it, for
|
|
* a library that insists on owning its own server. guacamole-lite is the
|
|
* reason this exists: it constructs a WebSocketServer internally, so the
|
|
* only way to attach it is to give it the upgrade.
|
|
*
|
|
* Both run auth first, so "bring your own server" never means "bring your own
|
|
* auth".
|
|
*/
|
|
|
|
import type { IncomingMessage, Server as HttpServer } from "node:http";
|
|
import type { Duplex } from "node:stream";
|
|
import { WebSocketServer, type WebSocket } from "ws";
|
|
import type {
|
|
PluginWebSocketHandler,
|
|
PluginWebSocketOptions,
|
|
} from "@termix-ssh/plugin-sdk/backend";
|
|
import { pluginLogger } from "../utils/logger.js";
|
|
import { recordConflict } from "./conflicts.js";
|
|
import {
|
|
extractWebSocketToken,
|
|
isCookieOriginAllowed,
|
|
} from "../utils/ws-auth.js";
|
|
import { runAsActor } from "./actor.js";
|
|
import { isPluginInstalled } from "./http.js";
|
|
|
|
const PLUGIN_WS_PREFIX = "/plugin-ws/";
|
|
|
|
type RawUpgradeHandler = (
|
|
request: IncomingMessage,
|
|
socket: Duplex,
|
|
head: Buffer,
|
|
userId: string,
|
|
) => void;
|
|
|
|
interface Route {
|
|
pluginId: string;
|
|
path: string;
|
|
options: PluginWebSocketOptions;
|
|
/** Declared capabilities, for the per-upgrade network:serve check. */
|
|
declared: readonly string[];
|
|
handler?: PluginWebSocketHandler;
|
|
rawHandler?: RawUpgradeHandler;
|
|
/** Sockets handed to rawHandler, destroyed when the route goes away. */
|
|
rawSockets?: Set<Duplex>;
|
|
/** Core's own socket: served under /plugin-ws/core/, no plugin grant. */
|
|
core?: boolean;
|
|
}
|
|
|
|
/** Largest frame a plugin socket accepts. ws defaults to 100MB. */
|
|
const MAX_PAYLOAD_BYTES = 64 * 1024 * 1024;
|
|
|
|
/** Keyed by "<pluginId>:<path>". */
|
|
const routes = new Map<string, Route>();
|
|
|
|
/** One server per plugin route, so disposal can close exactly its sockets. */
|
|
const servers = new Map<string, WebSocketServer>();
|
|
|
|
const attachedServers = new Set<HttpServer>();
|
|
|
|
function normalizePath(path: string): string {
|
|
const withSlash = path.startsWith("/") ? path : `/${path}`;
|
|
return withSlash.length > 1 && withSlash.endsWith("/")
|
|
? withSlash.slice(0, -1)
|
|
: withSlash;
|
|
}
|
|
|
|
function key(pluginId: string, path: string): string {
|
|
return `${pluginId}:${normalizePath(path)}`;
|
|
}
|
|
|
|
/**
|
|
* Parses /plugin-ws/<id>/<path> out of an upgrade URL.
|
|
*
|
|
* Returns null for anything else, so core's own sockets and a stray request
|
|
* both fall through to whatever else is listening rather than being claimed.
|
|
*/
|
|
export function parsePluginWsUrl(
|
|
url: string | undefined,
|
|
): { pluginId: string; path: string } | null {
|
|
if (!url) return null;
|
|
const pathname = url.split("?")[0];
|
|
if (!pathname.startsWith(PLUGIN_WS_PREFIX)) return null;
|
|
|
|
const rest = pathname.slice(PLUGIN_WS_PREFIX.length);
|
|
const slash = rest.indexOf("/");
|
|
if (slash <= 0) return null;
|
|
|
|
const pluginId = rest.slice(0, slash);
|
|
const path = normalizePath(rest.slice(slash));
|
|
if (!pluginId || path === "/") return null;
|
|
return { pluginId, path };
|
|
}
|
|
|
|
/**
|
|
* Resolves the user behind an upgrade, or null.
|
|
*
|
|
* A half-authenticated session is not a user: a token still awaiting TOTP is
|
|
* refused here exactly as createAuthMiddleware refuses it for HTTP, so a
|
|
* socket cannot be the way around the second factor.
|
|
*/
|
|
async function verifyToken(request: IncomingMessage): Promise<string | null> {
|
|
const token = extractWebSocketToken(request);
|
|
if (!token) return null;
|
|
try {
|
|
const { AuthManager } = await import("../utils/auth-manager.js");
|
|
const payload = await AuthManager.getInstance().verifyJWTToken(token, {
|
|
allowSocketTicket: true,
|
|
});
|
|
if (!payload || payload.pendingTOTP) return null;
|
|
return payload.userId ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function reject(socket: Duplex, status: number, message: string): void {
|
|
try {
|
|
socket.write(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\n\r\n`);
|
|
} catch {
|
|
// The peer may already be gone.
|
|
}
|
|
socket.destroy();
|
|
}
|
|
|
|
/**
|
|
* Serves a plugin upgrade, or returns false so the caller can pass it on.
|
|
*
|
|
* Exported for the test suite and for database.ts, which wires it into both
|
|
* the HTTP and the HTTPS server.
|
|
*/
|
|
async function handlePluginUpgrade(
|
|
request: IncomingMessage,
|
|
socket: Duplex,
|
|
head: Buffer,
|
|
): Promise<boolean> {
|
|
const parsed = parsePluginWsUrl(request.url);
|
|
if (!parsed) return false;
|
|
|
|
const route = routes.get(key(parsed.pluginId, parsed.path));
|
|
if (!route) {
|
|
// Claimed by the prefix but nothing is listening: answering here is more
|
|
// honest than leaving the socket hanging. A disabled plugin has no routes
|
|
// left, so an installed id means "off", not "missing".
|
|
if (isPluginInstalled(parsed.pluginId)) {
|
|
reject(socket, 503, "Service Unavailable");
|
|
} else {
|
|
reject(socket, 404, "Not Found");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Same reasoning as the HTTP side: the grant is in the database, so it is
|
|
// checked per upgrade rather than at registration, and a revoke takes effect
|
|
// on the next connection without a restart.
|
|
const { hasCapability } = await import("./permissions.js");
|
|
if (
|
|
!route.core &&
|
|
!(await hasCapability(route.pluginId, "network:serve", route.declared))
|
|
) {
|
|
reject(socket, 403, "Forbidden");
|
|
return true;
|
|
}
|
|
|
|
// A page on another site gets the browser's cookies attached for free.
|
|
// Core already ignores them for auth; plugin code must not see them either.
|
|
if (request.headers.cookie && !isCookieOriginAllowed(request)) {
|
|
delete request.headers.cookie;
|
|
}
|
|
|
|
let userId = "";
|
|
if (!route.options.public) {
|
|
const resolved = await verifyToken(request);
|
|
if (!resolved) {
|
|
reject(socket, 401, "Unauthorized");
|
|
return true;
|
|
}
|
|
userId = resolved;
|
|
} else if (route.options.optionalAuth) {
|
|
userId = (await verifyToken(request)) ?? "";
|
|
}
|
|
|
|
// A guest on a public socket has no actor at all, the same as a public
|
|
// HTTP route, so nothing downstream mistakes a placeholder for a user.
|
|
const asCaller = (fn: () => void) =>
|
|
userId ? runAsActor(userId, "request", fn) : fn();
|
|
|
|
if (route.rawHandler) {
|
|
const rawHandler = route.rawHandler;
|
|
const tracked = route.rawSockets;
|
|
if (tracked) {
|
|
tracked.add(socket);
|
|
socket.once("close", () => tracked.delete(socket));
|
|
}
|
|
asCaller(() => rawHandler(request, socket, head, userId));
|
|
return true;
|
|
}
|
|
|
|
const server = servers.get(key(route.pluginId, route.path));
|
|
if (!server) {
|
|
reject(socket, 503, "Service Unavailable");
|
|
return true;
|
|
}
|
|
|
|
const { getClientIp, getRequestOrigin } =
|
|
await import("../utils/request-origin.js");
|
|
const { DataCrypto } = await import("../utils/data-crypto.js");
|
|
const connection = {
|
|
userId,
|
|
request,
|
|
clientIp: getClientIp(request as never),
|
|
requestOrigin: getRequestOrigin(request as never),
|
|
isDataUnlocked: () => !!userId && !!DataCrypto.getUserDataKey(userId),
|
|
};
|
|
|
|
server.handleUpgrade(request, socket, head, (ws: WebSocket) => {
|
|
const handler = route.handler;
|
|
if (!handler) {
|
|
ws.close(1011, "No handler");
|
|
return;
|
|
}
|
|
asCaller(() => {
|
|
void Promise.resolve(handler({ ...connection, socket: ws })).catch(
|
|
(error) => {
|
|
pluginLogger.error(
|
|
`Plugin ${route.pluginId} socket ${route.path} failed`,
|
|
error instanceof Error ? error : new Error(String(error)),
|
|
{ operation: "plugin_ws_error" },
|
|
);
|
|
try {
|
|
ws.close(1011, "Plugin error");
|
|
} catch {
|
|
// Already closed.
|
|
}
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
/** Wires the upgrade handler onto a server. Idempotent per server. */
|
|
export function attachPluginWebSockets(server: HttpServer): void {
|
|
if (attachedServers.has(server)) return;
|
|
attachedServers.add(server);
|
|
|
|
server.on("upgrade", (request, socket, head) => {
|
|
void handlePluginUpgrade(
|
|
request as IncomingMessage,
|
|
socket as Duplex,
|
|
head as Buffer,
|
|
).catch((error) => {
|
|
pluginLogger.error(
|
|
"Plugin WebSocket upgrade failed",
|
|
error instanceof Error ? error : new Error(String(error)),
|
|
{ operation: "plugin_ws_upgrade" },
|
|
);
|
|
reject(socket as Duplex, 500, "Internal Server Error");
|
|
});
|
|
});
|
|
}
|
|
|
|
export function registerPluginWsRoute(
|
|
pluginId: string,
|
|
path: string,
|
|
handler: PluginWebSocketHandler,
|
|
declared: readonly string[],
|
|
options: PluginWebSocketOptions = {},
|
|
): () => void {
|
|
const routeKey = key(pluginId, path);
|
|
const normalized = normalizePath(path);
|
|
if (routes.has(routeKey)) {
|
|
recordConflict({ kind: "ws", pluginId, name: normalized });
|
|
throw new Error(
|
|
`/plugin-ws/${pluginId}${normalized} is already registered`,
|
|
);
|
|
}
|
|
|
|
routes.set(routeKey, {
|
|
pluginId,
|
|
path: normalized,
|
|
options,
|
|
declared,
|
|
handler,
|
|
});
|
|
if (options.public) auditPublicSocket(pluginId, normalized);
|
|
servers.set(
|
|
routeKey,
|
|
new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD_BYTES }),
|
|
);
|
|
|
|
pluginLogger.info(`Mounted /plugin-ws/${pluginId}${normalized}`, {
|
|
operation: "plugin_ws_mount",
|
|
});
|
|
|
|
return () => disposeRoute(routeKey, pluginId, normalized);
|
|
}
|
|
|
|
/**
|
|
* A socket core itself serves, at /plugin-ws/core/<path>, so it rides the same
|
|
* proxy rules and token checks as plugin sockets. Authenticated like them.
|
|
*/
|
|
export function registerCoreWsRoute(
|
|
path: string,
|
|
handler: PluginWebSocketHandler,
|
|
): () => void {
|
|
const routeKey = key("core", path);
|
|
const normalized = normalizePath(path);
|
|
if (routes.has(routeKey)) return () => {};
|
|
routes.set(routeKey, {
|
|
pluginId: "core",
|
|
path: normalized,
|
|
options: {},
|
|
declared: [],
|
|
handler,
|
|
core: true,
|
|
});
|
|
servers.set(
|
|
routeKey,
|
|
new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD_BYTES }),
|
|
);
|
|
return () => disposeRoute(routeKey, "core", normalized);
|
|
}
|
|
|
|
export function registerPluginWsUpgrade(
|
|
pluginId: string,
|
|
path: string,
|
|
handler: RawUpgradeHandler,
|
|
declared: readonly string[],
|
|
options: PluginWebSocketOptions = {},
|
|
): () => void {
|
|
const routeKey = key(pluginId, path);
|
|
const normalized = normalizePath(path);
|
|
if (routes.has(routeKey)) {
|
|
recordConflict({ kind: "ws", pluginId, name: normalized });
|
|
throw new Error(
|
|
`/plugin-ws/${pluginId}${normalized} is already registered`,
|
|
);
|
|
}
|
|
|
|
routes.set(routeKey, {
|
|
pluginId,
|
|
path: normalized,
|
|
options,
|
|
declared,
|
|
rawHandler: handler,
|
|
rawSockets: new Set(),
|
|
});
|
|
if (options.public) auditPublicSocket(pluginId, normalized);
|
|
|
|
pluginLogger.info(`Mounted raw upgrade /plugin-ws/${pluginId}${normalized}`, {
|
|
operation: "plugin_ws_mount",
|
|
});
|
|
|
|
return () => disposeRoute(routeKey, pluginId, normalized);
|
|
}
|
|
|
|
function disposeRoute(routeKey: string, pluginId: string, path: string): void {
|
|
const route = routes.get(routeKey);
|
|
routes.delete(routeKey);
|
|
|
|
for (const socket of route?.rawSockets ?? []) {
|
|
try {
|
|
socket.destroy();
|
|
} catch {
|
|
// Already gone.
|
|
}
|
|
}
|
|
route?.rawSockets?.clear();
|
|
|
|
const server = servers.get(routeKey);
|
|
if (server) {
|
|
servers.delete(routeKey);
|
|
// Close the clients first: server.close() waits for them to drain, and a
|
|
// live terminal would otherwise keep the route alive after deactivate.
|
|
for (const client of server.clients) {
|
|
try {
|
|
client.close(1001, "Plugin disabled");
|
|
} catch {
|
|
// Already gone.
|
|
}
|
|
}
|
|
try {
|
|
server.close();
|
|
} catch {
|
|
// Closing twice is not an error worth surfacing.
|
|
}
|
|
}
|
|
|
|
pluginLogger.info(`Unmounted /plugin-ws/${pluginId}${path}`, {
|
|
operation: "plugin_ws_unmount",
|
|
});
|
|
}
|
|
|
|
/** The socket paths a plugin serves without core's login check. */
|
|
export function getPluginPublicWsRoutes(pluginId: string): string[] {
|
|
return [...routes.values()]
|
|
.filter((route) => route.pluginId === pluginId && route.options.public)
|
|
.map((route) => route.path);
|
|
}
|
|
|
|
/** Same record the HTTP side writes for its public routes. */
|
|
function auditPublicSocket(pluginId: string, path: string): void {
|
|
pluginLogger.warn(
|
|
`Plugin ${pluginId} serves /plugin-ws/${pluginId}${path} without core authentication`,
|
|
{ operation: "plugin_ws_public" },
|
|
);
|
|
void import("../utils/audit-logger.js")
|
|
.then(({ logAudit }) =>
|
|
logAudit({
|
|
userId: null,
|
|
username: `plugin:${pluginId}`,
|
|
action: "plugin_ws_public_route",
|
|
resourceType: "plugin",
|
|
resourceId: pluginId,
|
|
resourceName: pluginId,
|
|
details: `unauthenticated socket: ${path}`,
|
|
success: true,
|
|
}),
|
|
)
|
|
.catch(() => {});
|
|
}
|
|
|
|
/** Test seam. */
|
|
export function resetPluginWebSockets(): void {
|
|
for (const routeKey of [...routes.keys()]) {
|
|
const route = routes.get(routeKey);
|
|
if (route) disposeRoute(routeKey, route.pluginId, route.path);
|
|
}
|
|
routes.clear();
|
|
servers.clear();
|
|
attachedServers.clear();
|
|
}
|