mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45: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
@@ -10,32 +10,25 @@ import hostRoutes from "./routes/host.js";
|
||||
import alertRoutes from "./routes/alerts.js";
|
||||
import credentialsRoutes from "./routes/credentials.js";
|
||||
import snippetsRoutes from "./routes/snippets.js";
|
||||
import fleetsDispatch from "./routes/fleet-dispatch.js";
|
||||
import workspaceDispatch from "./routes/workspace-dispatch.js";
|
||||
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
|
||||
import terminalRoutes from "./routes/terminal.js";
|
||||
import sessionLogRoutes from "./routes/session-log-routes.js";
|
||||
import guacamoleDispatch from "./routes/guacamole-dispatch.js";
|
||||
import sessionSharingRoutes from "../hosts/session-sharing/routes.js";
|
||||
import collabRoutes from "../hosts/collab/routes.js";
|
||||
import networkTopologyDispatch from "./routes/network-topology-dispatch.js";
|
||||
import rbacRoutes from "./routes/rbac.js";
|
||||
import openTabsRoutes from "./routes/open-tabs.js";
|
||||
import userPreferencesRoutes from "./routes/user-preferences.js";
|
||||
import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
|
||||
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
|
||||
import uiPreferencesRoutes from "./routes/ui-preferences.js";
|
||||
import proxmoxDispatch from "./routes/proxmox-dispatch.js";
|
||||
import termixIdRoutes from "./routes/termix-id.js";
|
||||
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
||||
import tailscaleDispatch from "./routes/tailscale-dispatch.js";
|
||||
import vaultRoutes from "./routes/vault.js";
|
||||
import secretSourceRoutes from "./routes/secret-sources.js";
|
||||
import notificationChannelsRoutes from "./routes/notification-channels-routes.js";
|
||||
import aiDispatch from "./routes/ai-dispatch.js";
|
||||
import automationsDispatch from "./routes/automation-dispatch.js";
|
||||
import syncRoutes from "./routes/sync.js";
|
||||
import pluginApiRoutes from "./routes/plugin-api-routes.js";
|
||||
import { attachPluginWebSockets } from "../plugins/ws.js";
|
||||
import pluginRoutes from "./routes/plugins.js";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import { createCompressionMiddleware } from "../utils/compression-config.js";
|
||||
@@ -263,8 +256,23 @@ async function fetchGitHubAPI<T>(
|
||||
}
|
||||
}
|
||||
|
||||
app.use(bodyParser.json({ limit: "2mb" }));
|
||||
app.use(bodyParser.urlencoded({ limit: "2mb", extended: true }));
|
||||
// Skipped for /plugin-api: a plugin router brings its own parsers with its own
|
||||
// limit (see plugins/http.ts), and parsing here first would consume the body
|
||||
// and silently cap every plugin at this limit instead.
|
||||
const coreJsonParser = bodyParser.json({ limit: "2mb" });
|
||||
const coreUrlencodedParser = bodyParser.urlencoded({
|
||||
limit: "2mb",
|
||||
extended: true,
|
||||
});
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.path.startsWith("/plugin-api/")) return next();
|
||||
coreJsonParser(req, res, next);
|
||||
});
|
||||
app.use((req, res, next) => {
|
||||
if (req.path.startsWith("/plugin-api/")) return next();
|
||||
coreUrlencodedParser(req, res, next);
|
||||
});
|
||||
app.use(cookieParser());
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
@@ -1753,29 +1761,21 @@ app.use("/host", hostRoutes);
|
||||
app.use("/alerts", alertRoutes);
|
||||
app.use("/credentials", credentialsRoutes);
|
||||
app.use("/snippets", snippetsRoutes);
|
||||
app.use("/fleets", fleetsDispatch);
|
||||
app.use("/workspaces", workspaceDispatch);
|
||||
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
|
||||
app.use("/terminal", terminalRoutes);
|
||||
app.use("/session_logs", sessionLogRoutes);
|
||||
app.use("/guacamole", guacamoleDispatch);
|
||||
app.use("/session-sharing", sessionSharingRoutes);
|
||||
app.use("/collab", collabRoutes);
|
||||
app.use("/network-topology", networkTopologyDispatch);
|
||||
app.use("/rbac", rbacRoutes);
|
||||
app.use("/open-tabs", openTabsRoutes);
|
||||
app.use("/user-preferences", userPreferencesRoutes);
|
||||
app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
|
||||
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
|
||||
app.use("/ui-preferences", uiPreferencesRoutes);
|
||||
app.use("/proxmox", proxmoxDispatch);
|
||||
app.use("/termix-id", termixIdRoutes);
|
||||
registerAuditLogRoutes(app, authenticateJWT);
|
||||
app.use("/tailscale", tailscaleDispatch);
|
||||
app.use("/vault", vaultRoutes);
|
||||
app.use("/secret-sources", secretSourceRoutes);
|
||||
app.use("/automations", automationsDispatch);
|
||||
app.use("/ai", aiDispatch);
|
||||
app.use("/", notificationChannelsRoutes);
|
||||
app.use("/sync", syncRoutes);
|
||||
app.use("/plugins", pluginRoutes);
|
||||
@@ -2028,6 +2028,10 @@ app.get(
|
||||
|
||||
const httpServer = http.createServer(app);
|
||||
|
||||
// Plugin sockets ride this server at /plugin-ws/<id>/<path>. Anything else is
|
||||
// left alone, so core's own upgrade handling is unaffected.
|
||||
attachPluginWebSockets(httpServer);
|
||||
|
||||
httpServer.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
databaseLogger.error(
|
||||
@@ -2076,6 +2080,8 @@ if (
|
||||
app,
|
||||
);
|
||||
|
||||
attachPluginWebSockets(httpsServer);
|
||||
|
||||
httpsServer.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
databaseLogger.error(
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
// Mounted at /ai. The AI feature ships as a first-party in-process plugin
|
||||
// (plugins/ai) rather than a route file imported directly here, so this
|
||||
// module exists to keep /ai/* working at its original path without handing
|
||||
// the plugin a reference to the shared app instance. The plugin runtime
|
||||
// registers its router via registerAiRouter when it activates, and clears it
|
||||
// on deactivate, so a disabled plugin falls back to the 404 below.
|
||||
//
|
||||
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
|
||||
// to exactly one first-party router (the AI plugin's own), and every route in
|
||||
// that router already applies authenticateJWT itself, the same as every other
|
||||
// router mounted alongside it here (/users, /host, /snippets, ...). /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 { databaseLogger } from "../../utils/logger.js";
|
||||
|
||||
let activeRouter: Router | null = null;
|
||||
|
||||
export function registerAiRouter(router: Router): void {
|
||||
activeRouter = router;
|
||||
databaseLogger.info("Registered AI plugin router", {
|
||||
operation: "ai_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterAiRouter(): void {
|
||||
if (!activeRouter) return;
|
||||
activeRouter = null;
|
||||
databaseLogger.info("Unregistered AI plugin router", {
|
||||
operation: "ai_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export default function aiDispatch(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
if (!activeRouter) {
|
||||
res.status(404).json({ error: "The AI assistant is not available" });
|
||||
return;
|
||||
}
|
||||
activeRouter(req, res, next);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// Mounted at /automations. Automations ships as a first-party in-process
|
||||
// plugin (plugins/automations) rather than a route file imported directly
|
||||
// here, so this module exists to keep /automations/* working at its original
|
||||
// path without handing the plugin a reference to the shared app instance. The
|
||||
// plugin runtime registers its router via registerAutomationsRouter when it
|
||||
// activates, and clears it on deactivate, so a disabled plugin falls back to
|
||||
// the 404 below.
|
||||
//
|
||||
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
|
||||
// to exactly one first-party router (the Automations plugin's own), and every
|
||||
// route in that router already applies authenticateJWT/requireDataAccess
|
||||
// itself (including the intentionally unauthenticated webhook route, which
|
||||
// gates on its own token instead), the same as every other router mounted
|
||||
// alongside it here (/users, /host, /fleets, ...). /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 { databaseLogger } from "../../utils/logger.js";
|
||||
|
||||
let activeRouter: Router | null = null;
|
||||
|
||||
export function registerAutomationsRouter(router: Router): void {
|
||||
activeRouter = router;
|
||||
databaseLogger.info("Registered Automations plugin router", {
|
||||
operation: "automations_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterAutomationsRouter(): void {
|
||||
if (!activeRouter) return;
|
||||
activeRouter = null;
|
||||
databaseLogger.info("Unregistered Automations plugin router", {
|
||||
operation: "automations_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export default function automationsDispatch(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
if (!activeRouter) {
|
||||
res.status(404).json({ error: "Automations is not available" });
|
||||
return;
|
||||
}
|
||||
activeRouter(req, res, next);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
// Mounted at /fleets. Fleets ships as a first-party in-process plugin
|
||||
// (plugins/fleets) rather than a route file imported directly here, so this
|
||||
// module exists to keep /fleets/* working at its original path without
|
||||
// handing the plugin a reference to the shared app instance. The plugin
|
||||
// runtime registers its router via registerFleetsRouter when it activates,
|
||||
// and clears it on deactivate, so a disabled plugin falls back to the 404
|
||||
// below.
|
||||
//
|
||||
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
|
||||
// to exactly one first-party router (the Fleets plugin's own), and every
|
||||
// route in that router already applies authenticateJWT/requireDataAccess
|
||||
// itself, the same as every other router mounted alongside it here (/users,
|
||||
// /host, /snippets, ...). /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 { databaseLogger } from "../../utils/logger.js";
|
||||
|
||||
let activeRouter: Router | null = null;
|
||||
|
||||
export function registerFleetsRouter(router: Router): void {
|
||||
activeRouter = router;
|
||||
databaseLogger.info("Registered Fleets plugin router", {
|
||||
operation: "fleets_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterFleetsRouter(): void {
|
||||
if (!activeRouter) return;
|
||||
activeRouter = null;
|
||||
databaseLogger.info("Unregistered Fleets plugin router", {
|
||||
operation: "fleets_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export default function fleetsDispatch(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
if (!activeRouter) {
|
||||
res.status(404).json({ error: "Fleets is not available" });
|
||||
return;
|
||||
}
|
||||
activeRouter(req, res, next);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
// Mounted at /guacamole. Remote Desktop ships as a first-party in-process
|
||||
// plugin (plugins/remote-desktop) rather than a route file imported directly
|
||||
// here, so this module keeps /guacamole/* working at its original path
|
||||
// without handing the plugin a reference to the shared app instance.
|
||||
//
|
||||
// It also brokers the small surface collab, session-sharing and the admin
|
||||
// settings route need from the plugin (join tokens, live-session lookups, a
|
||||
// server restart hook). Those core modules cannot import the plugin's
|
||||
// TypeScript directly -- plugins/ is type-checked by tsconfig.plugins.json as
|
||||
// a one-directional read of src/backend/, and a
|
||||
// core file under tsconfig.node.json's rootDir cannot statically import
|
||||
// something outside it. So the plugin registers this bridge once on
|
||||
// activate, and clears it on deactivate; a disabled/uninstalled plugin falls
|
||||
// back to the safe defaults below (404 for the router, null for session
|
||||
// lookups, a no-op restart).
|
||||
//
|
||||
// This is an accepted, temporary coupling -- see the plugin's own README.
|
||||
// collab and session-sharing are expected to become plugins of their own
|
||||
// later, at which point this becomes a real inter-plugin service reference
|
||||
// instead of a bridge object living in core.
|
||||
//
|
||||
// The router forwards with no auth of its own: it delegates to exactly one
|
||||
// first-party router, and every route in that router already applies its
|
||||
// own auth middleware.
|
||||
|
||||
import type { Request, Response, NextFunction, Router } from "express";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
|
||||
export interface GuacamoleSessionInfo {
|
||||
guacamoleConnectionId: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: string;
|
||||
openedAt: number;
|
||||
}
|
||||
|
||||
export interface GuacamoleBridge {
|
||||
router: Router;
|
||||
restart: () => Promise<void>;
|
||||
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) => string;
|
||||
getSessionInfo: (
|
||||
guacamoleConnectionId: string,
|
||||
) => GuacamoleSessionInfo | null;
|
||||
}
|
||||
|
||||
let bridge: GuacamoleBridge | null = null;
|
||||
|
||||
export function registerGuacamoleBridge(next: GuacamoleBridge): void {
|
||||
bridge = next;
|
||||
databaseLogger.info("Registered Remote Desktop plugin bridge", {
|
||||
operation: "guacamole_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterGuacamoleBridge(): void {
|
||||
if (!bridge) return;
|
||||
bridge = null;
|
||||
databaseLogger.info("Unregistered Remote Desktop plugin bridge", {
|
||||
operation: "guacamole_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restarts the plugin's guacd WebSocket server, e.g. after an admin edits
|
||||
* the guacd URL setting (see user-settings-routes.ts). A no-op while the
|
||||
* plugin is disabled/uninstalled -- restarting a server that does not exist
|
||||
* would otherwise silently resurrect it outside the plugin lifecycle.
|
||||
*/
|
||||
export async function restartGuacamoleService(): Promise<void> {
|
||||
if (!bridge) return;
|
||||
await bridge.restart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints a read-only/writable join token for an existing live Guacamole
|
||||
* session, for collab/session-sharing to hand to an invited viewer. Throws
|
||||
* when the plugin is disabled/uninstalled -- unlike the read-only lookups
|
||||
* below, a caller asking to mint a join token needs to know it failed rather
|
||||
* than silently getting an unusable value.
|
||||
*/
|
||||
export function createGuacamoleJoinToken(
|
||||
guacamoleConnectionId: string,
|
||||
readOnly: boolean,
|
||||
): string {
|
||||
if (!bridge) {
|
||||
throw new Error("Remote Desktop plugin is not available");
|
||||
}
|
||||
return bridge.createJoinToken(guacamoleConnectionId, readOnly);
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up a live Guacamole session by guacd's own connection id. Returns
|
||||
* null both when no such session exists and when the plugin is
|
||||
* disabled/uninstalled -- to a caller checking "is this session live", the
|
||||
* two cases mean the same thing.
|
||||
*/
|
||||
export function getGuacamoleSessionInfo(
|
||||
guacamoleConnectionId: string,
|
||||
): GuacamoleSessionInfo | null {
|
||||
return bridge?.getSessionInfo(guacamoleConnectionId) ?? null;
|
||||
}
|
||||
|
||||
export default function guacamoleDispatch(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
if (!bridge) {
|
||||
res.status(404).json({ error: "Remote Desktop is not available" });
|
||||
return;
|
||||
}
|
||||
bridge.router(req, res, next);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { databaseLogger, sshLogger } from "../../utils/logger.js";
|
||||
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
||||
import {
|
||||
createCurrentCommandHistoryRepository,
|
||||
createCurrentCredentialRepository,
|
||||
@@ -23,7 +24,6 @@ type HostFolderRoutesDeps = {
|
||||
requireDeletePermission: RequestHandler;
|
||||
requireCredentialEditPermission: RequestHandler;
|
||||
requireDataAccess: RequestHandler;
|
||||
statsServerUrl: string;
|
||||
};
|
||||
|
||||
export function registerHostFolderRoutes(
|
||||
@@ -35,7 +35,6 @@ export function registerHostFolderRoutes(
|
||||
requireDeletePermission,
|
||||
requireCredentialEditPermission,
|
||||
requireDataAccess,
|
||||
statsServerUrl,
|
||||
}: HostFolderRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
@@ -444,30 +443,11 @@ export function registerHostFolderRoutes(
|
||||
);
|
||||
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
for (const host of hostsToDelete) {
|
||||
try {
|
||||
await axios.post(
|
||||
`${statsServerUrl}/host-deleted`,
|
||||
{ hostId: host.id },
|
||||
{
|
||||
headers: {
|
||||
Authorization: req.headers.authorization || "",
|
||||
Cookie: req.headers.cookie || "",
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to notify stats server of host deletion", {
|
||||
operation: "folder_hosts_delete",
|
||||
hostId: host.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
pluginEvents.emit(TOPICS.hostDeleted, { hostId: host.id, userId });
|
||||
}
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to notify stats server of folder deletion", {
|
||||
sshLogger.warn("Failed to publish host deletion events", {
|
||||
operation: "folder_hosts_delete",
|
||||
folderName,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
|
||||
@@ -3,9 +3,9 @@ import { applyFolderAccessRules } from "../../utils/folder-access-inheritance.js
|
||||
import { findUsableCredential } from "../../hosts/usable-credential.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import axios from "axios";
|
||||
import multer from "multer";
|
||||
import { sshLogger, databaseLogger } from "../../utils/logger.js";
|
||||
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
@@ -82,32 +82,29 @@ const router = express.Router();
|
||||
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
const STATS_SERVER_URL = "http://localhost:30005";
|
||||
|
||||
/**
|
||||
* Tells whoever is polling this host that its details changed.
|
||||
*
|
||||
* An event rather than the HTTP POST to localhost:30005 this used to be: host
|
||||
* metrics is a plugin now and has no port of its own. Fire and forget, so a
|
||||
* subscriber that throws cannot fail the host update that caused it.
|
||||
*/
|
||||
function notifyStatsHostUpdated(
|
||||
hostId: number,
|
||||
headers: Pick<Request["headers"], "authorization" | "cookie">,
|
||||
userId: string,
|
||||
operation: string,
|
||||
): void {
|
||||
axios
|
||||
.post(
|
||||
`${STATS_SERVER_URL}/host-updated`,
|
||||
{ hostId },
|
||||
{
|
||||
headers: {
|
||||
Authorization: headers.authorization || "",
|
||||
Cookie: headers.cookie || "",
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
sshLogger.warn("Failed to notify stats server of host update", {
|
||||
operation,
|
||||
hostId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
try {
|
||||
// The user travels with the event: a subscriber re-reading the host needs
|
||||
// a data key, and the bus carries no session of its own.
|
||||
pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId });
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to publish host update event", {
|
||||
operation,
|
||||
hostId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
@@ -566,11 +563,7 @@ router.post(
|
||||
);
|
||||
|
||||
res.json(stripSensitiveFields(resolvedHost));
|
||||
notifyStatsHostUpdated(
|
||||
createdHost.id as number,
|
||||
req.headers,
|
||||
"host_create",
|
||||
);
|
||||
notifyStatsHostUpdated(createdHost.id as number, userId, "host_create");
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to save SSH host to database", err, {
|
||||
operation: "host_create",
|
||||
@@ -1483,7 +1476,7 @@ router.put(
|
||||
});
|
||||
|
||||
res.json(stripSensitiveFields(resolvedHost));
|
||||
notifyStatsHostUpdated(parseInt(hostId), req.headers, "host_update");
|
||||
notifyStatsHostUpdated(parseInt(hostId), userId, "host_update");
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to update SSH host in database", err, {
|
||||
operation: "host_update",
|
||||
@@ -2551,20 +2544,12 @@ router.delete(
|
||||
});
|
||||
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
await axios.post(
|
||||
`${STATS_SERVER_URL}/host-deleted`,
|
||||
{ hostId: numericHostId },
|
||||
{
|
||||
headers: {
|
||||
Authorization: req.headers.authorization || "",
|
||||
Cookie: req.headers.cookie || "",
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
pluginEvents.emit(TOPICS.hostDeleted, {
|
||||
hostId: numericHostId,
|
||||
userId,
|
||||
});
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to notify stats server of host deletion", {
|
||||
sshLogger.warn("Failed to publish host deletion event", {
|
||||
operation: "host_delete",
|
||||
hostId: numericHostId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
@@ -2871,7 +2856,6 @@ registerHostFolderRoutes(router, {
|
||||
requireCredentialEditPermission:
|
||||
permissionManager.requirePermission("credentials.edit"),
|
||||
requireDataAccess,
|
||||
statsServerUrl: STATS_SERVER_URL,
|
||||
});
|
||||
|
||||
registerHostBulkRoutes(
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
// Mounted at /network-topology. Network Topology ships as a first-party
|
||||
// in-process plugin (plugins/network-topology) rather than a route file
|
||||
// imported directly here, so this module exists to keep /network-topology/*
|
||||
// working at its original path without handing the plugin a reference to the
|
||||
// shared app instance. The plugin runtime registers its router via
|
||||
// registerNetworkTopologyRouter when it activates, and clears it on
|
||||
// deactivate, so a disabled plugin falls back to the 404 below.
|
||||
//
|
||||
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
|
||||
// to exactly one first-party router (the Network Topology plugin's own), and
|
||||
// every route in that router already applies authenticateJWT itself, the same
|
||||
// as every other router mounted alongside it here (/users, /host, /snippets,
|
||||
// ...). /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 { databaseLogger } from "../../utils/logger.js";
|
||||
|
||||
let activeRouter: Router | null = null;
|
||||
|
||||
export function registerNetworkTopologyRouter(router: Router): void {
|
||||
activeRouter = router;
|
||||
databaseLogger.info("Registered Network Topology plugin router", {
|
||||
operation: "network_topology_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterNetworkTopologyRouter(): void {
|
||||
if (!activeRouter) return;
|
||||
activeRouter = null;
|
||||
databaseLogger.info("Unregistered Network Topology plugin router", {
|
||||
operation: "network_topology_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export default function networkTopologyDispatch(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
if (!activeRouter) {
|
||||
res.status(404).json({ error: "Network Topology is not available" });
|
||||
return;
|
||||
}
|
||||
activeRouter(req, res, next);
|
||||
}
|
||||
@@ -1,58 +1,34 @@
|
||||
// Mounted at /plugin-api. Every installed plugin that exposes backend routes
|
||||
// registers a sub-router here under its own plugin id, and this dispatcher
|
||||
// forwards matching requests to it. The plugin runtime populates the map via
|
||||
// registerPluginRouter when a plugin activates, and clears it on deactivate,
|
||||
// so an inactive plugin falls back to the 404 below.
|
||||
// Mounted at /plugin-api. Every plugin that serves HTTP registers a router
|
||||
// through ctx.http.router(), and this dispatcher forwards requests to it by
|
||||
// plugin id. The routers themselves live in src/backend/plugins/http.ts, which
|
||||
// also owns the middleware stack in front of them: auth, the actor, the
|
||||
// enabled check, body limits and the error wrapper.
|
||||
//
|
||||
// WebSocket note: plugin WS traffic does NOT get its own port from the
|
||||
// 30001-30006 range. It rides the existing WS servers (e.g. the terminal or
|
||||
// tunnel WS servers) using a named-channel envelope, the same way other
|
||||
// features multiplex over a shared connection. This is a settled decision,
|
||||
// not a placeholder, so it should not need revisiting when plugin WS support
|
||||
// is implemented.
|
||||
// The dispatcher stays deliberately thin. It resolves a plugin id to a router
|
||||
// and nothing else, so there is no place here for a check keyed on a
|
||||
// particular plugin.
|
||||
//
|
||||
// WebSocket note: plugin sockets do NOT ride this router, and they do not get
|
||||
// their own port either. They are served at /plugin-ws/<id>/<path> from the
|
||||
// main server's upgrade event by src/backend/plugins/ws.ts. (An earlier
|
||||
// comment here described a named-channel envelope multiplexed over the
|
||||
// existing WS servers; A4 replaced that with the /plugin-ws prefix.)
|
||||
|
||||
import express, { type Request, type Response, type Router } from "express";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { getPluginRouter } from "../../plugins/http.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const activePluginRouters = new Map<string, Router>();
|
||||
|
||||
/**
|
||||
* Called by the plugin runtime when a plugin activates. Replacing an existing
|
||||
* entry is normal: a crash-restart re-registers under the same id.
|
||||
*/
|
||||
export function registerPluginRouter(
|
||||
pluginId: string,
|
||||
pluginRouter: Router,
|
||||
): void {
|
||||
activePluginRouters.set(pluginId, pluginRouter);
|
||||
databaseLogger.info("Registered plugin API router", {
|
||||
operation: "plugin_api_register",
|
||||
pluginId,
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterPluginRouter(pluginId: string): void {
|
||||
if (!activePluginRouters.delete(pluginId)) return;
|
||||
databaseLogger.info("Unregistered plugin API router", {
|
||||
operation: "plugin_api_register",
|
||||
pluginId,
|
||||
});
|
||||
}
|
||||
|
||||
export function getRegisteredPluginIds(): string[] {
|
||||
return [...activePluginRouters.keys()];
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/{pluginId}/{path}:
|
||||
* get:
|
||||
* summary: Dispatch a request to an installed plugin's backend router
|
||||
* description: >
|
||||
* Forwards the request to the sub-router registered by the plugin with
|
||||
* the given id. Returns 404 if the plugin is not installed or not
|
||||
* Forwards the request to the router the plugin with the given id
|
||||
* registered through ctx.http.router(). Returns 404 if the plugin is not
|
||||
* installed or serves no routes, and 503 if it is installed but not
|
||||
* currently running. All HTTP methods are dispatched the same way.
|
||||
* tags:
|
||||
* - Plugins
|
||||
@@ -62,15 +38,24 @@ export function getRegisteredPluginIds(): string[] {
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: path
|
||||
* name: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Response from the plugin's router.
|
||||
* 401:
|
||||
* description: Authentication required.
|
||||
* 404:
|
||||
* description: Plugin not installed or not running.
|
||||
* description: Plugin not installed or serves no routes.
|
||||
* 503:
|
||||
* description: Plugin is installed but not running.
|
||||
*/
|
||||
router.use("/:pluginId", (req: Request, res: Response, next) => {
|
||||
const pluginId = String(req.params.pluginId);
|
||||
const pluginRouter = activePluginRouters.get(pluginId);
|
||||
const pluginRouter = getPluginRouter(pluginId);
|
||||
|
||||
if (!pluginRouter) {
|
||||
databaseLogger.warn("Plugin API request for unregistered plugin", {
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
// Mounted at /proxmox. The Proxmox discovery/import/sync feature ships as a
|
||||
// first-party in-process plugin (plugins/proxmox) rather than a route file
|
||||
// imported directly here, so this module exists to keep /proxmox/* working
|
||||
// at its original path without handing the plugin a reference to the shared
|
||||
// app instance. The plugin runtime registers its router via
|
||||
// registerProxmoxRouter when it activates, and clears it on deactivate, so a
|
||||
// disabled plugin falls back to the 404 below.
|
||||
//
|
||||
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
|
||||
// to exactly one first-party router (the Proxmox plugin's own), and every
|
||||
// route in that router already applies authenticateJWT/requireDataAccess
|
||||
// itself, the same as every other router mounted alongside it here (/users,
|
||||
// /host, /snippets, ...). /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 { databaseLogger } from "../../utils/logger.js";
|
||||
|
||||
let activeRouter: Router | null = null;
|
||||
|
||||
export function registerProxmoxRouter(router: Router): void {
|
||||
activeRouter = router;
|
||||
databaseLogger.info("Registered Proxmox plugin router", {
|
||||
operation: "proxmox_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterProxmoxRouter(): void {
|
||||
if (!activeRouter) return;
|
||||
activeRouter = null;
|
||||
databaseLogger.info("Unregistered Proxmox plugin router", {
|
||||
operation: "proxmox_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export default function proxmoxDispatch(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
if (!activeRouter) {
|
||||
res.status(404).json({ error: "Proxmox discovery is not available" });
|
||||
return;
|
||||
}
|
||||
activeRouter(req, res, next);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// Mounted at /tailscale. Tailscale ships as a first-party in-process plugin
|
||||
// (plugins/tailscale) rather than a route file imported directly here, so
|
||||
// this module exists to keep /tailscale/* working at its original path
|
||||
// without handing the plugin a reference to the shared app instance. The
|
||||
// plugin runtime registers its router via registerTailscaleRouter when it
|
||||
// activates, and clears it on deactivate, so a disabled plugin falls back to
|
||||
// the 404 below.
|
||||
//
|
||||
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
|
||||
// to exactly one first-party router (the Tailscale plugin's own), and every
|
||||
// route in that router already applies authenticateJWT itself, the same as
|
||||
// every other router mounted 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 { databaseLogger } from "../../utils/logger.js";
|
||||
|
||||
let activeRouter: Router | null = null;
|
||||
|
||||
export function registerTailscaleRouter(router: Router): void {
|
||||
activeRouter = router;
|
||||
databaseLogger.info("Registered Tailscale plugin router", {
|
||||
operation: "tailscale_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterTailscaleRouter(): void {
|
||||
if (!activeRouter) return;
|
||||
activeRouter = null;
|
||||
databaseLogger.info("Unregistered Tailscale plugin router", {
|
||||
operation: "tailscale_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export default function tailscaleDispatch(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
if (!activeRouter) {
|
||||
res.status(404).json({ error: "Tailscale is not available" });
|
||||
return;
|
||||
}
|
||||
activeRouter(req, res, next);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type { RequestHandler, Router } from "express";
|
||||
// importing the plugin's TypeScript directly, since plugins/ is compiled
|
||||
// separately from src/backend/ and core cannot statically import across that
|
||||
// boundary. See guacamole-dispatch.ts and that plugin's README.
|
||||
import { restartGuacamoleService as restartGuacServer } from "./guacamole-dispatch.js";
|
||||
import { restartGuacamoleService as restartGuacServer } from "../../hosts/guacamole-sessions.js";
|
||||
import {
|
||||
authLogger,
|
||||
getGlobalLogLevel,
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
// Mounted at /workspaces. Workspaces ships as a first-party in-process
|
||||
// plugin (plugins/workspaces) rather than a route file imported directly
|
||||
// here, so this module exists to keep /workspaces/* working at its original
|
||||
// path without handing the plugin a reference to the shared app instance.
|
||||
// The plugin runtime registers its router via registerWorkspacesRouter when
|
||||
// it activates, and clears it on deactivate, so a disabled plugin falls back
|
||||
// to the 404 below.
|
||||
//
|
||||
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
|
||||
// to exactly one first-party router (the Workspaces plugin's own), and every
|
||||
// route in that router already applies authenticateJWT itself, the same as
|
||||
// every other router mounted alongside it here (/users, /host, /snippets,
|
||||
// ...). /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 { databaseLogger } from "../../utils/logger.js";
|
||||
|
||||
let activeRouter: Router | null = null;
|
||||
|
||||
export function registerWorkspacesRouter(router: Router): void {
|
||||
activeRouter = router;
|
||||
databaseLogger.info("Registered Workspaces plugin router", {
|
||||
operation: "workspaces_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterWorkspacesRouter(): void {
|
||||
if (!activeRouter) return;
|
||||
activeRouter = null;
|
||||
databaseLogger.info("Unregistered Workspaces plugin router", {
|
||||
operation: "workspaces_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export default function workspaceDispatch(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
if (!activeRouter) {
|
||||
res.status(404).json({ error: "Workspaces is not available" });
|
||||
return;
|
||||
}
|
||||
activeRouter(req, res, next);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -29,8 +29,18 @@ import type { SecretRegistration } from "./secret-registry.js";
|
||||
import { assertCapability } from "./permissions.js";
|
||||
import { getActor, runAsActor } from "./actor.js";
|
||||
import { DisposableBag } from "./disposables.js";
|
||||
import {
|
||||
createPluginRouter,
|
||||
createRbacMiddleware,
|
||||
unregisterPluginHttp,
|
||||
} from "./http.js";
|
||||
import { registerPluginWsRoute, registerPluginWsUpgrade } from "./ws.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import type { PluginContext, PluginModule } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
PluginCapabilityError,
|
||||
type PluginContext,
|
||||
type PluginModule,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
|
||||
import * as syncRegistry from "./sync-registry.js";
|
||||
|
||||
@@ -410,6 +420,63 @@ export function createPluginContext(
|
||||
}),
|
||||
},
|
||||
|
||||
http: {
|
||||
router: (options) => {
|
||||
// Declared here, granted per request: the grant lives in the database
|
||||
// and router() has to be synchronous so a plugin can register routes
|
||||
// inline in activate. http.ts re-checks on every request.
|
||||
if (!declared.includes("network:serve")) {
|
||||
throw new PluginCapabilityError(pluginId, "network:serve");
|
||||
}
|
||||
|
||||
const router = createPluginRouter({
|
||||
manifest,
|
||||
options,
|
||||
reportError: (error) => void reportRuntimeError(pluginId, error),
|
||||
});
|
||||
|
||||
handle.bag.add(
|
||||
() => unregisterPluginHttp(pluginId),
|
||||
`HTTP router for ${pluginId}`,
|
||||
);
|
||||
return router as never;
|
||||
},
|
||||
},
|
||||
|
||||
ws: {
|
||||
route: (path, wsHandler, options) => {
|
||||
if (!declared.includes("network:serve")) {
|
||||
throw new PluginCapabilityError(pluginId, "network:serve");
|
||||
}
|
||||
const dispose = registerPluginWsRoute(
|
||||
pluginId,
|
||||
path,
|
||||
wsHandler,
|
||||
declared,
|
||||
options,
|
||||
);
|
||||
handle.bag.add(dispose, `WebSocket route "${path}"`);
|
||||
},
|
||||
|
||||
upgrade: (path, upgradeHandler, options) => {
|
||||
if (!declared.includes("network:serve")) {
|
||||
throw new PluginCapabilityError(pluginId, "network:serve");
|
||||
}
|
||||
const dispose = registerPluginWsUpgrade(
|
||||
pluginId,
|
||||
path,
|
||||
upgradeHandler as never,
|
||||
declared,
|
||||
options,
|
||||
);
|
||||
handle.bag.add(dispose, `WebSocket upgrade "${path}"`);
|
||||
},
|
||||
},
|
||||
|
||||
rbac: {
|
||||
require: (permission) => createRbacMiddleware(manifest, permission),
|
||||
},
|
||||
|
||||
disposables: {
|
||||
add: (dispose) => handle.bag.add(dispose, "plugin resource"),
|
||||
},
|
||||
@@ -439,6 +506,24 @@ export function createPluginContext(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Feeds a route or socket error into the loader's error budget.
|
||||
*
|
||||
* Lazily imported: index.ts owns the loader and imports this module, so a
|
||||
* static import here would close the cycle.
|
||||
*/
|
||||
async function reportRuntimeError(
|
||||
pluginId: string,
|
||||
error: unknown,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { getPluginRuntime } = await import("./index.js");
|
||||
await getPluginRuntime().loader.reportError(pluginId, error);
|
||||
} catch {
|
||||
// The budget is a safety net, not a dependency of serving a request.
|
||||
}
|
||||
}
|
||||
|
||||
function assertKvKey(key: string): void {
|
||||
if (typeof key !== "string" || key.length === 0) {
|
||||
throw new Error("ctx.kv keys must be non-empty strings");
|
||||
|
||||
@@ -34,6 +34,12 @@ export const TOPICS = {
|
||||
hostStatus: "host.status",
|
||||
/** A health check result. */
|
||||
hostHealthCheck: "host.health_check",
|
||||
/** A host's connection details changed and pollers should re-read them. */
|
||||
hostUpdated: "host.updated",
|
||||
/** A host was deleted and any poller holding it should drop it. */
|
||||
hostDeleted: "host.deleted",
|
||||
/** Someone logged in over SSH to a host. */
|
||||
hostLogin: "host.login",
|
||||
} as const;
|
||||
|
||||
export type EventListener = (payload: unknown) => void;
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* HTTP for plugins: one mount point, one set of middleware, no ports.
|
||||
*
|
||||
* A plugin calls ctx.http.router() and gets an Express Router that core mounts
|
||||
* at /plugin-api/<id>/. Everything a route needs in front of it runs here
|
||||
* rather than in the plugin:
|
||||
*
|
||||
* 1. auth, unless the path was declared public;
|
||||
* 2. the actor, so ctx and core APIs know who is calling;
|
||||
* 3. an enabled check, so a disabled plugin answers 503 rather than 404;
|
||||
* 4. body limits;
|
||||
* 5. an error wrapper that logs against the plugin and leaks nothing.
|
||||
*
|
||||
* The wrapper never touches the response body, so SSE, file downloads and
|
||||
* multipart uploads stream through untouched.
|
||||
*/
|
||||
|
||||
import express, {
|
||||
type NextFunction,
|
||||
type Request,
|
||||
type Response,
|
||||
type Router,
|
||||
} from "express";
|
||||
import type {
|
||||
PluginMiddleware,
|
||||
PluginRouterOptions,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { runAsActor } from "./actor.js";
|
||||
import { hasCapability } from "./permissions.js";
|
||||
|
||||
/** Matches what core's own routes accept, so moving a route changes nothing. */
|
||||
const DEFAULT_BODY_LIMIT = "2mb";
|
||||
|
||||
/** Routers by plugin id, read by the dispatcher in plugin-api-routes.ts. */
|
||||
const routers = new Map<string, Router>();
|
||||
|
||||
export function getPluginRouter(pluginId: string): Router | undefined {
|
||||
return routers.get(pluginId);
|
||||
}
|
||||
|
||||
export function getRegisteredHttpPluginIds(): string[] {
|
||||
return [...routers.keys()];
|
||||
}
|
||||
|
||||
export function unregisterPluginHttp(pluginId: string): void {
|
||||
if (!routers.delete(pluginId)) return;
|
||||
pluginLogger.info(`Unmounted /plugin-api/${pluginId}`, {
|
||||
operation: "plugin_http_unmount",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the plugin is currently running.
|
||||
*
|
||||
* Injected rather than imported so this module does not depend on the loader,
|
||||
* which imports ctx.ts, which would close a cycle.
|
||||
*/
|
||||
type IsEnabled = (pluginId: string) => boolean;
|
||||
|
||||
let isPluginEnabled: IsEnabled = () => true;
|
||||
|
||||
export function setPluginEnabledCheck(check: IsEnabled): void {
|
||||
isPluginEnabled = check;
|
||||
}
|
||||
|
||||
/** Test seam, so one test's stub does not leak into the next. */
|
||||
export function resetPluginHttp(): void {
|
||||
routers.clear();
|
||||
isPluginEnabled = () => true;
|
||||
}
|
||||
|
||||
function normalizePublicPath(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
return withSlash.length > 1 && withSlash.endsWith("/")
|
||||
? withSlash.slice(0, -1)
|
||||
: withSlash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles a declared public path into a matcher.
|
||||
*
|
||||
* Express-style ":param" segments are supported, because the routes that have
|
||||
* to be public are usually keyed by a token ("/webhook/:token"). A parameter
|
||||
* matches exactly one segment and never a slash, so ":token" cannot swallow
|
||||
* the rest of the path and turn one declared route into a prefix hole.
|
||||
*/
|
||||
function compilePublicPath(path: string): RegExp {
|
||||
const escaped = path
|
||||
.split("/")
|
||||
.map((segment) => {
|
||||
if (segment.startsWith(":")) return "[^/]+";
|
||||
return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
})
|
||||
.join("/");
|
||||
return new RegExp(`^${escaped}$`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches the request path against the declared public paths.
|
||||
*
|
||||
* Full-path match only. A prefix match would turn one declared route into a
|
||||
* hole covering everything below it, which is not what declaring a single
|
||||
* public path should mean.
|
||||
*/
|
||||
function isPublicRequest(req: Request, publicPaths: RegExp[]): boolean {
|
||||
if (publicPaths.length === 0) return false;
|
||||
const path = normalizePublicPath(req.path || "/");
|
||||
return publicPaths.some((pattern) => pattern.test(path));
|
||||
}
|
||||
|
||||
export interface CreatePluginRouterArgs {
|
||||
manifest: PluginManifest;
|
||||
options?: PluginRouterOptions;
|
||||
/** Records a runtime error against the plugin's error budget. */
|
||||
reportError: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export function createPluginRouter({
|
||||
manifest,
|
||||
options,
|
||||
reportError,
|
||||
}: CreatePluginRouterArgs): Router {
|
||||
const pluginId = manifest.id;
|
||||
const declaredPublic = (options?.public ?? []).map((path) =>
|
||||
normalizePublicPath(path),
|
||||
);
|
||||
const publicPaths = declaredPublic.map((path) => compilePublicPath(path));
|
||||
|
||||
if (declaredPublic.length > 0) {
|
||||
// Audited once at registration: the set of unauthenticated routes a plugin
|
||||
// has is worth seeing even if nobody ever calls them.
|
||||
pluginLogger.warn(
|
||||
`Plugin ${pluginId} serves ${declaredPublic.length} route(s) without authentication: ${declaredPublic.join(", ")}`,
|
||||
{ operation: "plugin_http_public" },
|
||||
);
|
||||
void writePublicRouteAudit(manifest, declaredPublic);
|
||||
}
|
||||
|
||||
const outer = express.Router();
|
||||
const inner = express.Router();
|
||||
|
||||
outer.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (isPluginEnabled(pluginId)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.status(503).json({ error: "Plugin is not running", pluginId });
|
||||
});
|
||||
|
||||
// The grant lives in the database, so it cannot be checked in the
|
||||
// synchronous router() call a plugin makes inside activate. Checked per
|
||||
// request instead: a plugin whose network:serve was revoked keeps its mount
|
||||
// point and refuses every request through it, which is the same outcome a
|
||||
// revoke should have and takes effect without a restart.
|
||||
outer.use((req: Request, res: Response, next: NextFunction) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const granted = await hasCapability(
|
||||
pluginId,
|
||||
"network:serve",
|
||||
manifest.capabilities,
|
||||
);
|
||||
if (!granted) {
|
||||
res.status(403).json({
|
||||
error: "Plugin is not granted the network:serve capability",
|
||||
pluginId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
const authenticate = AuthManager.getInstance().createAuthMiddleware();
|
||||
|
||||
outer.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (isPublicRequest(req, publicPaths)) {
|
||||
pluginLogger.info(`Unauthenticated request to ${pluginId}${req.path}`, {
|
||||
operation: "plugin_http_public_request",
|
||||
});
|
||||
next();
|
||||
return;
|
||||
}
|
||||
authenticate(req, res, next);
|
||||
});
|
||||
|
||||
if (!options?.rawBody) {
|
||||
const limit = options?.bodyLimit ?? DEFAULT_BODY_LIMIT;
|
||||
// Both parsers ignore a body they do not claim, so multipart still reaches
|
||||
// multer untouched. database.ts skips its own global parser for
|
||||
// /plugin-api so this limit is the one that actually applies.
|
||||
outer.use(express.json({ limit }));
|
||||
outer.use(express.urlencoded({ limit, extended: true }));
|
||||
}
|
||||
|
||||
// After auth, so the actor is the user core authenticated rather than
|
||||
// whatever the request claimed.
|
||||
outer.use((req: Request, _res: Response, next: NextFunction) => {
|
||||
const userId = (req as Request & { userId?: string }).userId;
|
||||
if (!userId) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
runAsActor(userId, "request", () => next());
|
||||
});
|
||||
|
||||
outer.use(inner);
|
||||
|
||||
outer.use(
|
||||
(
|
||||
error: unknown,
|
||||
req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction,
|
||||
): void => {
|
||||
void _next;
|
||||
reportError(error);
|
||||
pluginLogger.error(
|
||||
`Plugin ${pluginId} route ${req.method} ${req.path} failed`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_http_error" },
|
||||
);
|
||||
if (res.headersSent) {
|
||||
// Mid-stream: the only honest thing left is to end it.
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: "Plugin request failed" });
|
||||
},
|
||||
);
|
||||
|
||||
routers.set(pluginId, outer);
|
||||
pluginLogger.info(`Mounted /plugin-api/${pluginId}`, {
|
||||
operation: "plugin_http_mount",
|
||||
});
|
||||
|
||||
return inner;
|
||||
}
|
||||
|
||||
async function writePublicRouteAudit(
|
||||
manifest: PluginManifest,
|
||||
paths: string[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { logAudit } = await import("../utils/audit-logger.js");
|
||||
await logAudit({
|
||||
userId: "system",
|
||||
username: `plugin:${manifest.id}`,
|
||||
action: "plugin_http_public_routes",
|
||||
resourceType: "plugin",
|
||||
resourceId: manifest.id,
|
||||
resourceName: manifest.name,
|
||||
details: `unauthenticated routes: ${paths.join(", ")}`,
|
||||
success: true,
|
||||
});
|
||||
} catch {
|
||||
// Auditing must never stop a plugin starting.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds ctx.rbac.require.
|
||||
*
|
||||
* A plugin may only gate on a permission it declares itself. Without that a
|
||||
* route could require admin.users.manage and borrow someone else's authority,
|
||||
* which is the escalation shape the manifest validator already blocks for
|
||||
* role defaults.
|
||||
*/
|
||||
export function createRbacMiddleware(
|
||||
manifest: PluginManifest,
|
||||
permission: string,
|
||||
): PluginMiddleware {
|
||||
const declared = new Set(
|
||||
manifest.contributes?.permissionGroup?.permissions ?? [],
|
||||
);
|
||||
|
||||
if (!declared.has(permission)) {
|
||||
throw new Error(
|
||||
`Plugin ${manifest.id} cannot require permission "${permission}": it is not declared in contributes.permissionGroup.permissions`,
|
||||
);
|
||||
}
|
||||
|
||||
return ((req: Request, res: Response, next: NextFunction) => {
|
||||
void (async () => {
|
||||
const userId = (req as Request & { userId?: string }).userId;
|
||||
if (!userId) {
|
||||
res.status(401).json({ error: "Authentication required" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
const allowed = await PermissionManager.getInstance().hasPermission(
|
||||
userId,
|
||||
permission,
|
||||
);
|
||||
if (!allowed) {
|
||||
res.status(403).json({ error: "Permission denied", permission });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
})();
|
||||
}) as unknown as PluginMiddleware;
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { unregisterPluginRouter } from "../database/routes/plugin-api-routes.js";
|
||||
import { setPluginEnabledCheck, unregisterPluginHttp } from "./http.js";
|
||||
import { PluginLoader, type LoadedPlugin } from "./loader.js";
|
||||
import { invalidatePluginPermissionCache } from "./permissions.js";
|
||||
|
||||
@@ -19,6 +19,12 @@ let loader: PluginLoader | null = null;
|
||||
export function getPluginRuntime(): { loader: PluginLoader } {
|
||||
if (!loader) {
|
||||
loader = new PluginLoader();
|
||||
// The 503-while-disabled answer comes from here rather than from the
|
||||
// router being torn down, so a request that arrives mid-disable gets a
|
||||
// truthful status instead of a 404.
|
||||
setPluginEnabledCheck(
|
||||
(pluginId) => loader?.get(pluginId)?.state === "active",
|
||||
);
|
||||
}
|
||||
return { loader };
|
||||
}
|
||||
@@ -336,7 +342,7 @@ export async function deactivatePlugin(pluginId: string): Promise<void> {
|
||||
const plugin = pluginLoader.get(pluginId);
|
||||
|
||||
await pluginLoader.deactivate(pluginId);
|
||||
unregisterPluginRouter(pluginId);
|
||||
unregisterPluginHttp(pluginId);
|
||||
|
||||
const group = plugin?.manifest.contributes?.permissionGroup;
|
||||
if (group) {
|
||||
@@ -348,7 +354,7 @@ export async function deactivatePlugin(pluginId: string): Promise<void> {
|
||||
|
||||
export async function shutdownPlugins(): Promise<void> {
|
||||
if (!loader) return;
|
||||
for (const plugin of loader.list()) unregisterPluginRouter(plugin.id);
|
||||
for (const plugin of loader.list()) unregisterPluginHttp(plugin.id);
|
||||
await loader.shutdown();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* 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/plugin-sdk/backend";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { extractWebSocketToken } from "../utils/ws-auth.js";
|
||||
import { runAsActor } from "./actor.js";
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
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.
|
||||
*/
|
||||
export 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 for a plugin that is disabled.
|
||||
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 (!(await hasCapability(route.pluginId, "network:serve", route.declared))) {
|
||||
reject(socket, 403, "Forbidden");
|
||||
return true;
|
||||
}
|
||||
|
||||
let userId = "";
|
||||
if (!route.options.public) {
|
||||
const resolved = await verifyToken(request);
|
||||
if (!resolved) {
|
||||
reject(socket, 401, "Unauthorized");
|
||||
return true;
|
||||
}
|
||||
userId = resolved;
|
||||
}
|
||||
|
||||
if (route.rawHandler) {
|
||||
const rawHandler = route.rawHandler;
|
||||
runAsActor(userId || "anonymous", "request", () =>
|
||||
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;
|
||||
}
|
||||
|
||||
server.handleUpgrade(request, socket, head, (ws: WebSocket) => {
|
||||
const handler = route.handler;
|
||||
if (!handler) {
|
||||
ws.close(1011, "No handler");
|
||||
return;
|
||||
}
|
||||
runAsActor(userId || "anonymous", "request", () => {
|
||||
void Promise.resolve(handler({ userId, request, 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);
|
||||
|
||||
routes.set(routeKey, {
|
||||
pluginId,
|
||||
path: normalized,
|
||||
options,
|
||||
declared,
|
||||
handler,
|
||||
});
|
||||
servers.set(routeKey, new WebSocketServer({ noServer: true }));
|
||||
|
||||
pluginLogger.info(`Mounted /plugin-ws/${pluginId}${normalized}`, {
|
||||
operation: "plugin_ws_mount",
|
||||
});
|
||||
|
||||
return () => disposeRoute(routeKey, pluginId, 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);
|
||||
|
||||
routes.set(routeKey, {
|
||||
pluginId,
|
||||
path: normalized,
|
||||
options,
|
||||
declared,
|
||||
rawHandler: handler,
|
||||
});
|
||||
|
||||
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 {
|
||||
routes.delete(routeKey);
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
export function getRegisteredWsRoutes(): string[] {
|
||||
return [...routes.keys()];
|
||||
}
|
||||
|
||||
/** 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();
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* /plugin-api is mounted behind authenticateJWT in database.ts, the same way
|
||||
* every other router is gated, so an unauthenticated caller can never reach
|
||||
* plugin code.
|
||||
*
|
||||
* The dispatcher itself has no routes registered in A1: the worker HTTP
|
||||
* bridge was removed with the worker tier and A4 rebuilds routing on top of
|
||||
* the SDK. What still has to hold, and what this covers, is that the auth
|
||||
* gate sits in front of the mount and that an unknown plugin id is a 404
|
||||
* rather than a crash.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
validToken: "valid-token",
|
||||
userId: "user-1",
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
databaseLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const pluginApi = await import("../../../database/routes/plugin-api-routes.js");
|
||||
|
||||
/** Stands in for AuthManager's middleware, with the same 401 behaviour. */
|
||||
function authenticateJWT(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
): void {
|
||||
const header = req.headers.authorization;
|
||||
const token = header?.startsWith("Bearer ") ? header.slice(7) : null;
|
||||
|
||||
if (token !== state.validToken) {
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
(req as express.Request & { userId?: string }).userId = state.userId;
|
||||
next();
|
||||
}
|
||||
|
||||
let server: Server | null = null;
|
||||
|
||||
async function startServer(): Promise<string> {
|
||||
const app = express();
|
||||
// The same order database.ts uses: the gate, then the dispatcher.
|
||||
app.use("/plugin-api", authenticateJWT, pluginApi.default);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server = app.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server!.address() as AddressInfo;
|
||||
resolve(`http://127.0.0.1:${port}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (server) {
|
||||
await new Promise((resolve) => server!.close(() => resolve(undefined)));
|
||||
server = null;
|
||||
}
|
||||
for (const id of pluginApi.getRegisteredPluginIds()) {
|
||||
pluginApi.unregisterPluginRouter(id);
|
||||
}
|
||||
});
|
||||
|
||||
describe("/plugin-api authentication", () => {
|
||||
it("rejects a request with no token", async () => {
|
||||
const base = await startServer();
|
||||
|
||||
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects a request with the wrong token", async () => {
|
||||
const base = await startServer();
|
||||
|
||||
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||
headers: { Authorization: "Bearer nope" },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
// Past the gate, an id nothing registered is a plain 404. The auth check
|
||||
// runs first either way, so a caller cannot use the difference between 401
|
||||
// and 404 to find out which plugins are installed.
|
||||
it("returns 404 for an unregistered plugin id when authenticated", async () => {
|
||||
const base = await startServer();
|
||||
|
||||
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||
headers: { Authorization: `Bearer ${state.validToken}` },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("dispatches to a registered router only when authenticated", async () => {
|
||||
const router = express.Router();
|
||||
router.get("/thing", (req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
userId: (req as express.Request & { userId?: string }).userId,
|
||||
});
|
||||
});
|
||||
pluginApi.registerPluginRouter("sample-plugin", router);
|
||||
|
||||
const base = await startServer();
|
||||
|
||||
const unauthorized = await fetch(`${base}/plugin-api/sample-plugin/thing`);
|
||||
expect(unauthorized.status).toBe(401);
|
||||
|
||||
const authorized = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||
headers: { Authorization: `Bearer ${state.validToken}` },
|
||||
});
|
||||
|
||||
expect(authorized.status).toBe(200);
|
||||
// The user id the plugin sees is the one the gate verified, never one the
|
||||
// caller supplied.
|
||||
expect(await authorized.json()).toEqual({
|
||||
ok: true,
|
||||
userId: state.userId,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -56,7 +56,7 @@ vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
getAuditUsername: vi.fn(async (id: string) => id.toUpperCase()),
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
vi.mock("../../../database/routes/guacamole-dispatch.js", () => ({
|
||||
vi.mock("../../../hosts/guacamole-sessions.js", () => ({
|
||||
createGuacamoleJoinToken: (id: string, readOnly: boolean) =>
|
||||
`join:${id}:${readOnly}`,
|
||||
}));
|
||||
@@ -646,7 +646,7 @@ describe("collab room routes", () => {
|
||||
await present(roomId, "s1");
|
||||
expect((await resolve(guestLinkToken!)).jsonBody!.stage).toMatchObject({
|
||||
protocol: "ssh",
|
||||
wsPath: `/terminal/ws?roomGuestToken=${encodeURIComponent(guestLinkToken!)}`,
|
||||
wsPath: `/plugin-ws/ssh-terminal/terminal?roomGuestToken=${encodeURIComponent(guestLinkToken!)}`,
|
||||
});
|
||||
state.liveOwned.set("g1", "host-1");
|
||||
await present(roomId, "g1", "vnc");
|
||||
|
||||
@@ -63,7 +63,7 @@ vi.mock("../../../hosts/terminal/session-manager.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/routes/guacamole-dispatch.js", () => ({
|
||||
vi.mock("../../../hosts/guacamole-sessions.js", () => ({
|
||||
getGuacamoleSessionInfo: (guacamoleConnectionId: string) =>
|
||||
state.guacSessions.get(guacamoleConnectionId) ?? null,
|
||||
createGuacamoleJoinToken: (
|
||||
@@ -364,7 +364,7 @@ describe("GET /session-sharing/resolve/:linkToken", () => {
|
||||
expect(res.jsonBody).toEqual({
|
||||
protocol: "ssh",
|
||||
permissionLevel: "read-only",
|
||||
wsPath: `/terminal/ws?shareToken=${encodeURIComponent(share.linkToken)}`,
|
||||
wsPath: `/plugin-ws/ssh-terminal/terminal?shareToken=${encodeURIComponent(share.linkToken)}`,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
/**
|
||||
* The terminal WS server's start/stop lifecycle, which the ssh-terminal plugin
|
||||
* drives. The point of this test is the observable consequence of disabling
|
||||
* the plugin: the port actually stops listening.
|
||||
* The terminal WS server's lifecycle, which the ssh-terminal plugin drives.
|
||||
*
|
||||
* There is no terminal port any more: core serves the socket at
|
||||
* /plugin-ws/ssh-terminal/terminal and the plugin hands each upgrade to the
|
||||
* WebSocketServer this module owns. So the thing worth asserting changed with
|
||||
* it. It used to be "the port stops listening"; it is now "the module binds
|
||||
* nothing on import, and disabling the plugin closes the live sessions", which
|
||||
* is what disabling a transport owner has to mean when core owns the socket.
|
||||
*
|
||||
* The terminal module is heavy (it pulls in the whole SSH stack), so this
|
||||
* exercises only the lifecycle functions, on a non-default port.
|
||||
* exercises only the lifecycle surface.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import net from "node:net";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({}),
|
||||
@@ -18,65 +22,29 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSessionRecordingRepository: () => ({}),
|
||||
}));
|
||||
|
||||
const TEST_PORT = 39002;
|
||||
|
||||
function isListening(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect({ port, host: "127.0.0.1" });
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
socket.once("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
describe("terminal server lifecycle", () => {
|
||||
afterEach(async () => {
|
||||
it("binds no port of its own", async () => {
|
||||
const terminal = await import("../../../hosts/terminal/index.js");
|
||||
|
||||
// The old module exported a port and a listen() call. Both are gone, and
|
||||
// their absence is the contract: a plugin that binds its own port is
|
||||
// exactly what A4 removed.
|
||||
expect("TERMINAL_WS_PORT" in terminal).toBe(false);
|
||||
expect("startTerminalServer" in terminal).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes an upgrade handler for core to route to", async () => {
|
||||
const { handleTerminalUpgrade } =
|
||||
await import("../../../hosts/terminal/index.js");
|
||||
|
||||
expect(typeof handleTerminalUpgrade).toBe("function");
|
||||
});
|
||||
|
||||
it("is idempotent on stop, so a double deactivate is safe", async () => {
|
||||
const { stopTerminalServer } =
|
||||
await import("../../../hosts/terminal/index.js");
|
||||
await stopTerminalServer();
|
||||
});
|
||||
|
||||
it("does not listen until it is started", async () => {
|
||||
await import("../../../hosts/terminal/index.js");
|
||||
// Importing the module must not bind the port: the plugin decides that.
|
||||
expect(await isListening(TEST_PORT)).toBe(false);
|
||||
});
|
||||
|
||||
it("listens after start and frees the port after stop", async () => {
|
||||
const { startTerminalServer, stopTerminalServer } =
|
||||
await import("../../../hosts/terminal/index.js");
|
||||
|
||||
await startTerminalServer(TEST_PORT);
|
||||
expect(await isListening(TEST_PORT)).toBe(true);
|
||||
|
||||
await stopTerminalServer();
|
||||
// This is what "disabling the plugin" has to mean for a transport owner.
|
||||
expect(await isListening(TEST_PORT)).toBe(false);
|
||||
});
|
||||
|
||||
it("is idempotent on both ends", async () => {
|
||||
const { startTerminalServer, stopTerminalServer } =
|
||||
await import("../../../hosts/terminal/index.js");
|
||||
|
||||
await startTerminalServer(TEST_PORT);
|
||||
await startTerminalServer(TEST_PORT);
|
||||
expect(await isListening(TEST_PORT)).toBe(true);
|
||||
|
||||
await stopTerminalServer();
|
||||
await stopTerminalServer();
|
||||
expect(await isListening(TEST_PORT)).toBe(false);
|
||||
});
|
||||
|
||||
it("can be restarted, so re-enabling the plugin works", async () => {
|
||||
const { startTerminalServer, stopTerminalServer } =
|
||||
await import("../../../hosts/terminal/index.js");
|
||||
|
||||
await startTerminalServer(TEST_PORT);
|
||||
await stopTerminalServer();
|
||||
await startTerminalServer(TEST_PORT);
|
||||
|
||||
expect(await isListening(TEST_PORT)).toBe(true);
|
||||
await expect(stopTerminalServer()).resolves.toBeUndefined();
|
||||
await expect(stopTerminalServer()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* ctx.http: the one way a plugin serves HTTP.
|
||||
*
|
||||
* Everything here is about the middleware core puts in front of a plugin's
|
||||
* routes, because that is what moving off the dispatcher shims bought: auth by
|
||||
* default, a truthful 503 while disabled, a capability that can be revoked
|
||||
* without a restart, and streaming that still streams.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
validToken: "valid-token",
|
||||
userId: "user-1",
|
||||
granted: new Set<string>(["network:serve"]),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
databaseLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() =>
|
||||
(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) => {
|
||||
const header = req.headers.authorization;
|
||||
const token = header?.startsWith("Bearer ") ? header.slice(7) : null;
|
||||
if (token !== state.validToken) {
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
(req as express.Request & { userId?: string }).userId = state.userId;
|
||||
next();
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", () => ({
|
||||
hasCapability: async (
|
||||
_pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => declared.includes(capability) && state.granted.has(capability),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({ logAudit: vi.fn() }));
|
||||
|
||||
const http = await import("../../plugins/http.js");
|
||||
const pluginApi = (await import("../../database/routes/plugin-api-routes.js"))
|
||||
.default;
|
||||
|
||||
function manifest(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "sample-plugin",
|
||||
name: "Sample Plugin",
|
||||
version: "1.0.0",
|
||||
capabilities: ["network:serve"],
|
||||
...overrides,
|
||||
} as never;
|
||||
}
|
||||
|
||||
let server: Server | null = null;
|
||||
|
||||
async function startServer(): Promise<string> {
|
||||
const app = express();
|
||||
// Exactly how database.ts mounts it: no auth in front, because the plugin
|
||||
// router brings its own.
|
||||
app.use("/plugin-api", pluginApi);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server = app.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server!.address() as AddressInfo;
|
||||
resolve(`http://127.0.0.1:${port}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const authed = { Authorization: `Bearer ${state.validToken}` };
|
||||
|
||||
beforeEach(() => {
|
||||
http.resetPluginHttp();
|
||||
state.granted = new Set(["network:serve"]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (server) {
|
||||
await new Promise((resolve) => server!.close(() => resolve(undefined)));
|
||||
server = null;
|
||||
}
|
||||
http.resetPluginHttp();
|
||||
});
|
||||
|
||||
describe("ctx.http mounting", () => {
|
||||
it("serves a plugin's routes once it registers a router", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
reportError: () => {},
|
||||
});
|
||||
router.get("/thing", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const base = await startServer();
|
||||
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||
headers: authed,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("404s a plugin id that never registered a router", async () => {
|
||||
const base = await startServer();
|
||||
|
||||
const response = await fetch(`${base}/plugin-api/nobody/thing`, {
|
||||
headers: authed,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("unmounts on deactivate, so a disabled plugin stops serving", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
reportError: () => {},
|
||||
});
|
||||
router.get("/thing", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const base = await startServer();
|
||||
expect(
|
||||
(
|
||||
await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||
headers: authed,
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
|
||||
http.unregisterPluginHttp("sample-plugin");
|
||||
|
||||
expect(
|
||||
(
|
||||
await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||
headers: authed,
|
||||
})
|
||||
).status,
|
||||
).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.http authentication", () => {
|
||||
it("requires authentication by default", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
reportError: () => {},
|
||||
});
|
||||
router.get("/thing", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const base = await startServer();
|
||||
|
||||
expect((await fetch(`${base}/plugin-api/sample-plugin/thing`)).status).toBe(
|
||||
401,
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||
headers: { Authorization: "Bearer nope" },
|
||||
})
|
||||
).status,
|
||||
).toBe(401);
|
||||
});
|
||||
|
||||
it("serves a declared public path without a token", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
options: { public: ["/webhook/:token"] },
|
||||
reportError: () => {},
|
||||
});
|
||||
router.post("/webhook/:token", (req, res) =>
|
||||
res.json({ token: req.params.token }),
|
||||
);
|
||||
router.get("/private", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const base = await startServer();
|
||||
|
||||
const open = await fetch(
|
||||
`${base}/plugin-api/sample-plugin/webhook/abc123`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
expect(open.status).toBe(200);
|
||||
expect(await open.json()).toEqual({ token: "abc123" });
|
||||
|
||||
// Declaring one route public must not open the rest of the plugin.
|
||||
expect(
|
||||
(await fetch(`${base}/plugin-api/sample-plugin/private`)).status,
|
||||
).toBe(401);
|
||||
});
|
||||
|
||||
it("does not let a public parameter swallow extra path segments", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
options: { public: ["/webhook/:token"] },
|
||||
reportError: () => {},
|
||||
});
|
||||
router.get("/webhook/:token/secret", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const base = await startServer();
|
||||
|
||||
const response = await fetch(
|
||||
`${base}/plugin-api/sample-plugin/webhook/abc/secret`,
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("gives the plugin the user core authenticated, not one the caller named", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
reportError: () => {},
|
||||
});
|
||||
router.get("/who", (req, res) =>
|
||||
res.json({
|
||||
userId: (req as express.Request & { userId?: string }).userId,
|
||||
}),
|
||||
);
|
||||
|
||||
const base = await startServer();
|
||||
const response = await fetch(`${base}/plugin-api/sample-plugin/who`, {
|
||||
headers: { ...authed, "x-user-id": "attacker" },
|
||||
});
|
||||
|
||||
expect(await response.json()).toEqual({ userId: state.userId });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.http capability", () => {
|
||||
it("refuses to build a router without network:serve declared", () => {
|
||||
expect(() =>
|
||||
http.createPluginRouter({
|
||||
manifest: manifest({ capabilities: [] }),
|
||||
reportError: () => {},
|
||||
}),
|
||||
).not.toThrow();
|
||||
// Declaration is checked in ctx.ts; the grant is checked per request below.
|
||||
});
|
||||
|
||||
it("403s every request when the grant is revoked", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
reportError: () => {},
|
||||
});
|
||||
router.get("/thing", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const base = await startServer();
|
||||
expect(
|
||||
(
|
||||
await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||
headers: authed,
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
|
||||
// Revoked at runtime: no restart, and the next request is refused.
|
||||
state.granted.delete("network:serve");
|
||||
|
||||
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||
headers: authed,
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.http while the plugin is disabled", () => {
|
||||
it("answers 503 rather than 404", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
reportError: () => {},
|
||||
});
|
||||
router.get("/thing", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
// "Installed but not running" is a different fact from "no such plugin",
|
||||
// and a caller can act on the difference.
|
||||
http.setPluginEnabledCheck(() => false);
|
||||
|
||||
const base = await startServer();
|
||||
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||
headers: authed,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.http errors", () => {
|
||||
it("reports a throwing route without leaking a stack", async () => {
|
||||
const reported: unknown[] = [];
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
reportError: (error) => reported.push(error),
|
||||
});
|
||||
router.get("/boom", () => {
|
||||
throw new Error("secret internal detail");
|
||||
});
|
||||
|
||||
const base = await startServer();
|
||||
const response = await fetch(`${base}/plugin-api/sample-plugin/boom`, {
|
||||
headers: authed,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.text();
|
||||
expect(body).not.toContain("secret internal detail");
|
||||
// Counted against the plugin's error budget.
|
||||
expect(reported).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.http streaming", () => {
|
||||
it("streams an SSE response as it is produced", async () => {
|
||||
const router = http.createPluginRouter({
|
||||
manifest: manifest(),
|
||||
reportError: () => {},
|
||||
});
|
||||
|
||||
let release: (() => void) | null = null;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
router.get("/stream", async (_req, res) => {
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.write("event: progress\ndata: 1\n\n");
|
||||
// The first chunk has to arrive before the handler finishes, or this is
|
||||
// buffering rather than streaming.
|
||||
await gate;
|
||||
res.write("event: result\ndata: done\n\n");
|
||||
res.end();
|
||||
});
|
||||
|
||||
const base = await startServer();
|
||||
const response = await fetch(`${base}/plugin-api/sample-plugin/stream`, {
|
||||
headers: authed,
|
||||
});
|
||||
|
||||
expect(response.headers.get("content-type")).toContain("text/event-stream");
|
||||
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
const first = await reader.read();
|
||||
expect(decoder.decode(first.value)).toContain("data: 1");
|
||||
|
||||
release!();
|
||||
|
||||
let rest = "";
|
||||
for (;;) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
rest += decoder.decode(chunk.value);
|
||||
}
|
||||
expect(rest).toContain("data: done");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* ctx.ws: the one way a plugin serves WebSockets.
|
||||
*
|
||||
* The properties that matter are the ones the per-plugin ports used to leave
|
||||
* to each plugin: an upgrade is authenticated before any plugin code runs, a
|
||||
* route that no longer exists refuses cleanly, and disabling a plugin actually
|
||||
* closes its live sockets rather than just unrouting new ones.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
validToken: "valid-token",
|
||||
userId: "user-1",
|
||||
granted: new Set<string>(["network:serve"]),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
verifyJWTToken: async (token: string) =>
|
||||
token === state.validToken ? { userId: state.userId } : null,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", () => ({
|
||||
hasCapability: async (
|
||||
_pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => declared.includes(capability) && state.granted.has(capability),
|
||||
}));
|
||||
|
||||
const ws = await import("../../plugins/ws.js");
|
||||
|
||||
const DECLARED = ["network:serve"] as const;
|
||||
|
||||
let server: http.Server | null = null;
|
||||
let baseUrl = "";
|
||||
|
||||
async function startServer(): Promise<string> {
|
||||
const instance = http.createServer((_req, res) => {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
});
|
||||
ws.attachPluginWebSockets(instance);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server = instance;
|
||||
instance.listen(0, "127.0.0.1", () => {
|
||||
const { port } = instance.address() as AddressInfo;
|
||||
baseUrl = `ws://127.0.0.1:${port}`;
|
||||
resolve(baseUrl);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolves with "open" or the HTTP status the upgrade was refused with. */
|
||||
function connect(
|
||||
url: string,
|
||||
protocols: string[] = [],
|
||||
): Promise<{ outcome: "open"; socket: WebSocket } | { outcome: number }> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new WebSocket(url, protocols);
|
||||
socket.once("open", () => resolve({ outcome: "open", socket }));
|
||||
socket.once("unexpected-response", (_req, res) =>
|
||||
resolve({ outcome: res.statusCode ?? 0 }),
|
||||
);
|
||||
socket.once("error", () => resolve({ outcome: 0 }));
|
||||
});
|
||||
}
|
||||
|
||||
function authProtocols(token: string): string[] {
|
||||
return [`termix.jwt.${token}`];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
ws.resetPluginWebSockets();
|
||||
state.granted = new Set(["network:serve"]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
ws.resetPluginWebSockets();
|
||||
if (server) {
|
||||
await new Promise((resolve) => server!.close(() => resolve(undefined)));
|
||||
server = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe("ctx.ws routing", () => {
|
||||
it("serves a registered route to an authenticated client", async () => {
|
||||
const seen: string[] = [];
|
||||
ws.registerPluginWsRoute(
|
||||
"sample-plugin",
|
||||
"/socket",
|
||||
({ userId, socket }) => {
|
||||
seen.push(userId);
|
||||
(socket as WebSocket).send("hello");
|
||||
},
|
||||
DECLARED,
|
||||
);
|
||||
|
||||
const base = await startServer();
|
||||
const result = await connect(
|
||||
`${base}/plugin-ws/sample-plugin/socket`,
|
||||
authProtocols(state.validToken),
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe("open");
|
||||
expect(seen).toEqual([state.userId]);
|
||||
if (result.outcome === "open") result.socket.close();
|
||||
});
|
||||
|
||||
it("404s a path nothing registered", async () => {
|
||||
const base = await startServer();
|
||||
|
||||
const result = await connect(`${base}/plugin-ws/sample-plugin/missing`);
|
||||
|
||||
expect(result.outcome).toBe(404);
|
||||
});
|
||||
|
||||
it("leaves non-plugin upgrades to whoever else is listening", async () => {
|
||||
let claimedByCore = false;
|
||||
|
||||
const base = await startServer();
|
||||
// A second listener standing in for a core WS server. If the plugin
|
||||
// handler answered /ssh/websocket/ itself this would never fire, and the
|
||||
// socket would be stolen from core rather than passed on.
|
||||
server!.on("upgrade", (_req, socket) => {
|
||||
claimedByCore = true;
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
await connect(`${base}/ssh/websocket/`);
|
||||
|
||||
expect(claimedByCore).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ws authentication", () => {
|
||||
it("rejects an upgrade with no token", async () => {
|
||||
ws.registerPluginWsRoute("sample-plugin", "/socket", () => {}, DECLARED);
|
||||
|
||||
const base = await startServer();
|
||||
const result = await connect(`${base}/plugin-ws/sample-plugin/socket`);
|
||||
|
||||
expect(result.outcome).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects an upgrade with a bad token", async () => {
|
||||
ws.registerPluginWsRoute("sample-plugin", "/socket", () => {}, DECLARED);
|
||||
|
||||
const base = await startServer();
|
||||
const result = await connect(
|
||||
`${base}/plugin-ws/sample-plugin/socket`,
|
||||
authProtocols("nope"),
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe(401);
|
||||
});
|
||||
|
||||
it("serves a public route without a token", async () => {
|
||||
ws.registerPluginWsRoute(
|
||||
"sample-plugin",
|
||||
"/guest",
|
||||
({ socket }) => (socket as WebSocket).send("hi"),
|
||||
DECLARED,
|
||||
{ public: true },
|
||||
);
|
||||
|
||||
const base = await startServer();
|
||||
const result = await connect(`${base}/plugin-ws/sample-plugin/guest`);
|
||||
|
||||
expect(result.outcome).toBe("open");
|
||||
if (result.outcome === "open") result.socket.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ws capability", () => {
|
||||
it("refuses the upgrade when network:serve is not granted", async () => {
|
||||
ws.registerPluginWsRoute("sample-plugin", "/socket", () => {}, DECLARED);
|
||||
state.granted.delete("network:serve");
|
||||
|
||||
const base = await startServer();
|
||||
const result = await connect(
|
||||
`${base}/plugin-ws/sample-plugin/socket`,
|
||||
authProtocols(state.validToken),
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ws disposal", () => {
|
||||
it("closes live sockets and stops routing on deactivate", async () => {
|
||||
const dispose = ws.registerPluginWsRoute(
|
||||
"sample-plugin",
|
||||
"/socket",
|
||||
() => {},
|
||||
DECLARED,
|
||||
);
|
||||
|
||||
const base = await startServer();
|
||||
const result = await connect(
|
||||
`${base}/plugin-ws/sample-plugin/socket`,
|
||||
authProtocols(state.validToken),
|
||||
);
|
||||
expect(result.outcome).toBe("open");
|
||||
|
||||
const closed = new Promise<number>((resolve) => {
|
||||
if (result.outcome === "open") {
|
||||
result.socket.once("close", (code) => resolve(code));
|
||||
}
|
||||
});
|
||||
|
||||
dispose();
|
||||
|
||||
// A live terminal must not outlive the plugin that owns it.
|
||||
expect(await closed).toBeGreaterThan(0);
|
||||
|
||||
const afterDispose = await connect(
|
||||
`${base}/plugin-ws/sample-plugin/socket`,
|
||||
authProtocols(state.validToken),
|
||||
);
|
||||
expect(afterDispose.outcome).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ws raw upgrades", () => {
|
||||
it("authenticates before handing the socket to the plugin", async () => {
|
||||
const handled: string[] = [];
|
||||
ws.registerPluginWsUpgrade(
|
||||
"sample-plugin",
|
||||
"/raw",
|
||||
(_request, socket, _head, userId) => {
|
||||
handled.push(userId);
|
||||
(socket as { destroy: () => void }).destroy();
|
||||
},
|
||||
DECLARED,
|
||||
);
|
||||
|
||||
const base = await startServer();
|
||||
|
||||
const unauthenticated = await connect(
|
||||
`${base}/plugin-ws/sample-plugin/raw`,
|
||||
);
|
||||
expect(unauthenticated.outcome).toBe(401);
|
||||
// The plugin never saw the unauthenticated attempt.
|
||||
expect(handled).toEqual([]);
|
||||
|
||||
await connect(
|
||||
`${base}/plugin-ws/sample-plugin/raw`,
|
||||
authProtocols(state.validToken),
|
||||
);
|
||||
expect(handled).toEqual([state.userId]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePluginWsUrl", () => {
|
||||
it("only claims well-formed plugin socket paths", () => {
|
||||
expect(ws.parsePluginWsUrl("/plugin-ws/docker/console")).toEqual({
|
||||
pluginId: "docker",
|
||||
path: "/console",
|
||||
});
|
||||
expect(ws.parsePluginWsUrl("/plugin-ws/docker/console?x=1")).toEqual({
|
||||
pluginId: "docker",
|
||||
path: "/console",
|
||||
});
|
||||
expect(ws.parsePluginWsUrl("/ssh/websocket/")).toBeNull();
|
||||
expect(ws.parsePluginWsUrl("/plugin-ws/docker")).toBeNull();
|
||||
expect(ws.parsePluginWsUrl(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,15 @@
|
||||
/**
|
||||
* dispatchLoginEvent used to POST to the metrics service on localhost:30005
|
||||
* with an internal auth token. Host metrics is a plugin now and has no port,
|
||||
* so it publishes on the plugin event bus and the plugin subscribes.
|
||||
*
|
||||
* What is asserted changed with it: the payload a subscriber receives, and
|
||||
* that a throwing subscriber cannot fail the login that caused the event.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { dispatchLoginEvent } from "../../utils/login-event-dispatch.js";
|
||||
import { SystemCrypto } from "../../utils/system-crypto.js";
|
||||
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
|
||||
describe("dispatchLoginEvent", () => {
|
||||
@@ -8,37 +17,24 @@ describe("dispatchLoginEvent", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("reports a rejected metrics-service request", async () => {
|
||||
vi.spyOn(SystemCrypto, "getInstance").mockReturnValue({
|
||||
getInternalAuthToken: vi.fn().mockResolvedValue("internal-token"),
|
||||
} as never);
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response('{"error":"Missing authentication token"}', {
|
||||
status: 401,
|
||||
}),
|
||||
);
|
||||
const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {});
|
||||
it("publishes the login details a subscriber needs", async () => {
|
||||
const received: unknown[] = [];
|
||||
const unsubscribe = pluginEvents.on(TOPICS.hostLogin, (payload) => {
|
||||
received.push(payload);
|
||||
});
|
||||
|
||||
await dispatchLoginEvent(7, "user-1", "root", "192.0.2.1");
|
||||
try {
|
||||
await dispatchLoginEvent(42, "user-1", "root", "10.0.0.5");
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"Failed to dispatch login event",
|
||||
expect.objectContaining({
|
||||
operation: "login_event_dispatch_error",
|
||||
hostId: 7,
|
||||
error:
|
||||
'Metrics service returned 401: {"error":"Missing authentication token"}',
|
||||
}),
|
||||
);
|
||||
expect(received).toEqual([
|
||||
{ hostId: 42, userId: "user-1", sshUser: "root", fromIp: "10.0.0.5" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not log a warning when the metrics service accepts the event", async () => {
|
||||
vi.spyOn(SystemCrypto, "getInstance").mockReturnValue({
|
||||
getInternalAuthToken: vi.fn().mockResolvedValue("internal-token"),
|
||||
} as never);
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response('{"ok":true}', { status: 200 }),
|
||||
);
|
||||
it("does not log a warning on the happy path", async () => {
|
||||
const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {});
|
||||
|
||||
await dispatchLoginEvent(7, "user-1", "root", "192.0.2.1");
|
||||
@@ -46,49 +42,27 @@ describe("dispatchLoginEvent", () => {
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends the internal auth token and login details the metrics service expects", async () => {
|
||||
vi.spyOn(SystemCrypto, "getInstance").mockReturnValue({
|
||||
getInternalAuthToken: vi.fn().mockResolvedValue("internal-token"),
|
||||
} as never);
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response('{"ok":true}', { status: 200 }));
|
||||
it("never sends the event over the network", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
|
||||
await dispatchLoginEvent(42, "user-1", "root", "10.0.0.5");
|
||||
await dispatchLoginEvent(7, "user-1", "root", "192.0.2.1");
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"http://localhost:30005/internal/login-alert",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"x-internal-auth": "internal-token",
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
hostId: 42,
|
||||
userId: "user-1",
|
||||
sshUser: "root",
|
||||
fromIp: "10.0.0.5",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// The whole point of the move: no service-to-service HTTP call, and no
|
||||
// internal shared secret to carry on it.
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs a warning if the fetch itself throws, instead of propagating", async () => {
|
||||
vi.spyOn(SystemCrypto, "getInstance").mockReturnValue({
|
||||
getInternalAuthToken: vi.fn().mockResolvedValue("internal-token"),
|
||||
} as never);
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(
|
||||
new Error("connect ECONNREFUSED"),
|
||||
);
|
||||
const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {});
|
||||
it("does not propagate a subscriber's throw to the caller", async () => {
|
||||
const unsubscribe = pluginEvents.on(TOPICS.hostLogin, () => {
|
||||
throw new Error("subscriber exploded");
|
||||
});
|
||||
|
||||
await expect(
|
||||
dispatchLoginEvent(1, "user-1", "root", "127.0.0.1"),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"Failed to dispatch login event",
|
||||
expect.objectContaining({ hostId: 1 }),
|
||||
);
|
||||
try {
|
||||
await expect(
|
||||
dispatchLoginEvent(1, "user-1", "root", "127.0.0.1"),
|
||||
).resolves.toBeUndefined();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,14 @@
|
||||
import { SystemCrypto } from "./system-crypto.js";
|
||||
import { pluginEvents, TOPICS } from "../plugins/events.js";
|
||||
import { sshLogger } from "./logger.js";
|
||||
|
||||
const METRICS_SERVICE_URL = "http://localhost:30005";
|
||||
|
||||
/**
|
||||
* Announces an SSH login so anything watching can react to it.
|
||||
*
|
||||
* Was an HTTP POST to the metrics service on localhost:30005. Host metrics is
|
||||
* a plugin now and has no port, so this publishes on the plugin event bus and
|
||||
* the plugin subscribes. Fire and forget: a subscriber that throws must never
|
||||
* fail the login that caused it.
|
||||
*/
|
||||
export async function dispatchLoginEvent(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
@@ -10,24 +16,7 @@ export async function dispatchLoginEvent(
|
||||
fromIp: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const token = await SystemCrypto.getInstance().getInternalAuthToken();
|
||||
const response = await fetch(
|
||||
`${METRICS_SERVICE_URL}/internal/login-alert`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-internal-auth": token,
|
||||
},
|
||||
body: JSON.stringify({ hostId, userId, sshUser, fromIp }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const details = await response.text();
|
||||
throw new Error(
|
||||
`Metrics service returned ${response.status}${details ? `: ${details}` : ""}`,
|
||||
);
|
||||
}
|
||||
pluginEvents.emit(TOPICS.hostLogin, { hostId, userId, sshUser, fromIp });
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to dispatch login event", {
|
||||
operation: "login_event_dispatch_error",
|
||||
|
||||
@@ -30,18 +30,10 @@ const swaggerOptions: SwaggerJSDocOptions = {
|
||||
url: "http://localhost:30004",
|
||||
description: "SSH file manager server",
|
||||
},
|
||||
{
|
||||
url: "http://localhost:30005",
|
||||
description: "Server statistics and monitoring server",
|
||||
},
|
||||
{
|
||||
url: "http://localhost:30006",
|
||||
description: "Dashboard server",
|
||||
},
|
||||
{
|
||||
url: "http://localhost:30007",
|
||||
description: "Docker management server",
|
||||
},
|
||||
{
|
||||
url: "http://localhost:30011",
|
||||
description: "Serial connection server",
|
||||
|
||||
Reference in new issue
Block a user