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

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

No files matched your search

+24 -18
View File
@@ -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),
+26 -42
View File
@@ -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);
}