From 6e058f6e9e758a2a8f63c4a3596f7fc48d13fc00 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 22 Sep 2026 12:30:35 -0500 Subject: [PATCH] feat: move web endpoint into a first-party plugin --- plugins/web-endpoint/README.md | 71 ++++++++++++++++ plugins/web-endpoint/backend/index.mjs | 75 +++++++++++++++++ .../web-endpoint/backend/routes.ts | 59 ++++++++------ .../frontend}/WebEndpointTab.test.tsx | 59 +++++--------- .../web-endpoint/frontend}/WebEndpointTab.tsx | 4 +- plugins/web-endpoint/frontend/index.mjs | 39 +++++++++ .../frontend}/web-endpoint-api.test.ts | 18 ++--- .../frontend}/web-endpoint-api.ts | 5 +- .../frontend}/web-endpoint-url.test.ts | 2 +- .../frontend}/web-endpoint-url.ts | 0 .../frontend}/web-endpoint-validation.test.ts | 4 +- .../frontend}/web-endpoint-validation.ts | 0 plugins/web-endpoint/manifest.json | 35 ++++++++ .../database/routes/host-web-endpoints.ts | 2 +- src/backend/hosts/tunnel/index.ts | 6 +- .../hosts/tunnel/web-endpoint-dispatch.ts | 55 +++++++++++++ src/backend/plugins/first-party.ts | 10 +++ src/backend/starter.ts | 8 +- .../tests/plugins/bundled-plugin.test.ts | 51 ++++++++++++ src/backend/tests/plugins/first-party.test.ts | 22 ++++- .../web-endpoint/routes.test.ts} | 80 ++++++++++++------- src/ui/shell/pluginLoader.ts | 1 + src/ui/shell/tabUtils.tsx | 2 +- src/ui/sidebar/HostEditorWebUiSection.tsx | 4 +- src/ui/sidebar/tree/HostItem/HostItem.tsx | 2 +- tsconfig.plugins.json | 3 +- 26 files changed, 496 insertions(+), 121 deletions(-) create mode 100644 plugins/web-endpoint/README.md create mode 100644 plugins/web-endpoint/backend/index.mjs rename src/backend/hosts/tunnel/web-endpoint-routes.ts => plugins/web-endpoint/backend/routes.ts (86%) rename {src/ui/tests/features => plugins/web-endpoint/frontend}/WebEndpointTab.test.tsx (88%) rename {src/ui/features/web-endpoint => plugins/web-endpoint/frontend}/WebEndpointTab.tsx (99%) create mode 100644 plugins/web-endpoint/frontend/index.mjs rename {src/ui/tests/api => plugins/web-endpoint/frontend}/web-endpoint-api.test.ts (89%) rename {src/ui/api => plugins/web-endpoint/frontend}/web-endpoint-api.ts (98%) rename {src/ui/tests/lib => plugins/web-endpoint/frontend}/web-endpoint-url.test.ts (99%) rename {src/ui/lib => plugins/web-endpoint/frontend}/web-endpoint-url.ts (100%) rename {src/ui/tests/lib => plugins/web-endpoint/frontend}/web-endpoint-validation.test.ts (96%) rename {src/ui/lib => plugins/web-endpoint/frontend}/web-endpoint-validation.ts (100%) create mode 100644 plugins/web-endpoint/manifest.json create mode 100644 src/backend/hosts/tunnel/web-endpoint-dispatch.ts rename src/backend/tests/{hosts/web-endpoint-open-route.test.ts => plugins/web-endpoint/routes.test.ts} (77%) diff --git a/plugins/web-endpoint/README.md b/plugins/web-endpoint/README.md new file mode 100644 index 000000000..915f93491 --- /dev/null +++ b/plugins/web-endpoint/README.md @@ -0,0 +1,71 @@ +# Web Endpoint + +Opens a host's web UI from Termix, either directly or through an SSH tunnel, in an +embedded tab or an isolated desktop window. + +Each host can define up to 16 endpoints (`MAX_WEB_ENDPOINTS`). An endpoint is either: + +- **direct** — the browser loads `scheme://host:port/path` itself, or +- **tunnel** — the backend opens a local SSH forward to the target port and the frame + loads it over loopback. + +## Layout + +| Path | What it is | +| ------------------------------------- | ---------------------------------------------------------------------- | +| `backend/index.mjs` | Plugin entry: `activate` starts the service, `deactivate` stops it. | +| `backend/routes.ts` | `POST /open` — opens or reuses the forward and returns the bound port. | +| `frontend/WebEndpointTab.tsx` | The embedded viewer (a `credentialless` sandboxed iframe). | +| `frontend/web-endpoint-api.ts` | Client for the open route plus the Electron bridges. | +| `frontend/web-endpoint-url.ts` | URL building and the cookie/reachability refusal rules. | +| `frontend/web-endpoint-validation.ts` | Editor-side mirror of the backend normalizer. | + +## What stays in core + +This plugin owns its lifecycle, not all of its code. Four pieces deliberately stay in +Termix proper: + +- **The tunnel manager** (`src/backend/hosts/tunnel/manager.ts`). It serves the whole + server-tunnels feature. The reserved `web:` tunnel-name prefix that exempts these + tunnels from the retry machinery is part of its own disconnect path. +- **`host-web-endpoints.ts`** (`src/backend/database/routes/`). Despite sitting in + `routes/` it has no router — it is the `webUiConfig` normalizer, imported by `host.ts`, + `host-bulk-routes.ts` and `host-normalizers.ts` to sanitize the column on every host + save and list. Disabling this plugin must not stop that validation, and core cannot + import plugin code in any case. +- **The Electron half** (`electron/web-endpoint-window.cjs` plus the + `open-isolated-web-endpoint` and `allow-invalid-certificate-for-origin` handlers in + `main.cjs`). `BrowserWindow` and `session` are main-process-only, the renderer is what + invokes those channels, and the main process loads no plugin code today. +- **`HostEditorWebUiSection.tsx`** (`src/ui/sidebar/`). It is a section inside the host + editor's General tab and takes the core-owned `{ form, setField }` pair, the same + reason docker leaves `HostDockerTab` in core. + +The `enableWebUi` and `webUiConfig` columns stay on core's `ssh_data` table, declared +here through `contributes.hostCapability`. + +## Why it runs in-process + +The open route polls the tunnel manager's live maps, builds a `TunnelConfig` from +plaintext host credentials that `ctx.hosts` deliberately withholds, and hands the +resulting live `ssh2.Client` to `forwardOut`. A structured-clone postMessage boundary +can carry none of those, so this is a genuine transport dependency — see +`src/backend/plugins/first-party.ts`. + +## Routing + +The route is served by the tunnel service on port 30003, so the dispatcher lives at +`src/backend/hosts/tunnel/web-endpoint-dispatch.ts` rather than in `database.ts` with +the others. The public path stays `/ssh/tunnel/web-endpoint/open`, which the generic +`location /ssh/tunnel/` block in both nginx configs already proxies — so no nginx change +was needed. + +## Tests + +Backend tests are in `src/backend/tests/plugins/web-endpoint/` (the backend vitest +project only globs `src/backend/**`). Frontend tests sit beside the source here, which +the frontend project picks up via `plugins/**/frontend/**/*.test.{ts,tsx}`. + +`web-endpoint-validation.test.ts` deliberately imports the backend normalizer as well as +the editor one and runs both over the same samples, so the two implementations cannot +drift. diff --git a/plugins/web-endpoint/backend/index.mjs b/plugins/web-endpoint/backend/index.mjs new file mode 100644 index 000000000..227c20e6c --- /dev/null +++ b/plugins/web-endpoint/backend/index.mjs @@ -0,0 +1,75 @@ +/** + * Web Endpoint - first-party, in-process plugin. + * + * This plugin runs on the main thread because its one route is a real + * transport consumer: it polls the tunnel manager's live maps + * (activeTunnelRuntimes, connectionStatus, tunnelConnecting), needs the + * plaintext host credentials ctx.hosts deliberately withholds (PluginHostView + * is a 13-field allowlist excluding password, key and passphrase), and hands a + * live ssh2.Client to forwardOut to probe the target. None of that survives + * the structured-clone postMessage boundary a worker plugin talks over. + * + * Unlike every other dispatcher-backed plugin, this one does not register with + * a dispatcher in database.ts: its route is served by the tunnel service on + * port 30003, so the dispatcher lives at + * src/backend/hosts/tunnel/web-endpoint-dispatch.ts instead. Both that service + * and the plugin runtime load into the same backend process, so the + * dispatcher's module-level slot is shared between them. Keeping the URL + * (/ssh/tunnel/web-endpoint/open) byte-identical is also why no nginx change + * was needed -- the generic /ssh/tunnel/ location block already covers it. + * + * Three pieces stay in core rather than moving here: + * + * - The tunnel manager itself (src/backend/hosts/tunnel/manager.ts). It + * serves the whole server-tunnels feature, and the reserved "web:" name + * prefix that exempts these tunnels from the retry machinery is part of + * its own disconnect path. + * + * - host-web-endpoints.ts (the webUiConfig normalizer). Despite its name it + * has no router: it is imported by host.ts, host-bulk-routes.ts and + * host-normalizers.ts to sanitize the column on every host save and list. + * Disabling this plugin must not stop that validation, and core cannot + * import plugin code in any case. + * + * - The Electron half (electron/web-endpoint-window.cjs and the two + * ipcMain handlers in main.cjs). BrowserWindow and session are + * main-process-only, the renderer is what invokes those channels, and the + * main process loads no plugin code at all today. + * + * The enableWebUi/webUiConfig columns stay on core's ssh_data table, declared + * here through contributes.hostCapability -- this plugin system has no + * owned-table migration mechanism of its own. + */ + +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Loads a module by its path relative to this file's own directory. + * + * The plugin ships beside dist/backend, so in a built server the compiled .js + * for routes.js sits right next to this .mjs. Under vitest/dev nothing imports + * this plugin directly (the loader loads in-process plugins with a plain + * dynamic import, no tsx loader involved), so the only layout that needs to + * resolve here is the built one. + */ +async function loadRelative(relativePath) { + const here = path.dirname(fileURLToPath(import.meta.url)); + const target = path.join(here, `${relativePath}.js`); + return import(new URL(`file://${target.replace(/\\/g, "/")}`).href); +} + +let routesModule = null; + +export async function activate(ctx) { + routesModule = await loadRelative("routes"); + routesModule.startWebEndpointService(); + ctx.log.info("Web Endpoint router registered at /ssh/tunnel/web-endpoint"); +} + +export async function deactivate() { + if (routesModule) { + routesModule.stopWebEndpointService(); + routesModule = null; + } +} diff --git a/src/backend/hosts/tunnel/web-endpoint-routes.ts b/plugins/web-endpoint/backend/routes.ts similarity index 86% rename from src/backend/hosts/tunnel/web-endpoint-routes.ts rename to plugins/web-endpoint/backend/routes.ts index e614a8957..1cb9009ae 100644 --- a/src/backend/hosts/tunnel/web-endpoint-routes.ts +++ b/plugins/web-endpoint/backend/routes.ts @@ -1,18 +1,22 @@ -import type express from "express"; +import express from "express"; import { Client } from "ssh2"; -import { AuthManager } from "../../utils/auth-manager.js"; -import { tunnelLogger } from "../../utils/logger.js"; -import { parseWebUiConfig } from "../../database/routes/host-web-endpoints.js"; +import { AuthManager } from "../../../src/backend/utils/auth-manager.js"; +import { tunnelLogger } from "../../../src/backend/utils/logger.js"; +import { parseWebUiConfig } from "../../../src/backend/database/routes/host-web-endpoints.js"; import { activeTunnelRuntimes, cleanupTunnelResources, connectSSHTunnel, connectionStatus, tunnelConnecting, -} from "./manager.js"; -import { forwardOut } from "./ssh-primitives.js"; -import { buildWebEndpointTunnelName } from "./utils.js"; -import type { TunnelConfig, WebEndpoint } from "../../../types/index.js"; +} 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. */ const WEB_ENDPOINT_IDLE_TIMEOUT_MS = 10 * 60 * 1000; @@ -146,7 +150,8 @@ export async function handleWebEndpointOpen( // browser -- loopback for the desktop, an address the server answers on for // a web deployment. - const { resolveHostById } = await import("../host-resolver.js"); + const { resolveHostById } = + await import("../../../src/backend/hosts/host-resolver.js"); const host = await resolveHostById(hostId, userId); if (!host) { return res.status(403).json({ error: "Host not found or access denied" }); @@ -293,18 +298,26 @@ export async function handleWebEndpointOpen( } } -export function registerWebEndpointRoutes(app: express.Express): void { - // Constructed here rather than at module scope so importing this module for - // a handler unit test does not construct the auth singleton. - const authenticateJWT = AuthManager.getInstance().createAuthMiddleware(); - // The "/ssh" prefix is part of the path this service serves, exactly as - // every route in routes.ts carries it -- nginx proxies /ssh through with the - // path intact. Registering "/tunnel/..." here 404s every call, since the - // client resolves "/tunnel/web-endpoint/open" against a baseURL that already - // ends in /ssh. - app.post( - "/ssh/tunnel/web-endpoint/open", - authenticateJWT, - handleWebEndpointOpen, - ); +export const router = express.Router(); + +let started = false; + +export function startWebEndpointService(): 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. + const authenticateJWT = AuthManager.getInstance().createAuthMiddleware(); + // Only "/open" here: the dispatcher this router is registered with is + // mounted at /ssh/tunnel/web-endpoint, so the full path stays + // /ssh/tunnel/web-endpoint/open exactly as before. The "/ssh" prefix is + // part of the path the tunnel service serves -- nginx proxies /ssh/tunnel/ + // through with the path intact. + router.post("/open", authenticateJWT, handleWebEndpointOpen); + started = true; + } + registerWebEndpointRouter(router); +} + +export function stopWebEndpointService(): void { + unregisterWebEndpointRouter(); } diff --git a/src/ui/tests/features/WebEndpointTab.test.tsx b/plugins/web-endpoint/frontend/WebEndpointTab.test.tsx similarity index 88% rename from src/ui/tests/features/WebEndpointTab.test.tsx rename to plugins/web-endpoint/frontend/WebEndpointTab.test.tsx index 79fb47a35..1ab50be8a 100644 --- a/src/ui/tests/features/WebEndpointTab.test.tsx +++ b/plugins/web-endpoint/frontend/WebEndpointTab.test.tsx @@ -13,12 +13,11 @@ import type { Host } from "@/types/ui-types"; const openWebEndpointTunnel = vi.hoisted(() => vi.fn()); const allowInvalidCertificateForOrigin = vi.hoisted(() => vi.fn()); -vi.mock("@/api/web-endpoint-api", async (importOriginal) => { +vi.mock("./web-endpoint-api", async (importOriginal) => { // requireNumericHostId is left as the real implementation -- the "no saved // host" case exercises the actual validation, not a re-implementation of it // that could drift. - const actual = - await importOriginal(); + const actual = await importOriginal(); return { ...actual, openWebEndpointTunnel: (...args: unknown[]) => @@ -95,8 +94,7 @@ describe("WebEndpointTab", () => { it("refuses unsupported environments before creating a tunnel or iframe", async () => { delete (HTMLIFrameElement.prototype as { credentialless?: boolean }) .credentialless; - const { WebEndpointTab } = - await import("@/features/web-endpoint/WebEndpointTab"); + const { WebEndpointTab } = await import("./WebEndpointTab"); render( { expect(document.querySelector("iframe")).toBeNull(); }); it("isolates cookie state and denies parent access, popups and top navigation", async () => { - const { WebEndpointTab } = - await import("@/features/web-endpoint/WebEndpointTab"); + const { WebEndpointTab } = await import("./WebEndpointTab"); render(); const frame = (await screen.findByTitle("Proxmox")) as HTMLIFrameElement; expect( @@ -121,8 +118,7 @@ describe("WebEndpointTab", () => { }); it("renders an iframe at the direct URL", async () => { - const { WebEndpointTab } = - await import("@/features/web-endpoint/WebEndpointTab"); + const { WebEndpointTab } = await import("./WebEndpointTab"); render(); await waitFor(() => expect(screen.getByTitle("Proxmox")).toHaveAttribute( @@ -134,8 +130,7 @@ describe("WebEndpointTab", () => { it("opens the tunnel through the numeric host id and frames the loopback URL", async () => { openWebEndpointTunnel.mockResolvedValue(41234); - const { WebEndpointTab } = - await import("@/features/web-endpoint/WebEndpointTab"); + const { WebEndpointTab } = await import("./WebEndpointTab"); render( { }); it("registers the certificate allowance for a direct endpoint with ignoreCert", async () => { - const { WebEndpointTab } = - await import("@/features/web-endpoint/WebEndpointTab"); + const { WebEndpointTab } = await import("./WebEndpointTab"); render( { openWebEndpointTunnel .mockResolvedValueOnce(41234) .mockResolvedValueOnce(51234); - const { WebEndpointTab } = - await import("@/features/web-endpoint/WebEndpointTab"); + const { WebEndpointTab } = await import("./WebEndpointTab"); render( { */ it("remounts the frame on reload even when the resolved URL is unchanged", async () => { openWebEndpointTunnel.mockResolvedValue(41234); - const { WebEndpointTab } = - await import("@/features/web-endpoint/WebEndpointTab"); + const { WebEndpointTab } = await import("./WebEndpointTab"); render( { openWebEndpointTunnel.mockRejectedValue( new Error("Timed out reaching the endpoint port"), ); - const { WebEndpointTab } = - await import("@/features/web-endpoint/WebEndpointTab"); + const { WebEndpointTab } = await import("./WebEndpointTab"); render( { }); it("shows a plain message when the endpoint id matches nothing on the host", async () => { - const { WebEndpointTab } = - await import("@/features/web-endpoint/WebEndpointTab"); + const { WebEndpointTab } = await import("./WebEndpointTab"); render(); await waitFor(() => expect(screen.getByText("webEndpoint.notFound")).toBeInTheDocument(), @@ -261,8 +251,7 @@ describe("WebEndpointTab", () => { }); it("explains that a tunnel needs a saved host, instead of calling the route", async () => { - const { WebEndpointTab } = - await import("@/features/web-endpoint/WebEndpointTab"); + const { WebEndpointTab } = await import("./WebEndpointTab"); render( { it("resolves a direct endpoint normally on the same quick-connect host", async () => { // The positive case: requireNumericHostId must be consulted only on the // tunnel branch, or direct endpoints would break on unsaved hosts too. - const { WebEndpointTab } = - await import("@/features/web-endpoint/WebEndpointTab"); + const { WebEndpointTab } = await import("./WebEndpointTab"); render( { // session cookie. electron.isElectron.mockReturnValue(false); openWebEndpointTunnel.mockResolvedValue(41234); - const { WebEndpointTab } = - await import("@/features/web-endpoint/WebEndpointTab"); + const { WebEndpointTab } = await import("./WebEndpointTab"); render( { it("refuses a loopback-bound tunnel in a browser instead of framing a dead port", async () => { electron.isElectron.mockReturnValue(false); - const { WebEndpointTab } = - await import("@/features/web-endpoint/WebEndpointTab"); + const { WebEndpointTab } = await import("./WebEndpointTab"); render( { it("refuses when the page host has no separate loopback spelling", async () => { electron.isElectron.mockReturnValue(false); pageHostname = "termix.example.com"; - const { WebEndpointTab } = - await import("@/features/web-endpoint/WebEndpointTab"); + const { WebEndpointTab } = await import("./WebEndpointTab"); render( { it("refuses a direct endpoint on the same host that serves Termix", async () => { electron.isElectron.mockReturnValue(false); pageHostname = "termix.example"; - const { WebEndpointTab } = - await import("@/features/web-endpoint/WebEndpointTab"); + const { WebEndpointTab } = await import("./WebEndpointTab"); render( { it("refuses a direct endpoint on a sub domain of the page host", async () => { electron.isElectron.mockReturnValue(false); pageHostname = "termix.example.com"; - const { WebEndpointTab } = - await import("@/features/web-endpoint/WebEndpointTab"); + const { WebEndpointTab } = await import("./WebEndpointTab"); render( { it("still frames a direct same-host endpoint on the desktop, whose jar has no jwt", async () => { electron.isElectron.mockReturnValue(true); pageHostname = "termix.example"; - const { WebEndpointTab } = - await import("@/features/web-endpoint/WebEndpointTab"); + const { WebEndpointTab } = await import("./WebEndpointTab"); render( { it("still frames a DIRECT endpoint on a hostname that refuses tunnels", async () => { electron.isElectron.mockReturnValue(false); pageHostname = "termix.example.com"; - const { WebEndpointTab } = - await import("@/features/web-endpoint/WebEndpointTab"); + const { WebEndpointTab } = await import("./WebEndpointTab"); render(); await waitFor(() => expect(screen.getByTitle("Proxmox")).toHaveAttribute( diff --git a/src/ui/features/web-endpoint/WebEndpointTab.tsx b/plugins/web-endpoint/frontend/WebEndpointTab.tsx similarity index 99% rename from src/ui/features/web-endpoint/WebEndpointTab.tsx rename to plugins/web-endpoint/frontend/WebEndpointTab.tsx index 37d81695c..310f53a25 100644 --- a/src/ui/features/web-endpoint/WebEndpointTab.tsx +++ b/plugins/web-endpoint/frontend/WebEndpointTab.tsx @@ -7,13 +7,13 @@ import { resolveWebEndpointUrl, webEndpointRefusalReason, type WebEndpointRefusalReason, -} from "@/lib/web-endpoint-url"; +} from "./web-endpoint-url"; import { allowInvalidCertificateForOrigin, openWebEndpointExternally, openWebEndpointTunnel, requireNumericHostId, -} from "@/api/web-endpoint-api"; +} from "./web-endpoint-api"; import { copyToClipboard } from "@/lib/clipboard"; import { isElectron } from "@/lib/electron"; import { Button } from "@/components/button"; diff --git a/plugins/web-endpoint/frontend/index.mjs b/plugins/web-endpoint/frontend/index.mjs new file mode 100644 index 000000000..2c1ab8454 --- /dev/null +++ b/plugins/web-endpoint/frontend/index.mjs @@ -0,0 +1,39 @@ +/** + * Frontend half of the web-endpoint plugin. + * + * Registers the endpoint viewer tab through the existing registerTabComponent + * seam the same way network-topology's frontend does. + * + * HostEditorWebUiSection stays in src/ui/sidebar/ rather than moving here, and + * is not registered through registerHostEditorTab: it is a section inside the + * host editor's existing General tab, not a tab of its own, and it takes the + * same {form, setField} pair every other host editor field does -- those types + * (HostEditorForm, the setField signature) are core types HostEditor.tsx owns, + * the same reason docker leaves HostDockerTab in core rather than copying it + * into plugins/docker. + * + * register() is called when the plugin is enabled, unregister() when it is + * disabled. As of this change neither is actually invoked anywhere yet -- see + * src/ui/shell/pluginLoader.ts and + * src/ui/tests/sidebar/plugin-extension-seam.test.tsx, which says outright + * "there is no plugin loader yet" for the frontend half. Until one exists the + * tab is rendered directly by tabUtils.tsx and merely gated on the plugin's + * enabled state through BUILT_IN_TABS_BY_PLUGIN. This file follows the same + * shape every other plugin's frontend entry does so it is ready the moment a + * real loader lands. + */ + +export const id = "web-endpoint"; +export const tabId = "web-endpoint"; + +export async function register({ registerTabComponent }) { + registerTabComponent?.(tabId, () => + import("./WebEndpointTab.tsx").then((m) => ({ + default: m.WebEndpointTab, + })), + ); +} + +export async function unregister({ unregisterTabComponent }) { + unregisterTabComponent?.(tabId); +} diff --git a/src/ui/tests/api/web-endpoint-api.test.ts b/plugins/web-endpoint/frontend/web-endpoint-api.test.ts similarity index 89% rename from src/ui/tests/api/web-endpoint-api.test.ts rename to plugins/web-endpoint/frontend/web-endpoint-api.test.ts index bbc44b291..a352be07d 100644 --- a/src/ui/tests/api/web-endpoint-api.test.ts +++ b/plugins/web-endpoint/frontend/web-endpoint-api.test.ts @@ -59,7 +59,7 @@ describe("openWebEndpointTunnel", () => { // resolve to /ssh/ssh/... and 404 on every call. Asserted on the captured // runtime argument rather than by scanning source, so quoting style and // indirection cannot fool it. - const { openWebEndpointTunnel } = await import("@/api/web-endpoint-api"); + const { openWebEndpointTunnel } = await import("./web-endpoint-api"); await openWebEndpointTunnel(7, "e1"); expect(tunnelPost.mock.calls[0][0]).not.toMatch(/^\/ssh\//); @@ -70,7 +70,7 @@ describe("openWebEndpointTunnel", () => { }); it("returns the port", async () => { - const { openWebEndpointTunnel } = await import("@/api/web-endpoint-api"); + const { openWebEndpointTunnel } = await import("./web-endpoint-api"); await expect(openWebEndpointTunnel(7, "e1")).resolves.toBe(41234); }); @@ -87,7 +87,7 @@ describe("openWebEndpointTunnel", () => { }); tunnelPost.mockRejectedValue(axiosError); const { openWebEndpointTunnel, WebEndpointTunnelError } = - await import("@/api/web-endpoint-api"); + await import("./web-endpoint-api"); await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow( /Timed out reaching the endpoint port/, @@ -106,20 +106,20 @@ describe("openWebEndpointTunnel", () => { response: { status: 500, data: { error: { nested: true } } }, }), ); - const { openWebEndpointTunnel } = await import("@/api/web-endpoint-api"); + const { openWebEndpointTunnel } = await import("./web-endpoint-api"); await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(/generic:/); }); it("treats a missing port as a failure rather than returning undefined", async () => { tunnelPost.mockResolvedValue({ data: {} }); - const { openWebEndpointTunnel } = await import("@/api/web-endpoint-api"); + const { openWebEndpointTunnel } = await import("./web-endpoint-api"); await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(/no port/); }); }); describe("requireNumericHostId", () => { it("accepts a saved host id and rejects a quick-connect one", async () => { - const { requireNumericHostId } = await import("@/api/web-endpoint-api"); + const { requireNumericHostId } = await import("./web-endpoint-api"); expect(requireNumericHostId("7")).toBe(7); for (const bad of ["quick-connect-1", "", "0", "-3", "abc"]) { expect(() => requireNumericHostId(bad)).toThrow(/saved host/); @@ -134,8 +134,7 @@ describe("requireNumericHostId", () => { */ describe("openWebEndpointExternally", () => { it("refuses shared-cookie browser windows before opening a tunnel", async () => { - const { openWebEndpointExternally } = - await import("@/api/web-endpoint-api"); + const { openWebEndpointExternally } = await import("./web-endpoint-api"); await expect(openWebEndpointExternally(host, endpoint())).rejects.toThrow( /desktop app/, ); @@ -149,8 +148,7 @@ describe("openWebEndpointExternally", () => { configurable: true, value: { invoke }, }); - const { openWebEndpointExternally } = - await import("@/api/web-endpoint-api"); + const { openWebEndpointExternally } = await import("./web-endpoint-api"); await openWebEndpointExternally(host, endpoint({ ignoreCert: true })); expect(invoke).toHaveBeenCalledWith("open-isolated-web-endpoint", { url: "https://127.0.0.1:41234/", diff --git a/src/ui/api/web-endpoint-api.ts b/plugins/web-endpoint/frontend/web-endpoint-api.ts similarity index 98% rename from src/ui/api/web-endpoint-api.ts rename to plugins/web-endpoint/frontend/web-endpoint-api.ts index 03bbf8790..a766acd54 100644 --- a/src/ui/api/web-endpoint-api.ts +++ b/plugins/web-endpoint/frontend/web-endpoint-api.ts @@ -1,7 +1,4 @@ -import { - currentTunnelHost, - resolveWebEndpointUrl, -} from "@/lib/web-endpoint-url"; +import { currentTunnelHost, resolveWebEndpointUrl } from "./web-endpoint-url"; import axios from "axios"; import { handleApiError, tunnelApi } from "@/main-axios"; import { isElectron } from "@/lib/electron"; diff --git a/src/ui/tests/lib/web-endpoint-url.test.ts b/plugins/web-endpoint/frontend/web-endpoint-url.test.ts similarity index 99% rename from src/ui/tests/lib/web-endpoint-url.test.ts rename to plugins/web-endpoint/frontend/web-endpoint-url.test.ts index 83c858996..87e3dc6bb 100644 --- a/src/ui/tests/lib/web-endpoint-url.test.ts +++ b/plugins/web-endpoint/frontend/web-endpoint-url.test.ts @@ -5,7 +5,7 @@ import { separatedTunnelHost, sharesCookieSiteWithPage, webEndpointRefusalReason, -} from "@/lib/web-endpoint-url"; +} from "./web-endpoint-url"; import type { WebEndpoint } from "@/types/index"; function endpoint(overrides: Partial = {}): WebEndpoint { diff --git a/src/ui/lib/web-endpoint-url.ts b/plugins/web-endpoint/frontend/web-endpoint-url.ts similarity index 100% rename from src/ui/lib/web-endpoint-url.ts rename to plugins/web-endpoint/frontend/web-endpoint-url.ts diff --git a/src/ui/tests/lib/web-endpoint-validation.test.ts b/plugins/web-endpoint/frontend/web-endpoint-validation.test.ts similarity index 96% rename from src/ui/tests/lib/web-endpoint-validation.test.ts rename to plugins/web-endpoint/frontend/web-endpoint-validation.test.ts index 9aed76e61..905cf77cd 100644 --- a/src/ui/tests/lib/web-endpoint-validation.test.ts +++ b/plugins/web-endpoint/frontend/web-endpoint-validation.test.ts @@ -5,8 +5,8 @@ import { isWebEndpointPortValid, webEndpointErrorKey, webEndpointRowError, -} from "@/lib/web-endpoint-validation"; -import { normalizeWebEndpoints } from "../../../backend/database/routes/host-web-endpoints.js"; +} from "./web-endpoint-validation"; +import { normalizeWebEndpoints } from "../../../src/backend/database/routes/host-web-endpoints.js"; import type { WebEndpoint } from "@/types/index"; function endpoint(overrides: Partial = {}): WebEndpoint { diff --git a/src/ui/lib/web-endpoint-validation.ts b/plugins/web-endpoint/frontend/web-endpoint-validation.ts similarity index 100% rename from src/ui/lib/web-endpoint-validation.ts rename to plugins/web-endpoint/frontend/web-endpoint-validation.ts diff --git a/plugins/web-endpoint/manifest.json b/plugins/web-endpoint/manifest.json new file mode 100644 index 000000000..bb26ce6a2 --- /dev/null +++ b/plugins/web-endpoint/manifest.json @@ -0,0 +1,35 @@ +{ + "id": "web-endpoint", + "name": "Web Endpoint", + "version": "1.0.0", + "description": "Open a host's web UI in a tab or an isolated desktop window, directly or over an SSH tunnel.", + "author": { "name": "Termix" }, + "license": "MIT", + "repository": "https://github.com/Termix-SSH/Plugin-WebEndpoint", + "category": "Networking", + "icon": "Globe", + "engine": { "termix": ">=2.9.0", "api": "1" }, + "capabilities": { + "backend": true, + "frontend": true, + "electron": true, + "platforms": ["linux", "win32", "darwin"] + }, + "permissions": ["process:transport-owner"], + "contributes": { + "tabs": [ + { + "id": "web-endpoint", + "titleKey": "hosts.tabWebUi", + "icon": "Globe", + "openFrom": ["host-context-menu"] + } + ], + "hostCapability": { + "key": "enableWebUi", + "labelKey": "hosts.tabWebUi", + "editorTab": "general" + } + }, + "sidecars": [] +} diff --git a/src/backend/database/routes/host-web-endpoints.ts b/src/backend/database/routes/host-web-endpoints.ts index 809430ad7..cb0297f9e 100644 --- a/src/backend/database/routes/host-web-endpoints.ts +++ b/src/backend/database/routes/host-web-endpoints.ts @@ -146,7 +146,7 @@ function normalizeEndpoint(raw: unknown): WebEndpoint | null { * Drops any endpoint it refuses rather than rejecting the whole host -- one * bad row must not make a host unsaveable or unlistable. The editor is * responsible for telling the user before that happens - * (src/ui/lib/web-endpoint-validation.ts). + * (plugins/web-endpoint/frontend/web-endpoint-validation.ts). */ export function normalizeWebEndpoints(raw: unknown): WebEndpoint[] { if (!Array.isArray(raw)) return []; diff --git a/src/backend/hosts/tunnel/index.ts b/src/backend/hosts/tunnel/index.ts index a825bba84..a4504efe5 100644 --- a/src/backend/hosts/tunnel/index.ts +++ b/src/backend/hosts/tunnel/index.ts @@ -21,7 +21,7 @@ import { } from "./c2s-relay.js"; import { registerTunnelRoutes } from "./routes.js"; -import { registerWebEndpointRoutes } from "./web-endpoint-routes.js"; +import webEndpointDispatch from "./web-endpoint-dispatch.js"; import { initializeAutoStartTunnels } from "./manager.js"; import { attachServicePortConflictHandler } from "../../utils/service-listen.js"; @@ -39,7 +39,9 @@ app.use((_req, res, next) => { }); registerTunnelRoutes(app); -registerWebEndpointRoutes(app); +// Always mounted; the Web Endpoint plugin registers its router behind this on +// activate and clears it on deactivate. See web-endpoint-dispatch.ts. +app.use("/ssh/tunnel/web-endpoint", webEndpointDispatch); const PORT = 30003; const server = createServer(app); diff --git a/src/backend/hosts/tunnel/web-endpoint-dispatch.ts b/src/backend/hosts/tunnel/web-endpoint-dispatch.ts new file mode 100644 index 000000000..1e3c58fcb --- /dev/null +++ b/src/backend/hosts/tunnel/web-endpoint-dispatch.ts @@ -0,0 +1,55 @@ +// Mounted at /ssh/tunnel/web-endpoint on the tunnel service's app. Web +// Endpoint ships as a first-party in-process plugin (plugins/web-endpoint) +// rather than a route file imported directly by index.ts, so this module +// exists to keep /ssh/tunnel/web-endpoint/* working at its original path +// without handing the plugin a reference to the shared app instance. The +// plugin runtime registers its router via registerWebEndpointRouter when it +// activates, and clears it on deactivate, so a disabled plugin falls back to +// the 404 below. +// +// This is the only dispatcher that lives outside database.ts: the route is +// served by the tunnel service on port 30003, and nginx already proxies the +// whole /ssh/tunnel/ prefix there. Both that service and the plugin runtime +// are loaded into the same backend process (starter.ts imports +// hosts/tunnel/index.js), so the module-level slot below is shared state +// between them, exactly as it is for the dispatchers in database.ts. +// +// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards +// to exactly one first-party router (the Web Endpoint plugin's own), and every +// route in that router already applies authenticateJWT itself, the same as +// every other route registered alongside it here. /plugin-api gates at the +// mount point because it forwards to arbitrary third-party plugin code that +// cannot be assumed to gate itself; that reasoning does not apply to a single +// first-party router that already does. + +import type { Request, Response, NextFunction, Router } from "express"; +import { tunnelLogger } from "../../utils/logger.js"; + +let activeRouter: Router | null = null; + +export function registerWebEndpointRouter(router: Router): void { + activeRouter = router; + tunnelLogger.info("Registered Web Endpoint plugin router", { + operation: "web_endpoint_dispatch_register", + }); +} + +export function unregisterWebEndpointRouter(): void { + if (!activeRouter) return; + activeRouter = null; + tunnelLogger.info("Unregistered Web Endpoint plugin router", { + operation: "web_endpoint_dispatch_register", + }); +} + +export default function webEndpointDispatch( + req: Request, + res: Response, + next: NextFunction, +): void { + if (!activeRouter) { + res.status(404).json({ error: "Web endpoints are not available" }); + return; + } + activeRouter(req, res, next); +} diff --git a/src/backend/plugins/first-party.ts b/src/backend/plugins/first-party.ts index 7c7f61938..d5995bccf 100644 --- a/src/backend/plugins/first-party.ts +++ b/src/backend/plugins/first-party.ts @@ -99,6 +99,15 @@ * in SDK v1, see worker-bootstrap.ts), so there is currently no way for a * worker-tier plugin to make an outbound HTTP request. * + * web-endpoint is here for the same reason ssh-terminal is, on a smaller + * scale: its open route polls the tunnel manager's live maps + * (activeTunnelRuntimes, connectionStatus, tunnelConnecting), builds a + * TunnelConfig from plaintext host credentials that ctx.hosts deliberately + * withholds, and hands the resulting live ssh2.Client to forwardOut to probe + * the target before reporting success. A structured-clone boundary can carry + * none of those three, so this is a genuine transport dependency rather than + * the URL-stability concession network-topology and workspaces make. + * * workspaces is here for the same reason network-topology is: its routes are * plain per-user JSON CRUD against a single repository, and would fit the * worker ctx fine on their own. It stays in-process to keep its existing @@ -119,6 +128,7 @@ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet = new Set([ "network-topology", "tailscale", "workspaces", + "web-endpoint", ]); export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner"; diff --git a/src/backend/starter.ts b/src/backend/starter.ts index def925235..21451631d 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -300,11 +300,15 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { // its router with its own dispatcher in database.ts on activate (/ai, // /proxmox, /guacamole, /fleets, /automations, /network-topology, // /workspaces), so disabling any of them makes its routes 404 instead of - // leaving a dead import here. Automations' scheduler (due schedules, + // leaving a dead import here. Web Endpoint works the same way, except its + // dispatcher is mounted by the tunnel service below rather than by + // database.ts, since its route is served on port 30003. Automations' + // scheduler (due schedules, // dwell rechecks, docker-event polling, history pruning) also starts // from its own activate() rather than here. See plugins/ai, // plugins/proxmox, plugins/remote-desktop, plugins/fleets, - // plugins/automations, plugins/network-topology and plugins/workspaces. + // plugins/automations, plugins/network-topology, plugins/workspaces and + // plugins/web-endpoint. await import("./hosts/tunnel/index.js"); await import("./hosts/file-manager/index.js"); await import("./hosts/tmux/index.js"); diff --git a/src/backend/tests/plugins/bundled-plugin.test.ts b/src/backend/tests/plugins/bundled-plugin.test.ts index 2d09d059b..259312eed 100644 --- a/src/backend/tests/plugins/bundled-plugin.test.ts +++ b/src/backend/tests/plugins/bundled-plugin.test.ts @@ -415,3 +415,54 @@ describe("bundled workspaces plugin", () => { expect(source).toMatch(/export async function deactivate/); }); }); + +describe("bundled web-endpoint plugin", () => { + it("ships a directory with a manifest and both entry points", () => { + const dir = path.join(getBundledPluginsDir(), "web-endpoint"); + + expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true); + expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true); + expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true); + }); + + it("has a manifest that passes the real validator", () => { + const { manifest, errors } = parseManifest( + readBundledManifest("web-endpoint"), + ); + + expect(errors).toEqual([]); + expect(manifest?.id).toBe("web-endpoint"); + expect(manifest?.category).toBe("Networking"); + }); + + it("declares the transport-owner capability and qualifies for the tier", () => { + const { manifest } = parseManifest(readBundledManifest("web-endpoint")); + + expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY); + expect(isFirstParty("web-endpoint")).toBe(true); + expect(runsInProcess("web-endpoint", manifest!.permissions)).toBe(true); + }); + + // The column this points at stays on core's ssh_data table, so a typo here + // would silently detach the host-editor checkbox from its storage. + it("contributes the enableWebUi host capability", () => { + const { manifest } = parseManifest(readBundledManifest("web-endpoint")); + + expect(manifest?.contributes?.hostCapability).toMatchObject({ + key: "enableWebUi", + }); + }); + + it("exports activate and deactivate from its backend entry", () => { + const entry = path.join( + getBundledPluginsDir(), + "web-endpoint", + "backend", + "index.mjs", + ); + const source = fs.readFileSync(entry, "utf8"); + + expect(source).toMatch(/export async function activate/); + expect(source).toMatch(/export async function deactivate/); + }); +}); diff --git a/src/backend/tests/plugins/first-party.test.ts b/src/backend/tests/plugins/first-party.test.ts index b5d36c3d8..b12527874 100644 --- a/src/backend/tests/plugins/first-party.test.ts +++ b/src/backend/tests/plugins/first-party.test.ts @@ -39,7 +39,7 @@ function manifest(overrides: Record = {}) { } describe("first-party allowlist", () => { - it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale and workspaces", () => { + it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale, workspaces and web-endpoint", () => { expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([ "ai", "automations", @@ -51,11 +51,12 @@ describe("first-party allowlist", () => { "remote-desktop", "ssh-terminal", "tailscale", + "web-endpoint", "workspaces", ]); }); - it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale and workspaces and nothing else", () => { + it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale, workspaces and web-endpoint and nothing else", () => { expect(isFirstParty("ssh-terminal")).toBe(true); expect(isFirstParty("docker")).toBe(true); expect(isFirstParty("host-metrics")).toBe(true); @@ -67,6 +68,7 @@ describe("first-party allowlist", () => { expect(isFirstParty("network-topology")).toBe(true); expect(isFirstParty("tailscale")).toBe(true); expect(isFirstParty("workspaces")).toBe(true); + expect(isFirstParty("web-endpoint")).toBe(true); expect(isFirstParty("ssh-terminal-pro")).toBe(false); expect(isFirstParty("community-plugin")).toBe(false); expect(isFirstParty("")).toBe(false); @@ -98,6 +100,9 @@ describe("runsInProcess", () => { expect(runsInProcess("workspaces", [TRANSPORT_OWNER_CAPABILITY])).toBe( true, ); + expect(runsInProcess("web-endpoint", [TRANSPORT_OWNER_CAPABILITY])).toBe( + true, + ); // On the list but not asking for it: stays in a worker, so the manifest // remains an honest description of what the plugin does. @@ -112,6 +117,7 @@ describe("runsInProcess", () => { expect(runsInProcess("network-topology", ["hosts.read"])).toBe(false); expect(runsInProcess("tailscale", ["hosts.read"])).toBe(false); expect(runsInProcess("workspaces", ["hosts.read"])).toBe(false); + expect(runsInProcess("web-endpoint", ["hosts.read"])).toBe(false); // Asking for it but not on the list. expect( @@ -251,6 +257,18 @@ describe("manifest gate on the reserved capability", () => { expect(parsed?.id).toBe("workspaces"); }); + it("allows it for web-endpoint", () => { + const { manifest: parsed, errors } = parseManifest( + manifest({ + id: "web-endpoint", + permissions: [TRANSPORT_OWNER_CAPABILITY], + }), + ); + + expect(errors).toEqual([]); + expect(parsed?.id).toBe("web-endpoint"); + }); + it("still accepts an ordinary manifest that never mentions it", () => { const { manifest: parsed, errors } = parseManifest( manifest({ permissions: ["hosts.read"] }), diff --git a/src/backend/tests/hosts/web-endpoint-open-route.test.ts b/src/backend/tests/plugins/web-endpoint/routes.test.ts similarity index 77% rename from src/backend/tests/hosts/web-endpoint-open-route.test.ts rename to src/backend/tests/plugins/web-endpoint/routes.test.ts index dd31a606c..a7902b7e6 100644 --- a/src/backend/tests/hosts/web-endpoint-open-route.test.ts +++ b/src/backend/tests/plugins/web-endpoint/routes.test.ts @@ -1,8 +1,9 @@ import type express from "express"; +import { readFile } from "node:fs/promises"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const resolveHostById = vi.hoisted(() => vi.fn()); -vi.mock("../../hosts/host-resolver.js", () => ({ resolveHostById })); +vi.mock("../../../hosts/host-resolver.js", () => ({ resolveHostById })); const manager = vi.hoisted(() => ({ activeTunnelRuntimes: new Map(), @@ -11,12 +12,12 @@ const manager = vi.hoisted(() => ({ connectSSHTunnel: vi.fn(), cleanupTunnelResources: vi.fn(async () => undefined), })); -vi.mock("../../hosts/tunnel/manager.js", () => manager); +vi.mock("../../../hosts/tunnel/manager.js", () => manager); const forwardOut = vi.hoisted(() => vi.fn(async () => ({ end: vi.fn() }))); -vi.mock("../../hosts/tunnel/ssh-primitives.js", () => ({ forwardOut })); +vi.mock("../../../hosts/tunnel/ssh-primitives.js", () => ({ forwardOut })); -vi.mock("../../utils/auth-manager.js", () => ({ +vi.mock("../../../utils/auth-manager.js", () => ({ AuthManager: { getInstance: () => ({ createAuthMiddleware: () => vi.fn() }) }, })); @@ -102,7 +103,7 @@ describe("POST /tunnel/web-endpoint/open", () => { it("refuses a host the user cannot resolve, without opening anything", async () => { resolveHostById.mockResolvedValue(null); const { handleWebEndpointOpen } = - await import("../../hosts/tunnel/web-endpoint-routes.js"); + await import("../../../../../plugins/web-endpoint/backend/routes.js"); const res = response(); await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res); @@ -115,7 +116,7 @@ describe("POST /tunnel/web-endpoint/open", () => { // stored enable_web_ui is still 0. The UI reads as off in that state. resolveHostById.mockResolvedValue(host({ enableWebUi: false })); const { handleWebEndpointOpen } = - await import("../../hosts/tunnel/web-endpoint-routes.js"); + await import("../../../../../plugins/web-endpoint/backend/routes.js"); const res = response(); await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res); @@ -125,7 +126,7 @@ describe("POST /tunnel/web-endpoint/open", () => { it("refuses an endpoint id that matches nothing", async () => { const { handleWebEndpointOpen } = - await import("../../hosts/tunnel/web-endpoint-routes.js"); + await import("../../../../../plugins/web-endpoint/backend/routes.js"); const res = response(); await handleWebEndpointOpen( request({ hostId: 7, endpointId: "nope" }), @@ -137,7 +138,7 @@ describe("POST /tunnel/web-endpoint/open", () => { it("returns the real bound port", async () => { tunnelConnectsOn(41234); const { handleWebEndpointOpen } = - await import("../../hosts/tunnel/web-endpoint-routes.js"); + await import("../../../../../plugins/web-endpoint/backend/routes.js"); const res = response(); await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res); @@ -151,7 +152,7 @@ describe("POST /tunnel/web-endpoint/open", () => { // select a different strategy and the forward would never bind locally. tunnelConnectsOn(41234); const { handleWebEndpointOpen } = - await import("../../hosts/tunnel/web-endpoint-routes.js"); + await import("../../../../../plugins/web-endpoint/backend/routes.js"); await handleWebEndpointOpen( request({ hostId: 7, endpointId: "e1" }), response(), @@ -170,7 +171,7 @@ describe("POST /tunnel/web-endpoint/open", () => { it("binds loopback by default and the endpoint's choice when given", async () => { tunnelConnectsOn(41234); const { handleWebEndpointOpen } = - await import("../../hosts/tunnel/web-endpoint-routes.js"); + await import("../../../../../plugins/web-endpoint/backend/routes.js"); await handleWebEndpointOpen( request({ hostId: 7, endpointId: "e1" }), response(), @@ -199,7 +200,7 @@ describe("POST /tunnel/web-endpoint/open", () => { it("reuses a live tunnel instead of opening a second one", async () => { tunnelConnectsOn(41234); const { handleWebEndpointOpen } = - await import("../../hosts/tunnel/web-endpoint-routes.js"); + await import("../../../../../plugins/web-endpoint/backend/routes.js"); const first = response(); await handleWebEndpointOpen( request({ hostId: 7, endpointId: "e1" }), @@ -218,7 +219,7 @@ describe("POST /tunnel/web-endpoint/open", () => { it("reopens when the endpoint's target changed under a live tunnel", async () => { tunnelConnectsOn(41234); const { handleWebEndpointOpen } = - await import("../../hosts/tunnel/web-endpoint-routes.js"); + await import("../../../../../plugins/web-endpoint/backend/routes.js"); await handleWebEndpointOpen( request({ hostId: 7, endpointId: "e1" }), response(), @@ -250,7 +251,7 @@ describe("POST /tunnel/web-endpoint/open", () => { new Error("Channel open failure: connect failed"), ); const { handleWebEndpointOpen } = - await import("../../hosts/tunnel/web-endpoint-routes.js"); + await import("../../../../../plugins/web-endpoint/backend/routes.js"); const res = response(); await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res); @@ -275,7 +276,7 @@ describe("POST /tunnel/web-endpoint/open", () => { }, ); const { handleWebEndpointOpen } = - await import("../../hosts/tunnel/web-endpoint-routes.js"); + await import("../../../../../plugins/web-endpoint/backend/routes.js"); const res = response(); await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res); @@ -285,7 +286,7 @@ describe("POST /tunnel/web-endpoint/open", () => { it("rejects a malformed request before touching the database", async () => { const { handleWebEndpointOpen } = - await import("../../hosts/tunnel/web-endpoint-routes.js"); + await import("../../../../../plugins/web-endpoint/backend/routes.js"); for (const body of [ {}, { hostId: "7", endpointId: "e1" }, @@ -302,25 +303,48 @@ 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 register the FULL "/ssh/tunnel/..." path, as every route - * in routes.ts does. Registering the unprefixed form 404s every call, and no - * handler unit test notices because they call the handler directly. + * 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. * * This was caught only by opening a real 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. */ describe("route registration", () => { - it("registers the /ssh-prefixed path the client actually calls", async () => { - const posts: string[] = []; - const app = { - post: (path: string) => { - posts.push(path); + it("registers /open on the router the dispatcher forwards to", async () => { + const routes = + await import("../../../../../plugins/web-endpoint/backend/routes.js"); + const dispatch = + await import("../../../hosts/tunnel/web-endpoint-dispatch.js"); + const registered: unknown[] = []; + vi.spyOn(dispatch, "registerWebEndpointRouter").mockImplementation( + (router) => { + registered.push(router); }, - } as unknown as express.Express; + ); - const { registerWebEndpointRoutes } = - await import("../../hosts/tunnel/web-endpoint-routes.js"); - registerWebEndpointRoutes(app); + routes.startWebEndpointService(); - expect(posts).toContain("/ssh/tunnel/web-endpoint/open"); + expect(registered).toHaveLength(1); + const paths = ( + routes.router as unknown as { + stack: Array<{ route?: { path: string; methods: { post?: boolean } } }>; + } + ).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 () => { + const source = await readFile( + new URL("../../../hosts/tunnel/index.ts", import.meta.url), + "utf8", + ); + + expect(source).toContain('app.use("/ssh/tunnel/web-endpoint"'); }); }); diff --git a/src/ui/shell/pluginLoader.ts b/src/ui/shell/pluginLoader.ts index e04e22c79..b94011565 100644 --- a/src/ui/shell/pluginLoader.ts +++ b/src/ui/shell/pluginLoader.ts @@ -44,6 +44,7 @@ const BUILT_IN_TABS_BY_PLUGIN: Record = { ai: ["ai"], fleets: ["fleet-inventory"], "network-topology": ["network_graph"], + "web-endpoint": ["web-endpoint"], }; let pluginEnabled = new Map(); diff --git a/src/ui/shell/tabUtils.tsx b/src/ui/shell/tabUtils.tsx index 7ec33ea40..a03986efe 100644 --- a/src/ui/shell/tabUtils.tsx +++ b/src/ui/shell/tabUtils.tsx @@ -89,7 +89,7 @@ const loadDockerManager = () => })); const DockerManager = lazy(loadDockerManager); const loadWebEndpointTab = () => - import("@/features/web-endpoint/WebEndpointTab").then((m) => ({ + import("../../../plugins/web-endpoint/frontend/WebEndpointTab").then((m) => ({ default: m.WebEndpointTab, })); const WebEndpointTab = lazy(loadWebEndpointTab); diff --git a/src/ui/sidebar/HostEditorWebUiSection.tsx b/src/ui/sidebar/HostEditorWebUiSection.tsx index d54af2c5e..1fbcb64b6 100644 --- a/src/ui/sidebar/HostEditorWebUiSection.tsx +++ b/src/ui/sidebar/HostEditorWebUiSection.tsx @@ -4,7 +4,7 @@ import { isElectron } from "@/lib/electron"; import { webEndpointRefusalReason, type WebEndpointRefusalReason, -} from "@/lib/web-endpoint-url"; +} from "../../../plugins/web-endpoint/frontend/web-endpoint-url"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; import { Checkbox } from "@/components/checkbox"; @@ -28,7 +28,7 @@ import { isWebEndpointPortValid, webEndpointErrorKey, webEndpointRowError, -} from "@/lib/web-endpoint-validation"; +} from "../../../plugins/web-endpoint/frontend/web-endpoint-validation"; /** * crypto.randomUUID is undefined outside a secure context, so a plain-http web diff --git a/src/ui/sidebar/tree/HostItem/HostItem.tsx b/src/ui/sidebar/tree/HostItem/HostItem.tsx index b4eb119de..bc9e4230d 100644 --- a/src/ui/sidebar/tree/HostItem/HostItem.tsx +++ b/src/ui/sidebar/tree/HostItem/HostItem.tsx @@ -87,7 +87,7 @@ import { recordHostActionPreference, } from "@/lib/local-adaptive-preferences"; import type { WebEndpoint } from "@/types/index"; -import { openWebEndpointExternally } from "@/api/web-endpoint-api"; +import { openWebEndpointExternally } from "../../../../../plugins/web-endpoint/frontend/web-endpoint-api"; import { isTabTypeAvailable } from "@/shell/pluginLoader"; export function statusCheckEnabled(host: Host): boolean { diff --git a/tsconfig.plugins.json b/tsconfig.plugins.json index 63d0186c9..9c5b2f90b 100644 --- a/tsconfig.plugins.json +++ b/tsconfig.plugins.json @@ -35,7 +35,8 @@ "plugins/fleets/backend/**/*.ts", "plugins/automations/backend/**/*.ts", "plugins/network-topology/backend/**/*.ts", - "plugins/workspaces/backend/**/*.ts" + "plugins/workspaces/backend/**/*.ts", + "plugins/web-endpoint/backend/**/*.ts" ], "exclude": ["src/**/*.test.ts"] }