mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: serve plugin http and websockets from one mount instead of ports
This commit is contained in:
1 parent
c496a093ca
commit
8431570ff7
102 files changed
+2894
-2308
No files matched your search
@@ -23,6 +23,7 @@
|
||||
"secrets:own",
|
||||
"network:outbound",
|
||||
"audit:read",
|
||||
"network:serve",
|
||||
"ui:surface"
|
||||
],
|
||||
"dependencies": {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
"events:core",
|
||||
"notify:send",
|
||||
"network:outbound",
|
||||
"network:serve",
|
||||
"ui:surface"
|
||||
],
|
||||
"locales": "locales",
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"credentials:use",
|
||||
"ssh:connect",
|
||||
"db:own",
|
||||
"network:serve",
|
||||
"ui:surface"
|
||||
],
|
||||
"locales": "locales",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
"credentials:use",
|
||||
"ssh:connect",
|
||||
"db:own",
|
||||
"network:serve",
|
||||
"ui:surface"
|
||||
],
|
||||
"contributes": {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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().
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,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() {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"hosts:read",
|
||||
"credentials:use",
|
||||
"ssh:connect",
|
||||
"network:serve",
|
||||
"ui:surface"
|
||||
],
|
||||
"contributes": {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()");
|
||||
});
|
||||
});
|
||||
@@ -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,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() {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user