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

+1
View File
@@ -23,6 +23,7 @@
"secrets:own",
"network:outbound",
"audit:read",
"network:serve",
"ui:surface"
],
"dependencies": {
+2 -2
View File
@@ -3,7 +3,7 @@ import { startAiService, stopAiService } from "./routes.js";
import { createCurrentAiRepository } from "../../../../src/backend/database/repositories/factory.js";
export async function activate(ctx: PluginContext) {
startAiService();
startAiService(ctx.http.router());
// Offered by reference, not copied: this hands over a resolver, so the key
// stays ours. Rotating or deleting the provider takes effect on the next
@@ -24,7 +24,7 @@ export async function activate(ctx: PluginContext) {
return withSecret?.apiKey ?? null;
});
ctx.log.info("AI assistant router registered at /ai");
ctx.log.info("AI assistant routes mounted at /plugin-api/ai");
}
export async function deactivate() {
+5 -9
View File
@@ -1,9 +1,5 @@
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import express from "express";
import {
registerAiRouter,
unregisterAiRouter,
} from "../../../../src/backend/database/routes/ai-dispatch.js";
import express, { type Router } from "express";
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
@@ -1339,12 +1335,12 @@ router.post(
},
);
/** Called from activate(). Mounts this router at /ai via the shared dispatcher. */
export function startAiService(): void {
registerAiRouter(router);
/** Called from activate(). Mounts this router at /plugin-api/ai. */
export function startAiService(mountOn: Router): void {
mountOn.use(router);
}
/** Called from deactivate(). /ai/* falls back to 404 until reactivated. */
export function stopAiService(): void {
unregisterAiRouter();
// Unmounted by the runtime when the plugin deactivates.
}
+1
View File
@@ -23,6 +23,7 @@
"events:core",
"notify:send",
"network:outbound",
"network:serve",
"ui:surface"
],
"locales": "locales",
+6 -2
View File
@@ -6,10 +6,14 @@ import {
} from "./scheduler.js";
export async function activate(ctx: PluginContext) {
startAutomationsService();
startAutomationsService(
// The webhook route authenticates on its own per-automation token rather
// than a session, which is the point of an inbound webhook.
ctx.http.router({ public: ["/webhook/:token"] }),
);
startAutomationScheduler();
ctx.log.info(
"Automations router registered at /automations; scheduler started",
"Automations routes mounted at /plugin-api/automations; scheduler started",
);
}
+4 -8
View File
@@ -1,5 +1,5 @@
import crypto from "node:crypto";
import express, { type Request, type Response } from "express";
import express, { type Request, type Response, type Router } from "express";
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
import type {
AutomationDefinition,
@@ -19,10 +19,6 @@ import { createCurrentAutomationRepository } from "../../../../src/backend/datab
import type { AutomationRow } from "../../../../src/backend/database/repositories/automation-repository.js";
import { AutomationEngine } from "./engine.js";
import { computeNextDueAt, isValidCron, isValidTimezone } from "./cron.js";
import {
registerAutomationsRouter,
unregisterAutomationsRouter,
} from "../../../../src/backend/database/routes/automation-dispatch.js";
export const router = express.Router();
@@ -821,12 +817,12 @@ function timingSafeEqual(a: string, b: string): boolean {
/** Called from activate(). Mounts this router at /automations via the shared
* dispatcher. */
export function startAutomationsService(): void {
registerAutomationsRouter(router);
export function startAutomationsService(mountOn: Router): void {
mountOn.use(router);
}
/** Called from deactivate(). /automations/* falls back to 404 until
* reactivated. */
export function stopAutomationsService(): void {
unregisterAutomationsRouter();
// Unmounted by the runtime when the plugin deactivates.
}
@@ -75,7 +75,7 @@ export interface AutomationInput {
export async function listAutomations(): Promise<AutomationRow[]> {
try {
return (await authApi.get("/automations")).data;
return (await authApi.get("/plugin-api/automations")).data;
} catch (error) {
throw handleApiError(error, "fetch automations");
}
@@ -85,7 +85,7 @@ export async function createAutomation(
input: AutomationInput,
): Promise<AutomationRow> {
try {
return (await authApi.post("/automations", input)).data;
return (await authApi.post("/plugin-api/automations", input)).data;
} catch (error) {
throw handleApiError(error, "create automation");
}
@@ -96,7 +96,7 @@ export async function updateAutomation(
input: Partial<AutomationInput>,
): Promise<AutomationRow> {
try {
return (await authApi.put(`/automations/${id}`, input)).data;
return (await authApi.put(`/plugin-api/automations/${id}`, input)).data;
} catch (error) {
throw handleApiError(error, "update automation");
}
@@ -104,7 +104,7 @@ export async function updateAutomation(
export async function deleteAutomation(id: number): Promise<void> {
try {
await authApi.delete(`/automations/${id}`);
await authApi.delete(`/plugin-api/automations/${id}`);
} catch (error) {
throw handleApiError(error, "delete automation");
}
@@ -115,7 +115,8 @@ export async function runAutomation(
options: { dryRun?: boolean } = {},
): Promise<AutomationRunOutcome> {
try {
return (await authApi.post(`/automations/${id}/run`, options)).data;
return (await authApi.post(`/plugin-api/automations/${id}/run`, options))
.data;
} catch (error) {
throw handleApiError(error, "run automation");
}
@@ -125,8 +126,11 @@ export async function listAutomationRuns(
options: { automationId?: number; limit?: number; offset?: number } = {},
): Promise<AutomationRunRow[]> {
try {
return (await authApi.get("/automations/runs/history", { params: options }))
.data;
return (
await authApi.get("/plugin-api/automations/runs/history", {
params: options,
})
).data;
} catch (error) {
throw handleApiError(error, "fetch automation runs");
}
@@ -136,7 +140,8 @@ export async function listAutomationRunSteps(
runId: number,
): Promise<AutomationRunStepRow[]> {
try {
return (await authApi.get(`/automations/runs/${runId}/steps`)).data;
return (await authApi.get(`/plugin-api/automations/runs/${runId}/steps`))
.data;
} catch (error) {
throw handleApiError(error, "fetch run steps");
}
+46 -33
View File
@@ -45,14 +45,14 @@ interface SSHSession {
const activeSessions = new Map<string, SSHSession>();
const CONSOLE_PORT = 30009;
/**
* Created by startConsoleServer(), not at module load.
*
* Importing this file used to bind the port as a side effect, which meant the
* Importing this file used to bind a port as a side effect, which meant the
* plugin could not be disabled and re-enabled: the module stays in the ESM
* cache, so the second activate() got a server that had already been closed.
* There is no port now -- core routes /plugin-ws/docker/console here -- but
* the same lifecycle applies to the server object itself.
*/
let wss: WebSocketServer | null = null;
@@ -60,40 +60,46 @@ function handleConnection(ws: WebSocket, req: import("http").IncomingMessage) {
void onConsoleConnection(ws, req);
}
/** Starts the console WebSocket server. Call from the plugin's activate(). */
/** Builds the console WebSocket server. Call from the plugin's activate(). */
export function startConsoleServer(): Promise<void> {
if (wss) return Promise.resolve();
return new Promise((resolve, reject) => {
const server = new WebSocketServer({
host: "127.0.0.1",
port: CONSOLE_PORT,
maxPayload: MAX_WS_MESSAGE_BYTES,
const server = new WebSocketServer({
noServer: true,
maxPayload: MAX_WS_MESSAGE_BYTES,
});
// One bad socket must not take the server down.
server.on("error", (error) => {
sshLogger.error("Docker console WebSocket server error", error, {
operation: "wss_error",
});
});
server.on("connection", handleConnection);
const onStartupError = (error: Error) => {
server.off("listening", onListening);
wss = null;
reject(error);
};
wss = server;
return Promise.resolve();
}
const onListening = () => {
server.off("error", onStartupError);
// Past startup an error is logged rather than fatal: one bad socket
// must not take the server down.
server.on("error", (error) => {
sshLogger.error("Docker console WebSocket server error", error, {
operation: "wss_error",
});
});
resolve();
};
server.once("error", onStartupError);
server.once("listening", onListening);
server.on("connection", handleConnection);
wss = server;
/**
* Hands one upgrade to the console server.
*
* Registered as a public ctx.ws route because onConsoleConnection does its own
* per-message authentication, which is where the container and host checks
* live too.
*/
export function handleConsoleUpgrade(
request: import("http").IncomingMessage,
socket: import("stream").Duplex,
head: Buffer,
): void {
const server = wss;
if (!server) {
socket.destroy();
return;
}
server.handleUpgrade(request, socket, head, (ws) => {
server.emit("connection", ws, request);
});
}
@@ -870,8 +876,8 @@ async function onConsoleConnection(
}
/**
* Closes every live console session and the WebSocket server, freeing port
* 30009. Called from the plugin's deactivate().
* Closes every live console session and the WebSocket server. Called from the
* plugin's deactivate().
*
* There is deliberately no process signal handler here. Core owns shutdown:
* gracefulShutdown in src/backend/starter.ts calls shutdownPlugins(), which
@@ -894,5 +900,12 @@ export function closeConsoleServer(): Promise<void> {
if (!server) return Promise.resolve();
server.off("connection", handleConnection);
for (const client of server.clients) {
try {
client.close(1001, "Docker plugin disabled");
} catch {
// Already gone.
}
}
return new Promise((resolve) => server.close(() => resolve()));
}
+28 -37
View File
@@ -1,9 +1,5 @@
import { createServer, type Server } from "node:http";
import express from "express";
import cookieParser from "cookie-parser";
import express, { type Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { createCorsMiddleware } from "../../../../src/backend/utils/cors-config.js";
import { createCompressionMiddleware } from "../../../../src/backend/utils/compression-config.js";
import { logger } from "../../../../src/backend/utils/logger.js";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { registerDockerContainerRoutes } from "./container-routes.js";
@@ -17,25 +13,21 @@ import {
getRequestUserId,
registerDockerSshRoutes,
} from "./routes.js";
import { startConsoleServer, closeConsoleServer } from "./console.js";
import {
closeConsoleServer,
handleConsoleUpgrade,
startConsoleServer,
} from "./console.js";
const PORT = 30007;
let httpServer: Server | null = null;
let authManagerInstance: ReturnType<typeof AuthManager.getInstance> | null =
null;
export async function activate(ctx: PluginContext) {
// An express app rather than the router directly, so the existing
// register*Routes helpers keep the Application they expect. Core mounts it
// at /plugin-api/docker and runs compression, CORS, cookies, auth and body
// parsing in front of it.
const app = express();
app.set("trust proxy", "loopback");
app.use(createCompressionMiddleware());
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
app.use(cookieParser());
authManagerInstance = AuthManager.getInstance();
app.use(authManagerInstance.createAuthMiddleware());
app.use(express.json({ limit: "100mb" }));
app.use(express.urlencoded({ limit: "100mb", extended: true }));
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
@@ -50,16 +42,9 @@ export async function activate(ctx: PluginContext) {
dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
});
const server = createServer(app);
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(PORT, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
httpServer = server;
ctx.http.router<Router>({ bodyLimit: "100mb" }).use(app);
authManagerInstance = AuthManager.getInstance();
try {
await authManagerInstance.initialize();
} catch (err) {
@@ -68,21 +53,27 @@ export async function activate(ctx: PluginContext) {
});
}
// The console server is started here rather than on import so disabling the
// plugin frees port 30009 and re-enabling can take it again.
await startConsoleServer();
ctx.log.info(`Docker plugin listening on ${PORT} (console on 30009)`);
// Public because the console authenticates each session over the socket
// itself, where the container and host checks also live.
ctx.ws.upgrade(
"/console",
(request, socket, head) =>
handleConsoleUpgrade(
request as Parameters<typeof handleConsoleUpgrade>[0],
socket as Parameters<typeof handleConsoleUpgrade>[1],
head as Buffer,
),
{ public: true },
);
ctx.log.info(
"Docker mounted at /plugin-api/docker and /plugin-ws/docker/console",
);
}
export async function deactivate() {
await closeConsoleServer();
if (httpServer) {
const server = httpServer;
httpServer = null;
await new Promise<void>((resolve) => server.close(() => resolve()));
}
authManagerInstance = null;
}
@@ -9,11 +9,8 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { Button } from "@/components/button.tsx";
import { Select2 } from "@/components/select2";
import { Card, CardContent } from "@/components/card.tsx";
import { getBasePath } from "@/lib/base-path";
import {
resolveConnectionOrigin,
buildOriginWsUrl,
} from "@/lib/connection-origin.ts";
import { resolveConnectionOrigin } from "@/lib/connection-origin.ts";
import { pluginWsUrl } from "@/lib/plugin-transport";
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
import { toast } from "sonner";
import type { SSHHost } from "@/types";
@@ -299,40 +296,21 @@ function ConsoleTerminalInner({
const isElectronApp = isElectron();
const isDev =
!isElectronApp &&
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "");
const origin = isElectronApp
? await resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: hostConfig.connectionOrigin,
})
: "local";
let baseWsUrl: string;
let wsProtocols: string[] = [];
if (isDev) {
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`;
} else if (isElectronApp) {
const origin = await resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: hostConfig.connectionOrigin,
});
const resolvedUrl = await buildOriginWsUrl({
origin,
localPort: 30009,
localPath: "/docker/console/",
remotePath: "/docker/console/",
});
if (!resolvedUrl) {
setIsConnecting(false);
toast.error(t("errors.remoteServerRequired"));
return;
}
baseWsUrl = resolvedUrl.url;
wsProtocols = resolvedUrl.protocols;
} else {
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`;
const resolvedUrl = await pluginWsUrl("docker", "/console", { origin });
if (!resolvedUrl) {
setIsConnecting(false);
toast.error(t("errors.remoteServerRequired"));
return;
}
const ws = new WebSocket(baseWsUrl, wsProtocols);
const ws = new WebSocket(resolvedUrl.url, resolvedUrl.protocols);
ws.onopen = () => {
const cols = terminal.cols || 80;
+1
View File
@@ -19,6 +19,7 @@
"credentials:use",
"ssh:connect",
"db:own",
"network:serve",
"ui:surface"
],
"locales": "locales",
+2 -2
View File
@@ -2,8 +2,8 @@ import type { PluginContext } from "@termix/plugin-sdk/backend";
import { startFleetsService, stopFleetsService } from "./routes.js";
export async function activate(ctx: PluginContext) {
startFleetsService();
ctx.log.info("Fleets router registered at /fleets");
startFleetsService(ctx.http.router());
ctx.log.info("Fleets routes mounted at /plugin-api/fleets");
}
export async function deactivate() {
+4 -8
View File
@@ -1,6 +1,6 @@
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
import express, { type Request, type Response } from "express";
import express, { type Request, type Response, type Router } from "express";
import multer from "multer";
import JSZip from "jszip";
import type { Client, SFTPWrapper } from "ssh2";
@@ -40,10 +40,6 @@ import {
import { buildPackageActionCommand } from "../../../../src/backend/hosts/metrics-shared/package-commands.js";
import { isValidPackageName } from "../../../../src/backend/hosts/metrics-shared/validation.js";
import { resolveSnippetCommand } from "../../../../src/backend/database/routes/snippets-execution.js";
import {
registerFleetsRouter,
unregisterFleetsRouter,
} from "../../../../src/backend/database/routes/fleet-dispatch.js";
export const router = express.Router();
@@ -1505,11 +1501,11 @@ export function buildRemoveCommand(
/** Called from activate(). Mounts this router at /fleets via the shared
* dispatcher. */
export function startFleetsService(): void {
registerFleetsRouter(router);
export function startFleetsService(mountOn: Router): void {
mountOn.use(router);
}
/** Called from deactivate(). /fleets/* falls back to 404 until reactivated. */
export function stopFleetsService(): void {
unregisterFleetsRouter();
// Unmounted by the runtime when the plugin deactivates.
}
+23 -12
View File
@@ -1,20 +1,31 @@
import type { Server } from "node:http";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { PORT, shutdown, startHostMetricsService } from "./routes.js";
let httpServer: Server | null = null;
import {
onHostDeleted,
onHostLogin,
onHostUpdated,
shutdown,
startHostMetricsService,
} from "./routes.js";
export async function activate(ctx: PluginContext) {
httpServer = startHostMetricsService();
ctx.log.info(`Host Metrics listening on ${PORT ?? 30005}`);
await startHostMetricsService(ctx.http.router());
// Core used to POST these to this plugin's own port with an internal auth
// token. They are events now: no port, no shared secret, and the
// subscriptions are torn down with the plugin.
ctx.events.on("host.updated", (payload) => {
void onHostUpdated(payload as Parameters<typeof onHostUpdated>[0]);
});
ctx.events.on("host.deleted", (payload) => {
onHostDeleted(payload as Parameters<typeof onHostDeleted>[0]);
});
ctx.events.on("host.login", (payload) => {
onHostLogin(payload as Parameters<typeof onHostLogin>[0]);
});
ctx.log.info("Host Metrics mounted at /plugin-api/host-metrics");
}
export async function deactivate() {
shutdown();
if (httpServer) {
const server = httpServer;
httpServer = null;
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
+62 -167
View File
@@ -1,10 +1,7 @@
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import { usesIssuedCertificate } from "../../../../src/backend/hosts/issued-certificate-auth.js";
import express from "express";
import express, { type Router } from "express";
import net from "net";
import { createCorsMiddleware } from "../../../../src/backend/utils/cors-config.js";
import { createCompressionMiddleware } from "../../../../src/backend/utils/compression-config.js";
import cookieParser from "cookie-parser";
import { Client, type ConnectConfig } from "ssh2";
import { SSH_ALGORITHMS } from "../../../../src/backend/utils/ssh-algorithms.js";
import {
@@ -116,7 +113,6 @@ import {
requestQueue,
statusPollLimiter,
} from "../../../../src/backend/hosts/metrics-shared/state.js";
import { listenOnServicePort } from "../../../../src/backend/utils/service-listen.js";
const authManager = AuthManager.getInstance();
const permissionManager = PermissionManager.getInstance();
@@ -1075,52 +1071,18 @@ function validateHostId(
next();
}
// Mounted onto the router core hands this plugin at /plugin-api/host-metrics,
// rather than listening on a port of its own. Compression, CORS, cookies and
// body parsing all run in core's stack in front of this, so only the
// cache header and the routes themselves stay here.
export const app = express();
app.set("trust proxy", "loopback");
app.use(createCompressionMiddleware());
app.use(createCorsMiddleware());
app.use(cookieParser());
app.use(express.json({ limit: "1mb" }));
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
// Internal endpoint — only accepts calls from localhost. Registered before
// the auth middleware since it's a service-to-service call authenticated by
// IP + shared secret, not a user JWT.
// Used by the main backend to notify the metrics service of SSH login events.
app.post("/internal/login-alert", async (req, res) => {
const remoteIp = req.socket.remoteAddress;
if (
remoteIp !== "127.0.0.1" &&
remoteIp !== "::1" &&
remoteIp !== "::ffff:127.0.0.1"
) {
return res.status(403).json({ error: "Forbidden" });
}
const systemCrypto = (
await import("../../../../src/backend/utils/system-crypto.js")
).SystemCrypto;
const expectedToken = await systemCrypto.getInstance().getInternalAuthToken();
const token = req.headers["x-internal-auth"];
if (!token || token !== expectedToken) {
return res.status(403).json({ error: "Forbidden" });
}
const { hostId, userId, sshUser, fromIp } = req.body as {
hostId: number;
userId: string;
sshUser: string;
fromIp: string;
};
notifyAutomationInternalEvent("user_login", userId, hostId, {
sshUser,
fromIp,
});
res.json({ ok: true });
});
app.use(authManager.createAuthMiddleware());
// Core authenticates every /plugin-api request before it reaches this app.
const requireAdmin = authManager.createAdminMiddleware();
async function fetchAllHosts(
@@ -2163,124 +2125,68 @@ app.post("/refresh", async (req, res) => {
});
/**
* @openapi
* /host-updated:
* post:
* summary: Start polling for updated host
* description: Starts polling for a specific host after it has been updated.
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* responses:
* 200:
* description: Host polling started.
* 400:
* description: Invalid hostId.
* 401:
* description: Session expired - please log in again.
* 404:
* description: Host not found.
* 500:
* description: Failed to start polling.
* Reacts to a host being created or updated somewhere in core.
*
* Used to be POST /host-updated on this plugin's own port. An event now, so
* there is no service-to-service HTTP call and no port. The acting user comes
* with the event because re-reading a host needs their data key.
*/
app.post("/host-updated", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId } = req.body;
export async function onHostUpdated(payload: {
hostId: number;
userId: string;
}): Promise<void> {
const { hostId, userId } = payload;
if (!hostId || !userId) return;
if (DataCrypto.getUserDataKey(userId) === null) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!hostId || typeof hostId !== "number") {
return res.status(400).json({ error: "Invalid hostId" });
}
// No data key means that user is not unlocked on this process, so there is
// nothing to decrypt the host with. Polling resumes when they next log in.
if (DataCrypto.getUserDataKey(userId) === null) return;
try {
hostPollCache.invalidate(hostId);
const host = await fetchHostById(hostId, userId);
if (host) {
connectionPool.clearKeyConnections(getPoolKey(host));
if (!host) return;
await pollingManager.startPollingForHost(host);
res.json({ message: "Host polling started" });
} else {
res.status(404).json({ error: "Host not found" });
}
connectionPool.clearKeyConnections(getPoolKey(host));
await pollingManager.startPollingForHost(host);
} catch (error) {
statsLogger.error("Failed to start polling for host", error, {
operation: "host_updated",
hostId,
userId,
});
res.status(500).json({ error: "Failed to start polling" });
}
});
}
/**
* @openapi
* /host-deleted:
* post:
* summary: Stop polling for deleted host
* description: Stops polling for a specific host after it has been deleted.
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* responses:
* 200:
* description: Host polling stopped.
* 400:
* description: Invalid hostId.
* 401:
* description: Session expired - please log in again.
* 500:
* description: Failed to stop polling.
*/
app.post("/host-deleted", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId } = req.body;
if (DataCrypto.getUserDataKey(userId) === null) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!hostId || typeof hostId !== "number") {
return res.status(400).json({ error: "Invalid hostId" });
}
/** Reacts to a host being deleted. Was POST /host-deleted. */
export function onHostDeleted(payload: { hostId: number }): void {
const { hostId } = payload;
if (!hostId) return;
try {
pollingManager.stopPollingForHost(hostId, true);
res.json({ message: "Host polling stopped" });
} catch (error) {
statsLogger.error("Failed to stop polling for host", error, {
operation: "host_deleted",
hostId,
userId,
});
res.status(500).json({ error: "Failed to stop polling" });
}
});
}
/** Reacts to an SSH login. Was the internal POST /internal/login-alert. */
export function onHostLogin(payload: {
hostId: number;
userId: string;
sshUser: string;
fromIp: string;
}): void {
const { hostId, userId, sshUser, fromIp } = payload;
if (!hostId || !userId) return;
notifyAutomationInternalEvent("user_login", userId, hostId, {
sshUser,
fromIp,
});
}
/**
* @openapi
@@ -3147,41 +3053,30 @@ registerManagerRoutes(app, {
},
});
export const PORT = 30005;
let cleanupInterval: NodeJS.Timeout | undefined;
/**
* Starts the Host Metrics HTTP service. Called from the plugin's activate()
* instead of running at module scope, so enabling/disabling the plugin
* actually starts and stops the listener.
* Mounts the Host Metrics routes on the router core provides. Called from the
* plugin's activate(); there is no port to bind any more.
*/
export function startHostMetricsService(): ReturnType<
typeof listenOnServicePort
> {
return listenOnServicePort({
app,
port: PORT,
logger: statsLogger,
serviceName: "metrics",
onListening: async () => {
try {
await authManager.initialize();
} catch (err) {
statsLogger.error("Failed to initialize AuthManager", err, {
operation: "auth_init_error",
});
}
export async function startHostMetricsService(mountOn: Router): Promise<void> {
mountOn.use(app);
cleanupInterval = setInterval(
() => {
authFailureTracker.cleanup();
pollingBackoff.cleanup();
},
10 * 60 * 1000,
);
try {
await authManager.initialize();
} catch (err) {
statsLogger.error("Failed to initialize AuthManager", err, {
operation: "auth_init_error",
});
}
cleanupInterval = setInterval(
() => {
authFailureTracker.cleanup();
pollingBackoff.cleanup();
},
});
10 * 60 * 1000,
);
}
/**
@@ -1,94 +0,0 @@
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import ts from "typescript";
import { beforeEach, expect, it, vi } from "vitest";
// Load the production route without starting metrics polling or SSH services.
const source = readFileSync(
new URL("../../src/backend/routes.ts", import.meta.url),
"utf8",
);
const ast = ts.createSourceFile(
"routes.ts",
source,
ts.ScriptTarget.Latest,
true,
);
const route = ast.statements.find(
(node) =>
ts.isExpressionStatement(node) &&
ts.isCallExpression(node.expression) &&
node.expression.expression.getText(ast) === "app.post" &&
node.expression.arguments[0]?.getText(ast) === '"/internal/login-alert"',
);
if (!route) throw new Error("Login event route is missing");
const notify = vi.fn();
let handler: (req: unknown, res: unknown) => Promise<void>;
runInNewContext(
ts.transpileModule(route.getText(ast), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText,
{
app: {
post: (_path: string, callback: typeof handler) => {
handler = callback;
},
},
require: () => ({
SystemCrypto: {
getInstance: () => ({
getInternalAuthToken: async () => "internal-test-token",
}),
},
}),
notifyAutomationInternalEvent: notify,
},
);
const body = {
hostId: 42,
userId: "owner",
sshUser: "alice",
fromIp: "192.0.2.4",
};
const response = () => ({ status: vi.fn().mockReturnThis(), json: vi.fn() });
beforeEach(() => vi.clearAllMocks());
it("delivers successful SSH login details to automations", async () => {
const res = response();
await handler(
{
socket: { remoteAddress: "127.0.0.1" },
headers: { "x-internal-auth": "internal-test-token" },
body,
},
res,
);
expect(notify).toHaveBeenCalledExactlyOnceWith("user_login", "owner", 42, {
sshUser: "alice",
fromIp: "192.0.2.4",
});
expect(res.json).toHaveBeenCalledWith({ ok: true });
});
it.each([
["192.0.2.9", "internal-test-token"],
["127.0.0.1", "incorrect"],
])(
"rejects untrusted login notifications (%s)",
async (remoteAddress, token) => {
const res = response();
await handler(
{
socket: { remoteAddress },
headers: { "x-internal-auth": token },
body,
},
res,
);
expect(res.status).toHaveBeenCalledWith(403);
expect(notify).not.toHaveBeenCalled();
},
);
@@ -1,27 +0,0 @@
import { describe, it, expect } from "vitest";
import fs from "fs";
import path from "path";
// Regression guard for: the /internal/login-alert route was registered
// after the global JWT auth middleware, so every service-to-service login
// alert got rejected with 401 before the route's own IP+token check ever
// ran. Spinning up the full metrics-service Express app (DB, SSH clients,
// polling managers, etc.) just to hit this one route is out of scope, so
// this asserts the registration order directly against the source instead.
describe("metrics service /internal/login-alert route order", () => {
it("is registered before the global auth middleware", () => {
const source = fs.readFileSync(
path.resolve(__dirname, "../../src/backend/routes.ts"),
"utf8",
);
const routeIndex = source.indexOf('app.post("/internal/login-alert"');
const authMiddlewareIndex = source.indexOf(
"app.use(authManager.createAuthMiddleware())",
);
expect(routeIndex).toBeGreaterThan(-1);
expect(authMiddlewareIndex).toBeGreaterThan(-1);
expect(routeIndex).toBeLessThan(authMiddlewareIndex);
});
});
@@ -0,0 +1,84 @@
/**
* SSH login alerts reach automations.
*
* This used to be POST /internal/login-alert on this plugin's own port,
* guarded by a localhost IP check plus a shared internal token, and the tests
* here covered both of those plus the middleware ordering bug that once made
* every alert 401. A4 replaced the call with an in-process event, so the
* transport, the shared secret and the ordering hazard are all gone; what is
* left to assert is that the handler forwards the right thing and ignores a
* payload it cannot use.
*
* The route module pulls in the whole metrics stack, so this extracts the
* handler from source rather than importing it, the same way the old test did.
*/
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import ts from "typescript";
import { beforeEach, expect, it, vi } from "vitest";
const source = readFileSync(
new URL("../../src/backend/routes.ts", import.meta.url),
"utf8",
);
const ast = ts.createSourceFile(
"routes.ts",
source,
ts.ScriptTarget.Latest,
true,
);
const declaration = ast.statements.find(
(node) =>
ts.isFunctionDeclaration(node) && node.name?.getText(ast) === "onHostLogin",
);
if (!declaration) throw new Error("onHostLogin is missing");
const notify = vi.fn();
const context: Record<string, unknown> = {
notifyAutomationInternalEvent: notify,
exports: {},
};
runInNewContext(
ts.transpileModule(declaration.getText(ast), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText,
context,
);
const onHostLogin = context.onHostLogin as (payload: {
hostId: number;
userId: string;
sshUser: string;
fromIp: string;
}) => void;
beforeEach(() => vi.clearAllMocks());
it("delivers successful SSH login details to automations", () => {
onHostLogin({
hostId: 42,
userId: "owner",
sshUser: "alice",
fromIp: "192.0.2.4",
});
expect(notify).toHaveBeenCalledExactlyOnceWith("user_login", "owner", 42, {
sshUser: "alice",
fromIp: "192.0.2.4",
});
});
it.each([
["no host", { hostId: 0, userId: "owner", sshUser: "a", fromIp: "1.2.3.4" }],
["no user", { hostId: 42, userId: "", sshUser: "a", fromIp: "1.2.3.4" }],
])("ignores an unusable payload (%s)", (_label, payload) => {
onHostLogin(payload as Parameters<typeof onHostLogin>[0]);
expect(notify).not.toHaveBeenCalled();
});
+1 -1
View File
@@ -14,7 +14,7 @@
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": ["db:own", "ui:surface"],
"capabilities": ["db:own", "network:serve", "ui:surface"],
"contributes": {
"tabs": [
{
@@ -5,8 +5,10 @@ import {
} from "./routes.js";
export async function activate(ctx: PluginContext) {
startNetworkTopologyService();
ctx.log.info("Network Topology router registered at /network-topology");
startNetworkTopologyService(ctx.http.router());
ctx.log.info(
"Network Topology routes mounted at /plugin-api/network-topology",
);
}
export async function deactivate() {
@@ -1,12 +1,8 @@
import express from "express";
import express, { type Router } from "express";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
import { databaseLogger } from "../../../../src/backend/utils/logger.js";
import { createCurrentNetworkTopologyRepository } from "../../../../src/backend/database/repositories/factory.js";
import {
registerNetworkTopologyRouter,
unregisterNetworkTopologyRouter,
} from "../../../../src/backend/database/routes/network-topology-dispatch.js";
export const router = express.Router();
@@ -179,10 +175,10 @@ router.post(
},
);
export function startNetworkTopologyService(): void {
registerNetworkTopologyRouter(router);
export function startNetworkTopologyService(mountOn: Router): void {
mountOn.use(router);
}
export function stopNetworkTopologyService(): void {
unregisterNetworkTopologyRouter();
// Unmounted by the runtime when the plugin deactivates.
}
+1
View File
@@ -20,6 +20,7 @@
"credentials:use",
"ssh:connect",
"db:own",
"network:serve",
"ui:surface"
],
"contributes": {
+2 -2
View File
@@ -2,8 +2,8 @@ import type { PluginContext } from "@termix/plugin-sdk/backend";
import { startProxmoxService, stopProxmoxService } from "./routes.js";
export async function activate(ctx: PluginContext) {
startProxmoxService();
ctx.log.info("Proxmox router registered at /proxmox");
startProxmoxService(ctx.http.router());
ctx.log.info("Proxmox routes mounted at /plugin-api/proxmox");
}
export async function deactivate() {
+4 -8
View File
@@ -1,5 +1,5 @@
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import express from "express";
import express, { type Router } from "express";
import { Client as SSHClient } from "ssh2";
import { logger } from "../../../../src/backend/utils/logger.js";
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
@@ -19,10 +19,6 @@ import {
} from "./proxmox-jump-hosts.js";
import { isSafeNodeName } from "../../../../src/backend/hosts/proxmox-shared.js";
import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import {
registerProxmoxRouter,
unregisterProxmoxRouter,
} from "../../../../src/backend/database/routes/proxmox-dispatch.js";
const router = express.Router();
const proxmoxLogger = logger;
@@ -1139,8 +1135,8 @@ router.post(
* dispatcher, and starts the background auto-sync scan (a 60s interval plus
* a one-off 30s-delayed startup run, both unref'd so they never keep the
* process alive on their own). */
export function startProxmoxService(): void {
registerProxmoxRouter(router);
export function startProxmoxService(mountOn: Router): void {
mountOn.use(router);
proxmoxAutoSyncTimer = setInterval(runDueProxmoxAutoSyncs, 60 * 1000);
proxmoxAutoSyncTimer.unref?.();
proxmoxAutoSyncStartupTimer = setTimeout(runDueProxmoxAutoSyncs, 30 * 1000);
@@ -1150,7 +1146,7 @@ export function startProxmoxService(): void {
/** Called from deactivate(). /proxmox/* falls back to 404 until reactivated,
* and the auto-sync timers are cleared so a disabled plugin stops syncing. */
export function stopProxmoxService(): void {
unregisterProxmoxRouter();
// Unmounted by the runtime when the plugin deactivates.
if (proxmoxAutoSyncTimer) clearInterval(proxmoxAutoSyncTimer);
if (proxmoxAutoSyncStartupTimer) clearTimeout(proxmoxAutoSyncStartupTimer);
proxmoxAutoSyncTimer = undefined;
@@ -24,7 +24,6 @@ function readGuacdOptions(): { host: string; port: number } {
return resolveGuacdOptions(dbUrl);
}
export const GUAC_WS_PORT = 30008;
const DATA_DIR = process.env.DATA_DIR || "./db/data";
const GUACAMOLE_RECORDINGS_DIR =
process.env.GUACD_RECORDING_BACKEND_PATH ||
@@ -121,9 +120,12 @@ async function persistGuacamoleRecording(
});
}
// noServer: core owns the listening socket. guacamole-lite passes this object
// straight to ws, and exposes the WebSocketServer it builds, so the plugin can
// hand it an upgrade from /plugin-ws/remote-desktop/display. There is no
// guacamole port any more.
const websocketOptions = {
host: "127.0.0.1",
port: GUAC_WS_PORT,
noServer: true,
};
const clientOptions = {
@@ -253,13 +255,48 @@ function createGuacServer(): GuacamoleLite {
let guacServer: GuacamoleLite | null = null;
/**
* Starts the guacamole-lite WebSocket server. Called from this plugin's
* activate() -- see plugins/remote-desktop/backend/index.mjs.
* Builds the guacamole-lite server. Called from this plugin's activate().
* Nothing binds a port: handleGuacamoleUpgrade below feeds it connections.
*/
export async function startGuacamoleService(): Promise<void> {
guacServer = createGuacServer();
}
/**
* Hands one upgrade to guacamole-lite's own WebSocketServer.
*
* guacamole-lite constructs that server internally and has no "attach to this
* http server" option, so giving it the upgrade is the only way in. Core has
* already authenticated the request by the time this runs.
*/
export function handleGuacamoleUpgrade(
request: import("http").IncomingMessage,
socket: import("stream").Duplex,
head: Buffer,
): void {
const server = guacServer as unknown as {
webSocketServer?: {
handleUpgrade: (
request: import("http").IncomingMessage,
socket: import("stream").Duplex,
head: Buffer,
done: (ws: unknown) => void,
) => void;
emit: (event: string, ...args: unknown[]) => void;
};
} | null;
const wss = server?.webSocketServer;
if (!wss) {
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit("connection", ws, request);
});
}
/**
* Stops the guacamole-lite WebSocket server. Called from this plugin's
* deactivate().
+28 -10
View File
@@ -1,33 +1,51 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import {
GUAC_WS_PORT,
getGuacSessionInfo,
handleGuacamoleUpgrade,
restartGuacServer,
startGuacamoleService,
stopGuacamoleService,
tokenService,
} from "./guacamole-server.js";
import { router } from "./routes.js";
import {
registerGuacamoleBridge,
unregisterGuacamoleBridge,
} from "../../../../src/backend/database/routes/guacamole-dispatch.js";
import { startRemoteDesktopService } from "./routes.js";
export async function activate(ctx: PluginContext) {
await startGuacamoleService();
registerGuacamoleBridge({
router,
startRemoteDesktopService(ctx.http.router());
// guacamole-lite owns its own WebSocketServer, so it takes the raw upgrade.
//
// Public because a Guacamole display authenticates with the encrypted,
// single-use connection token minted by POST /token, not with a session
// JWT. guacamole-lite decrypts and validates that token itself, and the
// route that mints one is authenticated normally.
ctx.ws.upgrade(
"/display",
(request, socket, head) =>
handleGuacamoleUpgrade(
request as Parameters<typeof handleGuacamoleUpgrade>[0],
socket as Parameters<typeof handleGuacamoleUpgrade>[1],
head as Buffer,
),
{ public: true },
);
// What collab, session-sharing and the admin guacd setting need from a
// running Guacamole server. Revoked automatically on deactivate, and
// hosts/guacamole-sessions.ts degrades safely while it is gone.
ctx.registry.provide("remote-desktop.sessions", {
restart: restartGuacServer,
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) =>
tokenService.createJoinToken(guacamoleConnectionId, readOnly),
getSessionInfo: getGuacSessionInfo,
});
ctx.log.info(`Remote Desktop listening on ${GUAC_WS_PORT ?? 30008}`);
ctx.log.info(
"Remote Desktop mounted at /plugin-api/remote-desktop and /plugin-ws/remote-desktop/display",
);
}
export async function deactivate() {
unregisterGuacamoleBridge();
await stopGuacamoleService();
}
+8 -5
View File
@@ -1,10 +1,9 @@
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import express from "express";
import express, { type Router } from "express";
import { GuacamoleTokenService } from "./token-service.js";
import { withRecordingSettings } from "./recording-settings.js";
import { withDriveSettings } from "./drive-settings.js";
import { guacLogger } from "../../../../src/backend/utils/logger.js";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
import {
resolveRecipientSharedHostAuthentication,
@@ -37,10 +36,10 @@ import { createMacosVncCompatibilityProxy } from "./macos-vnc-proxy.js";
const router = express.Router();
const tokenService = GuacamoleTokenService.getInstance();
const authManager = AuthManager.getInstance();
const DATA_DIR = process.env.DATA_DIR || "./db/data";
router.use(authManager.createAuthMiddleware());
// Core authenticates every /plugin-api route before it reaches here, so the
// router no longer applies its own middleware.
router.get("/connection/:connectId", (req: AuthenticatedRequest, res) => {
if (!req.userId)
@@ -816,7 +815,7 @@ router.get("/status", async (req, res) => {
status: isConnected ? "connected" : "disconnected",
},
websocket: {
port: 30008,
path: "/plugin-ws/remote-desktop/display",
status: "running",
},
});
@@ -829,3 +828,7 @@ router.get("/status", async (req, res) => {
});
export { router };
export function startRemoteDesktopService(mountOn: Router): void {
mountOn.use(router);
}
@@ -285,11 +285,13 @@ export const GuacamoleDisplay = forwardRef<
let wsBase: string | null;
if (isElectron()) {
// includeJwt stays off: a display authenticates with the encrypted
// connection token in the query, not a session.
const target = await buildOriginWsUrl({
origin,
localPort: 30008,
localPath: "/guacamole/websocket/",
remotePath: "/guacamole/websocket/",
localPort: 30001,
localPath: "/plugin-ws/remote-desktop/display",
remotePath: "/plugin-ws/remote-desktop/display",
includeJwt: false,
});
if (!target) {
@@ -299,7 +301,6 @@ export const GuacamoleDisplay = forwardRef<
wsBase = target.url;
} else {
wsBase = buildGuacamoleWebSocketBaseUrl({
isDev,
isElectronApp: false,
isEmbeddedApp: false,
basePath: getBasePath(),
@@ -174,17 +174,20 @@ export async function getGuacamoleToken(
try {
const guacParams = toGuacamoleParams(request.guacamoleConfig);
const response = await guacamoleApi(origin).post("/guacamole/token", {
type: request.protocol,
hostname: request.hostname,
port: request.port,
username: request.username,
password: request.password,
domain: request.domain,
security: request.security,
"ignore-cert": request.ignoreCert,
...guacParams,
});
const response = await guacamoleApi(origin).post(
"/plugin-api/remote-desktop/token",
{
type: request.protocol,
hostname: request.hostname,
port: request.port,
username: request.username,
password: request.password,
domain: request.domain,
security: request.security,
"ignore-cert": request.ignoreCert,
...guacParams,
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "get guacamole token");
@@ -214,7 +217,7 @@ export async function getGuacamoleTokenFromHost(
}
const targetHostId = remoteHostId ?? hostId;
const response = await guacamoleApi(origin).post(
`/guacamole/connect-host/${targetHostId}`,
`/plugin-api/remote-desktop/connect-host/${targetHostId}`,
{
...(protocol ? { protocol } : {}),
...(promptedCredentials?.username
@@ -237,7 +240,9 @@ export async function getGuacamoleTokenFromHost(
export async function getGuacdStatus(origin: ConnectionOrigin): Promise<{
guacd: { status: string };
}> {
const response = await guacamoleApi(origin).get("/guacamole/status");
const response = await guacamoleApi(origin).get(
"/plugin-api/remote-desktop/status",
);
return response.data;
}
@@ -247,7 +252,7 @@ export async function getGuacamoleConnectionId(
signal?: AbortSignal,
): Promise<string | null> {
const response = await guacamoleApi(origin).get(
`/guacamole/connection/${encodeURIComponent(connectId)}`,
`/plugin-api/remote-desktop/connection/${encodeURIComponent(connectId)}`,
{ signal },
);
return response.data.guacamoleConnectionId ?? null;
@@ -1,31 +1,39 @@
/**
* Where the Guacamole display socket lives.
*
* One path in every deployment: the plugin serves it at
* /plugin-ws/remote-desktop/display on the main backend, so there is no
* guacamole port and no separate nginx block any more. Electron with a remote
* server is the only case that targets a different host.
*/
export function buildGuacamoleWebSocketBaseUrl({
isDev,
isElectronApp,
isEmbeddedApp,
configuredServerUrl,
basePath,
location,
}: {
isDev: boolean;
isDev?: boolean;
isElectronApp: boolean;
isEmbeddedApp: boolean;
configuredServerUrl?: string;
basePath: string;
location: Pick<Location, "protocol" | "host">;
}) {
if (isDev) return "ws://localhost:30008";
if (isElectronApp) {
if (isEmbeddedApp || !configuredServerUrl) return "ws://127.0.0.1:30008";
const route = "/plugin-ws/remote-desktop/display";
if (isElectronApp && !isEmbeddedApp && configuredServerUrl) {
const wsProtocol = configuredServerUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = configuredServerUrl
.replace(/^https?:\/\//, "")
.replace(/\/$/, "");
return `${wsProtocol}${wsHost}/guacamole/websocket/`;
return `${wsProtocol}${wsHost}${route}`;
}
if (isElectronApp) return `ws://127.0.0.1:30001${route}`;
const wsProtocol = location.protocol === "https:" ? "wss" : "ws";
return `${wsProtocol}://${location.host}${basePath}/guacamole/websocket/`;
return `${wsProtocol}://${location.host}${basePath}${route}`;
}
@@ -42,7 +42,9 @@ describe("guacamole API origin", () => {
await getGuacdStatus("remote");
await getGuacamoleTokenFromHost(9, "remote", "vnc");
expect(authApiMock.get).toHaveBeenCalledWith("/guacamole/status");
expect(authApiMock.get).toHaveBeenCalledWith(
"/plugin-api/remote-desktop/status",
);
expect(authApiMock.post).toHaveBeenCalledOnce();
expect(remoteApiMock.get).not.toHaveBeenCalled();
expect(remoteApiMock.post).not.toHaveBeenCalled();
@@ -58,7 +60,9 @@ describe("guacamole API origin", () => {
expect(status.guacd.status).toBe("connected");
expect(token.token).toBe("remote-token");
expect(remoteApiMock.get).toHaveBeenCalledWith("/guacamole/status");
expect(remoteApiMock.get).toHaveBeenCalledWith(
"/plugin-api/remote-desktop/status",
);
expect(remoteApiMock.post).toHaveBeenCalledOnce();
expect(authApiMock.get).not.toHaveBeenCalled();
expect(authApiMock.post).not.toHaveBeenCalled();
@@ -81,7 +85,7 @@ describe("guacamole API origin", () => {
);
expect(remoteApiMock.post).toHaveBeenCalledWith(
"/guacamole/connect-host/41",
"/plugin-api/remote-desktop/connect-host/41",
{
protocol: "rdp",
promptedUsername: "admin",
@@ -101,12 +105,15 @@ describe("guacamole API origin", () => {
domain: "",
});
expect(authApiMock.post).toHaveBeenCalledWith("/guacamole/connect-host/9", {
protocol: "rdp",
promptedUsername: "local-admin",
promptedPassword: "secret",
promptedDomain: "",
});
expect(authApiMock.post).toHaveBeenCalledWith(
"/plugin-api/remote-desktop/connect-host/9",
{
protocol: "rdp",
promptedUsername: "local-admin",
promptedPassword: "secret",
promptedDomain: "",
},
);
});
// Support#1240: a host set to "This Device" is served by the embedded
@@ -118,9 +125,12 @@ describe("guacamole API origin", () => {
const token = await getGuacamoleTokenFromHost(9, "local", "rdp");
expect(token.token).toBe("local-token");
expect(authApiMock.post).toHaveBeenCalledWith("/guacamole/connect-host/9", {
protocol: "rdp",
});
expect(authApiMock.post).toHaveBeenCalledWith(
"/plugin-api/remote-desktop/connect-host/9",
{
protocol: "rdp",
},
);
expect(remoteApiMock.post).not.toHaveBeenCalled();
expect(resolveRemoteHostIdMock).not.toHaveBeenCalled();
});
@@ -131,7 +141,9 @@ describe("guacamole API origin", () => {
const status = await getGuacdStatus("local");
expect(status.guacd.status).toBe("disconnected");
expect(authApiMock.get).toHaveBeenCalledWith("/guacamole/status");
expect(authApiMock.get).toHaveBeenCalledWith(
"/plugin-api/remote-desktop/status",
);
expect(remoteApiMock.get).not.toHaveBeenCalled();
});
@@ -166,7 +178,7 @@ it("looks up the session on the backend that issued its token", async () => {
const signal = new AbortController().signal;
await getGuacamoleConnectionId("connect/id", "remote", signal);
expect(remoteApiMock.get).toHaveBeenCalledWith(
"/guacamole/connection/connect%2Fid",
"/plugin-api/remote-desktop/connection/connect%2Fid",
{ signal },
);
expect(authApiMock.get).not.toHaveBeenCalled();
@@ -6,63 +6,55 @@ const httpsLocation = {
host: "termix.example.com",
} as Location;
// The display socket moved off port 30008 to /plugin-ws/remote-desktop/display
// on the main backend, so every case below asserts the same one path. Dev is
// no longer a special case: Vite proxies the backend, so there is nothing to
// point at a separate port.
const ROUTE = "/plugin-ws/remote-desktop/display";
describe("buildGuacamoleWebSocketBaseUrl", () => {
it("uses the same origin guacamole websocket path in production web builds", () => {
it("uses the same origin in production web builds", () => {
expect(
buildGuacamoleWebSocketBaseUrl({
isDev: false,
isElectronApp: false,
isEmbeddedApp: false,
basePath: "",
location: httpsLocation,
}),
).toBe("wss://termix.example.com/guacamole/websocket/");
).toBe(`wss://termix.example.com${ROUTE}`);
});
it("preserves the runtime base path for proxied web deployments", () => {
expect(
buildGuacamoleWebSocketBaseUrl({
isDev: false,
isElectronApp: false,
isEmbeddedApp: false,
basePath: "/termix",
location: httpsLocation,
}),
).toBe("wss://termix.example.com/termix/guacamole/websocket/");
).toBe(`wss://termix.example.com/termix${ROUTE}`);
});
it("keeps direct local websocket access for development and embedded electron", () => {
it("targets the embedded backend for electron running locally", () => {
expect(
buildGuacamoleWebSocketBaseUrl({
isDev: true,
isElectronApp: false,
isEmbeddedApp: false,
basePath: "/termix",
location: httpsLocation,
}),
).toBe("ws://localhost:30008");
expect(
buildGuacamoleWebSocketBaseUrl({
isDev: false,
isElectronApp: true,
isEmbeddedApp: true,
basePath: "/termix",
location: httpsLocation,
}),
).toBe("ws://127.0.0.1:30008");
).toBe(`ws://127.0.0.1:30001${ROUTE}`);
});
it("uses the configured remote server URL for electron remote mode", () => {
expect(
buildGuacamoleWebSocketBaseUrl({
isDev: false,
isElectronApp: true,
isEmbeddedApp: false,
configuredServerUrl: "https://termix.example.com/termix/",
basePath: "",
location: httpsLocation,
}),
).toBe("wss://termix.example.com/termix/guacamole/websocket/");
).toBe(`wss://termix.example.com/termix${ROUTE}`);
});
});
+15 -4
View File
@@ -1,19 +1,30 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import {
TERMINAL_WS_PORT,
handleTerminalUpgrade,
sessionManager,
startTerminalServer,
stopTerminalServer,
} from "../../../../src/backend/hosts/terminal/index.js";
export async function activate(ctx: PluginContext) {
await startTerminalServer();
// Public because the terminal authenticates each socket itself: a share-link
// guest arrives with a share token instead of a session, so the check has to
// happen where that token is understood.
ctx.ws.upgrade(
"/terminal",
(request, socket, head) =>
handleTerminalUpgrade(
request as Parameters<typeof handleTerminalUpgrade>[0],
socket as Parameters<typeof handleTerminalUpgrade>[1],
head as Buffer,
),
{ public: true },
);
// Published so collab and session-sharing can reach live sessions without
// importing a plugin. Revoked automatically when this plugin is disabled.
ctx.registry.provide("terminal.sessions", sessionManager ?? null);
ctx.log.info(`SSH terminal listening on ${TERMINAL_WS_PORT ?? 30002}`);
ctx.log.info("SSH terminal mounted at /plugin-ws/ssh-terminal/terminal");
}
export async function deactivate() {
+6 -1
View File
@@ -14,7 +14,12 @@
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": ["settings:read-core", "network:outbound", "ui:surface"],
"capabilities": [
"settings:read-core",
"network:outbound",
"network:serve",
"ui:surface"
],
"contributes": {
"permissionGroup": {
"group": "tailscale",
+2 -2
View File
@@ -2,8 +2,8 @@ import type { PluginContext } from "@termix/plugin-sdk/backend";
import { startTailscaleService, stopTailscaleService } from "./routes.js";
export async function activate(ctx: PluginContext) {
startTailscaleService();
ctx.log.info("Tailscale router registered at /tailscale");
startTailscaleService(ctx.http.router());
ctx.log.info("Tailscale routes mounted at /plugin-api/tailscale");
}
export async function deactivate() {
+3 -7
View File
@@ -9,10 +9,6 @@ import { PermissionManager } from "../../../../src/backend/utils/permission-mana
import { apiLogger } from "../../../../src/backend/utils/logger.js";
import { fetchWithProxy } from "../../../../src/backend/utils/proxy-agent.js";
import { createCurrentSettingsRepository } from "../../../../src/backend/database/repositories/factory.js";
import {
registerTailscaleRouter,
unregisterTailscaleRouter,
} from "../../../../src/backend/database/routes/tailscale-dispatch.js";
interface TailscaleDevice {
id: string;
@@ -152,12 +148,12 @@ router.get(
/** Called from activate(). Mounts this router at /tailscale via the shared
* dispatcher. */
export function startTailscaleService(): void {
registerTailscaleRouter(router);
export function startTailscaleService(mountOn: Router): void {
mountOn.use(router);
}
/** Called from deactivate(). /tailscale/* falls back to 404 until
* reactivated. */
export function stopTailscaleService(): void {
unregisterTailscaleRouter();
// Unmounted by the runtime when the plugin deactivates.
}
@@ -47,7 +47,7 @@ export async function getTailscaleDevices(): Promise<{
error?: string;
}> {
try {
const response = await authApi.get("/tailscale/devices");
const response = await authApi.get("/plugin-api/tailscale/devices");
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
+1
View File
@@ -18,6 +18,7 @@
"hosts:read",
"credentials:use",
"ssh:connect",
"network:serve",
"ui:surface"
],
"contributes": {
+2 -2
View File
@@ -2,8 +2,8 @@ import type { PluginContext } from "@termix/plugin-sdk/backend";
import { startWebEndpointService, stopWebEndpointService } from "./routes.js";
export async function activate(ctx: PluginContext) {
startWebEndpointService();
ctx.log.info("Web Endpoint router registered at /ssh/tunnel/web-endpoint");
startWebEndpointService(ctx.http.router());
ctx.log.info("Web Endpoint routes mounted at /plugin-api/web-endpoint");
}
export async function deactivate() {
+4 -8
View File
@@ -1,4 +1,4 @@
import express from "express";
import express, { type Router } from "express";
import { Client } from "ssh2";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { tunnelLogger } from "../../../../src/backend/utils/logger.js";
@@ -12,10 +12,6 @@ import {
} from "../../../../src/backend/hosts/tunnel/manager.js";
import { forwardOut } from "../../../../src/backend/hosts/tunnel/ssh-primitives.js";
import { buildWebEndpointTunnelName } from "../../../../src/backend/hosts/tunnel/utils.js";
import {
registerWebEndpointRouter,
unregisterWebEndpointRouter,
} from "../../../../src/backend/hosts/tunnel/web-endpoint-dispatch.js";
import type { TunnelConfig, WebEndpoint } from "../../../../src/types/index.js";
/** Matches the spec's ten minutes. */
@@ -302,7 +298,7 @@ export const router = express.Router();
let started = false;
export function startWebEndpointService(): void {
export function startWebEndpointService(mountOn: Router): void {
if (!started) {
// Built on first start rather than at module scope so importing this
// module for a handler unit test does not construct the auth singleton.
@@ -315,9 +311,9 @@ export function startWebEndpointService(): void {
router.post("/open", authenticateJWT, handleWebEndpointOpen);
started = true;
}
registerWebEndpointRouter(router);
mountOn.use(router);
}
export function stopWebEndpointService(): void {
unregisterWebEndpointRouter();
// Unmounted by the runtime when the plugin deactivates.
}
@@ -305,33 +305,28 @@ describe("POST /tunnel/web-endpoint/open", () => {
});
/**
* The client resolves "/tunnel/web-endpoint/open" against a baseURL that
* already ends in /ssh, and nginx proxies /ssh through with the path intact --
* so the server must serve the FULL "/ssh/tunnel/web-endpoint/open" path.
* Serving the unprefixed form 404s every call, and no handler unit test
* notices because they call the handler directly.
* The client path and the server path have to agree.
*
* This was caught only by opening a real tunnel against a deployed build.
* This guards a real bug: the route once served the unprefixed form while the
* client called the full one, so every call 404d and no handler unit test
* noticed, because those call the handler directly. It was caught only by
* opening a tunnel against a deployed build.
*
* Since the plugin migration that path is composed of two halves: the mount
* point in src/backend/hosts/tunnel/index.ts and the route on the plugin's own
* router. Either half drifting breaks the URL, so both are asserted here.
* The shape changed with A4. The route used to be composed of a mount point in
* the tunnel service (/ssh/tunnel/web-endpoint, port 30003) plus "/open" on
* this router. Now core mounts every plugin at /plugin-api/<id>, so the client
* calls /plugin-api/web-endpoint/open and the only half this plugin owns is
* "/open". Asserting that keeps the guard meaningful without re-encoding a
* prefix the plugin no longer controls.
*/
describe("route registration", () => {
it("registers /open on the router the dispatcher forwards to", async () => {
it("registers /open on the router core hands it", async () => {
const routes = await import("../../src/backend/routes.js");
const dispatch =
await import("../../../../src/backend/hosts/tunnel/web-endpoint-dispatch.js");
const registered: unknown[] = [];
vi.spyOn(dispatch, "registerWebEndpointRouter").mockImplementation(
(router) => {
registered.push(router);
},
);
const express = (await import("express")).default;
const mountOn = express.Router();
routes.startWebEndpointService();
routes.startWebEndpointService(mountOn);
expect(registered).toHaveLength(1);
const paths = (
routes.router as unknown as {
stack: Array<{ route?: { path: string; methods: { post?: boolean } } }>;
@@ -339,15 +334,17 @@ describe("route registration", () => {
).stack
.filter((layer) => layer.route?.methods.post)
.map((layer) => layer.route?.path);
expect(paths).toContain("/open");
});
it("is mounted so the full client path resolves", async () => {
it("is reached through the plugin mount rather than a port of its own", async () => {
const source = await readFile(
new URL("../../../../src/backend/hosts/tunnel/index.ts", import.meta.url),
new URL("../../src/backend/index.ts", import.meta.url),
"utf8",
);
expect(source).toContain('app.use("/ssh/tunnel/web-endpoint"');
// ctx.http.router() is what puts this plugin at /plugin-api/web-endpoint.
expect(source).toContain("ctx.http.router()");
});
});
+1 -1
View File
@@ -14,7 +14,7 @@
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": ["db:own", "ui:surface"],
"capabilities": ["db:own", "network:serve", "ui:surface"],
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
+2 -2
View File
@@ -2,8 +2,8 @@ import type { PluginContext } from "@termix/plugin-sdk/backend";
import { startWorkspacesService, stopWorkspacesService } from "./routes.js";
export async function activate(ctx: PluginContext) {
startWorkspacesService();
ctx.log.info("Workspaces router registered at /workspaces");
startWorkspacesService(ctx.http.router());
ctx.log.info("Workspaces routes mounted at /plugin-api/workspaces");
}
export async function deactivate() {
+4 -8
View File
@@ -1,13 +1,9 @@
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
import express, { type Request, type Response } from "express";
import express, { type Request, type Response, type Router } from "express";
import { databaseLogger } from "../../../../src/backend/utils/logger.js";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { createCurrentWorkspaceRepository } from "../../../../src/backend/database/repositories/factory.js";
import type { WorkspaceRecord } from "../../../../src/backend/database/repositories/workspace-repository.js";
import {
registerWorkspacesRouter,
unregisterWorkspacesRouter,
} from "../../../../src/backend/database/routes/workspace-dispatch.js";
export const router = express.Router();
@@ -639,10 +635,10 @@ router.delete(
},
);
export function startWorkspacesService(): void {
registerWorkspacesRouter(router);
export function startWorkspacesService(mountOn: Router): void {
mountOn.use(router);
}
export function stopWorkspacesService(): void {
unregisterWorkspacesRouter();
// Unmounted by the runtime when the plugin deactivates.
}
@@ -3,7 +3,7 @@ import type { Workspace, WorkspacePayload } from "@/types/ui-types";
export async function listWorkspaces(): Promise<Workspace[]> {
try {
const response = await authApi.get("/workspaces");
const response = await authApi.get("/plugin-api/workspaces");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch workspaces");
@@ -17,7 +17,7 @@ export async function createWorkspace(data: {
payload: WorkspacePayload;
}): Promise<Workspace> {
try {
const response = await authApi.post("/workspaces", data);
const response = await authApi.post("/plugin-api/workspaces", data);
return response.data;
} catch (error) {
throw handleApiError(error, "create workspace");
@@ -29,7 +29,7 @@ export async function renameWorkspace(
data: { name?: string; color?: string | null; icon?: string | null },
): Promise<Workspace> {
try {
const response = await authApi.patch(`/workspaces/${id}`, data);
const response = await authApi.patch(`/plugin-api/workspaces/${id}`, data);
return response.data;
} catch (error) {
throw handleApiError(error, "update workspace");
@@ -41,7 +41,7 @@ export async function updateWorkspaceContent(
payload: WorkspacePayload,
): Promise<Workspace> {
try {
const response = await authApi.put(`/workspaces/${id}/content`, {
const response = await authApi.put(`/plugin-api/workspaces/${id}/content`, {
payload,
});
return response.data;
@@ -54,7 +54,7 @@ export async function deleteWorkspace(
id: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(`/workspaces/${id}`);
const response = await authApi.delete(`/plugin-api/workspaces/${id}`);
return response.data;
} catch (error) {
throw handleApiError(error, "delete workspace");
@@ -66,9 +66,12 @@ export async function duplicateWorkspace(
name: string,
): Promise<Workspace> {
try {
const response = await authApi.post(`/workspaces/${id}/duplicate`, {
name,
});
const response = await authApi.post(
`/plugin-api/workspaces/${id}/duplicate`,
{
name,
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "duplicate workspace");
@@ -77,7 +80,9 @@ export async function duplicateWorkspace(
export async function setDefaultWorkspace(id: number): Promise<Workspace> {
try {
const response = await authApi.post(`/workspaces/${id}/set-default`);
const response = await authApi.post(
`/plugin-api/workspaces/${id}/set-default`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "set default workspace");
@@ -86,7 +91,9 @@ export async function setDefaultWorkspace(id: number): Promise<Workspace> {
export async function unsetDefaultWorkspace(id: number): Promise<Workspace> {
try {
const response = await authApi.post(`/workspaces/${id}/unset-default`);
const response = await authApi.post(
`/plugin-api/workspaces/${id}/unset-default`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "unset default workspace");
@@ -95,7 +102,7 @@ export async function unsetDefaultWorkspace(id: number): Promise<Workspace> {
export async function applyWorkspaceServer(id: number): Promise<Workspace> {
try {
const response = await authApi.post(`/workspaces/${id}/apply`);
const response = await authApi.post(`/plugin-api/workspaces/${id}/apply`);
return response.data;
} catch (error) {
throw handleApiError(error, "apply workspace");
@@ -104,7 +111,7 @@ export async function applyWorkspaceServer(id: number): Promise<Workspace> {
export async function getLastSessionWorkspace(): Promise<Workspace | null> {
try {
const response = await authApi.get("/workspaces/last-session");
const response = await authApi.get("/plugin-api/workspaces/last-session");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch last session workspace");
@@ -115,7 +122,7 @@ export async function saveLastSessionWorkspace(
payload: WorkspacePayload,
): Promise<Workspace> {
try {
const response = await authApi.put("/workspaces/last-session", {
const response = await authApi.put("/plugin-api/workspaces/last-session", {
payload,
});
return response.data;
@@ -46,73 +46,88 @@ const samplePayload: WorkspacePayload = {
};
describe("workspaces-api", () => {
it("listWorkspaces GETs /workspaces", async () => {
it("listWorkspaces GETs /plugin-api/workspaces", async () => {
authApiMock.get.mockResolvedValueOnce({ data: [{ id: 1 }] });
const result = await listWorkspaces();
expect(authApiMock.get).toHaveBeenCalledWith("/workspaces");
expect(authApiMock.get).toHaveBeenCalledWith("/plugin-api/workspaces");
expect(result).toEqual([{ id: 1 }]);
});
it("createWorkspace POSTs /workspaces with the payload", async () => {
it("createWorkspace POSTs /plugin-api/workspaces with the payload", async () => {
await createWorkspace({
name: "Test A",
color: "#fff",
payload: samplePayload,
});
expect(authApiMock.post).toHaveBeenCalledWith("/workspaces", {
expect(authApiMock.post).toHaveBeenCalledWith("/plugin-api/workspaces", {
name: "Test A",
color: "#fff",
payload: samplePayload,
});
});
it("renameWorkspace PATCHes /workspaces/:id", async () => {
it("renameWorkspace PATCHes /plugin-api/workspaces/:id", async () => {
await renameWorkspace(5, { name: "New Name" });
expect(authApiMock.patch).toHaveBeenCalledWith("/workspaces/5", {
expect(authApiMock.patch).toHaveBeenCalledWith("/plugin-api/workspaces/5", {
name: "New Name",
});
});
it("updateWorkspaceContent PUTs /workspaces/:id/content", async () => {
it("updateWorkspaceContent PUTs /plugin-api/workspaces/:id/content", async () => {
await updateWorkspaceContent(5, samplePayload);
expect(authApiMock.put).toHaveBeenCalledWith("/workspaces/5/content", {
payload: samplePayload,
});
expect(authApiMock.put).toHaveBeenCalledWith(
"/plugin-api/workspaces/5/content",
{
payload: samplePayload,
},
);
});
it("deleteWorkspace DELETEs /workspaces/:id", async () => {
it("deleteWorkspace DELETEs /plugin-api/workspaces/:id", async () => {
await deleteWorkspace(5);
expect(authApiMock.delete).toHaveBeenCalledWith("/workspaces/5");
expect(authApiMock.delete).toHaveBeenCalledWith("/plugin-api/workspaces/5");
});
it("duplicateWorkspace POSTs /workspaces/:id/duplicate with a name", async () => {
it("duplicateWorkspace POSTs /plugin-api/workspaces/:id/duplicate with a name", async () => {
await duplicateWorkspace(5, "Test A (copy)");
expect(authApiMock.post).toHaveBeenCalledWith("/workspaces/5/duplicate", {
name: "Test A (copy)",
});
expect(authApiMock.post).toHaveBeenCalledWith(
"/plugin-api/workspaces/5/duplicate",
{
name: "Test A (copy)",
},
);
});
it("setDefaultWorkspace POSTs /workspaces/:id/set-default", async () => {
it("setDefaultWorkspace POSTs /plugin-api/workspaces/:id/set-default", async () => {
await setDefaultWorkspace(5);
expect(authApiMock.post).toHaveBeenCalledWith("/workspaces/5/set-default");
expect(authApiMock.post).toHaveBeenCalledWith(
"/plugin-api/workspaces/5/set-default",
);
});
it("applyWorkspaceServer POSTs /workspaces/:id/apply", async () => {
it("applyWorkspaceServer POSTs /plugin-api/workspaces/:id/apply", async () => {
await applyWorkspaceServer(5);
expect(authApiMock.post).toHaveBeenCalledWith("/workspaces/5/apply");
expect(authApiMock.post).toHaveBeenCalledWith(
"/plugin-api/workspaces/5/apply",
);
});
it("getLastSessionWorkspace GETs /workspaces/last-session", async () => {
it("getLastSessionWorkspace GETs /plugin-api/workspaces/last-session", async () => {
authApiMock.get.mockResolvedValueOnce({ data: null });
const result = await getLastSessionWorkspace();
expect(authApiMock.get).toHaveBeenCalledWith("/workspaces/last-session");
expect(authApiMock.get).toHaveBeenCalledWith(
"/plugin-api/workspaces/last-session",
);
expect(result).toBeNull();
});
it("saveLastSessionWorkspace PUTs /workspaces/last-session", async () => {
it("saveLastSessionWorkspace PUTs /plugin-api/workspaces/last-session", async () => {
await saveLastSessionWorkspace(samplePayload);
expect(authApiMock.put).toHaveBeenCalledWith("/workspaces/last-session", {
payload: samplePayload,
});
expect(authApiMock.put).toHaveBeenCalledWith(
"/plugin-api/workspaces/last-session",
{
payload: samplePayload,
},
);
});
});