feat: move web endpoint into a first-party plugin

This commit is contained in:
LukeGus committed 2026-09-22 12:30:35 -05:00
1 parent 6f0dff58a3
commit 6e058f6e9e
26 files changed
+496 -121

No files matched your search

+71
View File
@@ -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.
+75
View File
@@ -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;
}
}
@@ -1,18 +1,22 @@
import type express from "express"; import express from "express";
import { Client } from "ssh2"; import { Client } from "ssh2";
import { AuthManager } from "../../utils/auth-manager.js"; import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
import { tunnelLogger } from "../../utils/logger.js"; import { tunnelLogger } from "../../../src/backend/utils/logger.js";
import { parseWebUiConfig } from "../../database/routes/host-web-endpoints.js"; import { parseWebUiConfig } from "../../../src/backend/database/routes/host-web-endpoints.js";
import { import {
activeTunnelRuntimes, activeTunnelRuntimes,
cleanupTunnelResources, cleanupTunnelResources,
connectSSHTunnel, connectSSHTunnel,
connectionStatus, connectionStatus,
tunnelConnecting, tunnelConnecting,
} from "./manager.js"; } from "../../../src/backend/hosts/tunnel/manager.js";
import { forwardOut } from "./ssh-primitives.js"; import { forwardOut } from "../../../src/backend/hosts/tunnel/ssh-primitives.js";
import { buildWebEndpointTunnelName } from "./utils.js"; import { buildWebEndpointTunnelName } from "../../../src/backend/hosts/tunnel/utils.js";
import type { TunnelConfig, WebEndpoint } from "../../../types/index.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. */ /** Matches the spec's ten minutes. */
const WEB_ENDPOINT_IDLE_TIMEOUT_MS = 10 * 60 * 1000; 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 // browser -- loopback for the desktop, an address the server answers on for
// a web deployment. // 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); const host = await resolveHostById(hostId, userId);
if (!host) { if (!host) {
return res.status(403).json({ error: "Host not found or access denied" }); 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 { export const router = express.Router();
// Constructed here rather than at module scope so importing this module for
// a handler unit test does not construct the auth singleton. let started = false;
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
// The "/ssh" prefix is part of the path this service serves, exactly as export function startWebEndpointService(): void {
// every route in routes.ts carries it -- nginx proxies /ssh through with the if (!started) {
// path intact. Registering "/tunnel/..." here 404s every call, since the // Built on first start rather than at module scope so importing this
// client resolves "/tunnel/web-endpoint/open" against a baseURL that already // module for a handler unit test does not construct the auth singleton.
// ends in /ssh. const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
app.post( // Only "/open" here: the dispatcher this router is registered with is
"/ssh/tunnel/web-endpoint/open", // mounted at /ssh/tunnel/web-endpoint, so the full path stays
authenticateJWT, // /ssh/tunnel/web-endpoint/open exactly as before. The "/ssh" prefix is
handleWebEndpointOpen, // 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();
} }
@@ -13,12 +13,11 @@ import type { Host } from "@/types/ui-types";
const openWebEndpointTunnel = vi.hoisted(() => vi.fn()); const openWebEndpointTunnel = vi.hoisted(() => vi.fn());
const allowInvalidCertificateForOrigin = 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 // requireNumericHostId is left as the real implementation -- the "no saved
// host" case exercises the actual validation, not a re-implementation of it // host" case exercises the actual validation, not a re-implementation of it
// that could drift. // that could drift.
const actual = const actual = await importOriginal<typeof import("./web-endpoint-api")>();
await importOriginal<typeof import("@/api/web-endpoint-api")>();
return { return {
...actual, ...actual,
openWebEndpointTunnel: (...args: unknown[]) => openWebEndpointTunnel: (...args: unknown[]) =>
@@ -95,8 +94,7 @@ describe("WebEndpointTab", () => {
it("refuses unsupported environments before creating a tunnel or iframe", async () => { it("refuses unsupported environments before creating a tunnel or iframe", async () => {
delete (HTMLIFrameElement.prototype as { credentialless?: boolean }) delete (HTMLIFrameElement.prototype as { credentialless?: boolean })
.credentialless; .credentialless;
const { WebEndpointTab } = const { WebEndpointTab } = await import("./WebEndpointTab");
await import("@/features/web-endpoint/WebEndpointTab");
render( render(
<WebEndpointTab <WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])} host={host({}, [endpoint({ access: "tunnel" })])}
@@ -108,8 +106,7 @@ describe("WebEndpointTab", () => {
expect(document.querySelector("iframe")).toBeNull(); expect(document.querySelector("iframe")).toBeNull();
}); });
it("isolates cookie state and denies parent access, popups and top navigation", async () => { it("isolates cookie state and denies parent access, popups and top navigation", async () => {
const { WebEndpointTab } = const { WebEndpointTab } = await import("./WebEndpointTab");
await import("@/features/web-endpoint/WebEndpointTab");
render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />); render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />);
const frame = (await screen.findByTitle("Proxmox")) as HTMLIFrameElement; const frame = (await screen.findByTitle("Proxmox")) as HTMLIFrameElement;
expect( expect(
@@ -121,8 +118,7 @@ describe("WebEndpointTab", () => {
}); });
it("renders an iframe at the direct URL", async () => { it("renders an iframe at the direct URL", async () => {
const { WebEndpointTab } = const { WebEndpointTab } = await import("./WebEndpointTab");
await import("@/features/web-endpoint/WebEndpointTab");
render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />); render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />);
await waitFor(() => await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute( 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 () => { it("opens the tunnel through the numeric host id and frames the loopback URL", async () => {
openWebEndpointTunnel.mockResolvedValue(41234); openWebEndpointTunnel.mockResolvedValue(41234);
const { WebEndpointTab } = const { WebEndpointTab } = await import("./WebEndpointTab");
await import("@/features/web-endpoint/WebEndpointTab");
render( render(
<WebEndpointTab <WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])} host={host({}, [endpoint({ access: "tunnel" })])}
@@ -152,8 +147,7 @@ describe("WebEndpointTab", () => {
}); });
it("registers the certificate allowance for a direct endpoint with ignoreCert", async () => { it("registers the certificate allowance for a direct endpoint with ignoreCert", async () => {
const { WebEndpointTab } = const { WebEndpointTab } = await import("./WebEndpointTab");
await import("@/features/web-endpoint/WebEndpointTab");
render( render(
<WebEndpointTab <WebEndpointTab
host={host({}, [endpoint({ ignoreCert: true })])} host={host({}, [endpoint({ ignoreCert: true })])}
@@ -171,8 +165,7 @@ describe("WebEndpointTab", () => {
openWebEndpointTunnel openWebEndpointTunnel
.mockResolvedValueOnce(41234) .mockResolvedValueOnce(41234)
.mockResolvedValueOnce(51234); .mockResolvedValueOnce(51234);
const { WebEndpointTab } = const { WebEndpointTab } = await import("./WebEndpointTab");
await import("@/features/web-endpoint/WebEndpointTab");
render( render(
<WebEndpointTab <WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])} host={host({}, [endpoint({ access: "tunnel" })])}
@@ -208,8 +201,7 @@ describe("WebEndpointTab", () => {
*/ */
it("remounts the frame on reload even when the resolved URL is unchanged", async () => { it("remounts the frame on reload even when the resolved URL is unchanged", async () => {
openWebEndpointTunnel.mockResolvedValue(41234); openWebEndpointTunnel.mockResolvedValue(41234);
const { WebEndpointTab } = const { WebEndpointTab } = await import("./WebEndpointTab");
await import("@/features/web-endpoint/WebEndpointTab");
render( render(
<WebEndpointTab <WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])} host={host({}, [endpoint({ access: "tunnel" })])}
@@ -234,8 +226,7 @@ describe("WebEndpointTab", () => {
openWebEndpointTunnel.mockRejectedValue( openWebEndpointTunnel.mockRejectedValue(
new Error("Timed out reaching the endpoint port"), new Error("Timed out reaching the endpoint port"),
); );
const { WebEndpointTab } = const { WebEndpointTab } = await import("./WebEndpointTab");
await import("@/features/web-endpoint/WebEndpointTab");
render( render(
<WebEndpointTab <WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])} host={host({}, [endpoint({ access: "tunnel" })])}
@@ -250,8 +241,7 @@ describe("WebEndpointTab", () => {
}); });
it("shows a plain message when the endpoint id matches nothing on the host", async () => { it("shows a plain message when the endpoint id matches nothing on the host", async () => {
const { WebEndpointTab } = const { WebEndpointTab } = await import("./WebEndpointTab");
await import("@/features/web-endpoint/WebEndpointTab");
render(<WebEndpointTab host={host({}, [])} endpointId="deleted" />); render(<WebEndpointTab host={host({}, [])} endpointId="deleted" />);
await waitFor(() => await waitFor(() =>
expect(screen.getByText("webEndpoint.notFound")).toBeInTheDocument(), 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 () => { it("explains that a tunnel needs a saved host, instead of calling the route", async () => {
const { WebEndpointTab } = const { WebEndpointTab } = await import("./WebEndpointTab");
await import("@/features/web-endpoint/WebEndpointTab");
render( render(
<WebEndpointTab <WebEndpointTab
host={host({ id: "quick-connect-1" }, [endpoint({ access: "tunnel" })])} host={host({ id: "quick-connect-1" }, [endpoint({ access: "tunnel" })])}
@@ -278,8 +267,7 @@ describe("WebEndpointTab", () => {
it("resolves a direct endpoint normally on the same quick-connect host", async () => { it("resolves a direct endpoint normally on the same quick-connect host", async () => {
// The positive case: requireNumericHostId must be consulted only on the // The positive case: requireNumericHostId must be consulted only on the
// tunnel branch, or direct endpoints would break on unsaved hosts too. // tunnel branch, or direct endpoints would break on unsaved hosts too.
const { WebEndpointTab } = const { WebEndpointTab } = await import("./WebEndpointTab");
await import("@/features/web-endpoint/WebEndpointTab");
render( render(
<WebEndpointTab <WebEndpointTab
host={host({ id: "quick-connect-1" }, [endpoint({ access: "direct" })])} host={host({ id: "quick-connect-1" }, [endpoint({ access: "direct" })])}
@@ -302,8 +290,7 @@ describe("WebEndpointTab", () => {
// session cookie. // session cookie.
electron.isElectron.mockReturnValue(false); electron.isElectron.mockReturnValue(false);
openWebEndpointTunnel.mockResolvedValue(41234); openWebEndpointTunnel.mockResolvedValue(41234);
const { WebEndpointTab } = const { WebEndpointTab } = await import("./WebEndpointTab");
await import("@/features/web-endpoint/WebEndpointTab");
render( render(
<WebEndpointTab <WebEndpointTab
host={host({}, [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })])} host={host({}, [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })])}
@@ -327,8 +314,7 @@ describe("WebEndpointTab", () => {
it("refuses a loopback-bound tunnel in a browser instead of framing a dead port", async () => { it("refuses a loopback-bound tunnel in a browser instead of framing a dead port", async () => {
electron.isElectron.mockReturnValue(false); electron.isElectron.mockReturnValue(false);
const { WebEndpointTab } = const { WebEndpointTab } = await import("./WebEndpointTab");
await import("@/features/web-endpoint/WebEndpointTab");
render( render(
<WebEndpointTab <WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])} host={host({}, [endpoint({ access: "tunnel" })])}
@@ -347,8 +333,7 @@ describe("WebEndpointTab", () => {
it("refuses when the page host has no separate loopback spelling", async () => { it("refuses when the page host has no separate loopback spelling", async () => {
electron.isElectron.mockReturnValue(false); electron.isElectron.mockReturnValue(false);
pageHostname = "termix.example.com"; pageHostname = "termix.example.com";
const { WebEndpointTab } = const { WebEndpointTab } = await import("./WebEndpointTab");
await import("@/features/web-endpoint/WebEndpointTab");
render( render(
<WebEndpointTab <WebEndpointTab
host={host({}, [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })])} host={host({}, [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })])}
@@ -375,8 +360,7 @@ describe("WebEndpointTab", () => {
it("refuses a direct endpoint on the same host that serves Termix", async () => { it("refuses a direct endpoint on the same host that serves Termix", async () => {
electron.isElectron.mockReturnValue(false); electron.isElectron.mockReturnValue(false);
pageHostname = "termix.example"; pageHostname = "termix.example";
const { WebEndpointTab } = const { WebEndpointTab } = await import("./WebEndpointTab");
await import("@/features/web-endpoint/WebEndpointTab");
render( render(
<WebEndpointTab <WebEndpointTab
host={host({ ip: "termix.example" }, [ host={host({ ip: "termix.example" }, [
@@ -397,8 +381,7 @@ describe("WebEndpointTab", () => {
it("refuses a direct endpoint on a sub domain of the page host", async () => { it("refuses a direct endpoint on a sub domain of the page host", async () => {
electron.isElectron.mockReturnValue(false); electron.isElectron.mockReturnValue(false);
pageHostname = "termix.example.com"; pageHostname = "termix.example.com";
const { WebEndpointTab } = const { WebEndpointTab } = await import("./WebEndpointTab");
await import("@/features/web-endpoint/WebEndpointTab");
render( render(
<WebEndpointTab <WebEndpointTab
host={host({ ip: "ui.termix.example.com" }, [ host={host({ ip: "ui.termix.example.com" }, [
@@ -418,8 +401,7 @@ describe("WebEndpointTab", () => {
it("still frames a direct same-host endpoint on the desktop, whose jar has no jwt", async () => { it("still frames a direct same-host endpoint on the desktop, whose jar has no jwt", async () => {
electron.isElectron.mockReturnValue(true); electron.isElectron.mockReturnValue(true);
pageHostname = "termix.example"; pageHostname = "termix.example";
const { WebEndpointTab } = const { WebEndpointTab } = await import("./WebEndpointTab");
await import("@/features/web-endpoint/WebEndpointTab");
render( render(
<WebEndpointTab <WebEndpointTab
host={host({ ip: "termix.example" }, [ host={host({ ip: "termix.example" }, [
@@ -439,8 +421,7 @@ describe("WebEndpointTab", () => {
it("still frames a DIRECT endpoint on a hostname that refuses tunnels", async () => { it("still frames a DIRECT endpoint on a hostname that refuses tunnels", async () => {
electron.isElectron.mockReturnValue(false); electron.isElectron.mockReturnValue(false);
pageHostname = "termix.example.com"; pageHostname = "termix.example.com";
const { WebEndpointTab } = const { WebEndpointTab } = await import("./WebEndpointTab");
await import("@/features/web-endpoint/WebEndpointTab");
render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />); render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />);
await waitFor(() => await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute( expect(screen.getByTitle("Proxmox")).toHaveAttribute(
@@ -7,13 +7,13 @@ import {
resolveWebEndpointUrl, resolveWebEndpointUrl,
webEndpointRefusalReason, webEndpointRefusalReason,
type WebEndpointRefusalReason, type WebEndpointRefusalReason,
} from "@/lib/web-endpoint-url"; } from "./web-endpoint-url";
import { import {
allowInvalidCertificateForOrigin, allowInvalidCertificateForOrigin,
openWebEndpointExternally, openWebEndpointExternally,
openWebEndpointTunnel, openWebEndpointTunnel,
requireNumericHostId, requireNumericHostId,
} from "@/api/web-endpoint-api"; } from "./web-endpoint-api";
import { copyToClipboard } from "@/lib/clipboard"; import { copyToClipboard } from "@/lib/clipboard";
import { isElectron } from "@/lib/electron"; import { isElectron } from "@/lib/electron";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
+39
View File
@@ -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);
}
@@ -59,7 +59,7 @@ describe("openWebEndpointTunnel", () => {
// resolve to /ssh/ssh/... and 404 on every call. Asserted on the captured // resolve to /ssh/ssh/... and 404 on every call. Asserted on the captured
// runtime argument rather than by scanning source, so quoting style and // runtime argument rather than by scanning source, so quoting style and
// indirection cannot fool it. // indirection cannot fool it.
const { openWebEndpointTunnel } = await import("@/api/web-endpoint-api"); const { openWebEndpointTunnel } = await import("./web-endpoint-api");
await openWebEndpointTunnel(7, "e1"); await openWebEndpointTunnel(7, "e1");
expect(tunnelPost.mock.calls[0][0]).not.toMatch(/^\/ssh\//); expect(tunnelPost.mock.calls[0][0]).not.toMatch(/^\/ssh\//);
@@ -70,7 +70,7 @@ describe("openWebEndpointTunnel", () => {
}); });
it("returns the port", async () => { 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); await expect(openWebEndpointTunnel(7, "e1")).resolves.toBe(41234);
}); });
@@ -87,7 +87,7 @@ describe("openWebEndpointTunnel", () => {
}); });
tunnelPost.mockRejectedValue(axiosError); tunnelPost.mockRejectedValue(axiosError);
const { openWebEndpointTunnel, WebEndpointTunnelError } = const { openWebEndpointTunnel, WebEndpointTunnelError } =
await import("@/api/web-endpoint-api"); await import("./web-endpoint-api");
await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow( await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(
/Timed out reaching the endpoint port/, /Timed out reaching the endpoint port/,
@@ -106,20 +106,20 @@ describe("openWebEndpointTunnel", () => {
response: { status: 500, data: { error: { nested: true } } }, 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:/); await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(/generic:/);
}); });
it("treats a missing port as a failure rather than returning undefined", async () => { it("treats a missing port as a failure rather than returning undefined", async () => {
tunnelPost.mockResolvedValue({ data: {} }); 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/); await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(/no port/);
}); });
}); });
describe("requireNumericHostId", () => { describe("requireNumericHostId", () => {
it("accepts a saved host id and rejects a quick-connect one", async () => { 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); expect(requireNumericHostId("7")).toBe(7);
for (const bad of ["quick-connect-1", "", "0", "-3", "abc"]) { for (const bad of ["quick-connect-1", "", "0", "-3", "abc"]) {
expect(() => requireNumericHostId(bad)).toThrow(/saved host/); expect(() => requireNumericHostId(bad)).toThrow(/saved host/);
@@ -134,8 +134,7 @@ describe("requireNumericHostId", () => {
*/ */
describe("openWebEndpointExternally", () => { describe("openWebEndpointExternally", () => {
it("refuses shared-cookie browser windows before opening a tunnel", async () => { it("refuses shared-cookie browser windows before opening a tunnel", async () => {
const { openWebEndpointExternally } = const { openWebEndpointExternally } = await import("./web-endpoint-api");
await import("@/api/web-endpoint-api");
await expect(openWebEndpointExternally(host, endpoint())).rejects.toThrow( await expect(openWebEndpointExternally(host, endpoint())).rejects.toThrow(
/desktop app/, /desktop app/,
); );
@@ -149,8 +148,7 @@ describe("openWebEndpointExternally", () => {
configurable: true, configurable: true,
value: { invoke }, value: { invoke },
}); });
const { openWebEndpointExternally } = const { openWebEndpointExternally } = await import("./web-endpoint-api");
await import("@/api/web-endpoint-api");
await openWebEndpointExternally(host, endpoint({ ignoreCert: true })); await openWebEndpointExternally(host, endpoint({ ignoreCert: true }));
expect(invoke).toHaveBeenCalledWith("open-isolated-web-endpoint", { expect(invoke).toHaveBeenCalledWith("open-isolated-web-endpoint", {
url: "https://127.0.0.1:41234/", url: "https://127.0.0.1:41234/",
@@ -1,7 +1,4 @@
import { import { currentTunnelHost, resolveWebEndpointUrl } from "./web-endpoint-url";
currentTunnelHost,
resolveWebEndpointUrl,
} from "@/lib/web-endpoint-url";
import axios from "axios"; import axios from "axios";
import { handleApiError, tunnelApi } from "@/main-axios"; import { handleApiError, tunnelApi } from "@/main-axios";
import { isElectron } from "@/lib/electron"; import { isElectron } from "@/lib/electron";
@@ -5,7 +5,7 @@ import {
separatedTunnelHost, separatedTunnelHost,
sharesCookieSiteWithPage, sharesCookieSiteWithPage,
webEndpointRefusalReason, webEndpointRefusalReason,
} from "@/lib/web-endpoint-url"; } from "./web-endpoint-url";
import type { WebEndpoint } from "@/types/index"; import type { WebEndpoint } from "@/types/index";
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint { function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
@@ -5,8 +5,8 @@ import {
isWebEndpointPortValid, isWebEndpointPortValid,
webEndpointErrorKey, webEndpointErrorKey,
webEndpointRowError, webEndpointRowError,
} from "@/lib/web-endpoint-validation"; } from "./web-endpoint-validation";
import { normalizeWebEndpoints } from "../../../backend/database/routes/host-web-endpoints.js"; import { normalizeWebEndpoints } from "../../../src/backend/database/routes/host-web-endpoints.js";
import type { WebEndpoint } from "@/types/index"; import type { WebEndpoint } from "@/types/index";
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint { function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
+35
View File
@@ -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": []
}
@@ -146,7 +146,7 @@ function normalizeEndpoint(raw: unknown): WebEndpoint | null {
* Drops any endpoint it refuses rather than rejecting the whole host -- one * 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 * bad row must not make a host unsaveable or unlistable. The editor is
* responsible for telling the user before that happens * 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[] { export function normalizeWebEndpoints(raw: unknown): WebEndpoint[] {
if (!Array.isArray(raw)) return []; if (!Array.isArray(raw)) return [];
+4 -2
View File
@@ -21,7 +21,7 @@ import {
} from "./c2s-relay.js"; } from "./c2s-relay.js";
import { registerTunnelRoutes } from "./routes.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 { initializeAutoStartTunnels } from "./manager.js";
import { attachServicePortConflictHandler } from "../../utils/service-listen.js"; import { attachServicePortConflictHandler } from "../../utils/service-listen.js";
@@ -39,7 +39,9 @@ app.use((_req, res, next) => {
}); });
registerTunnelRoutes(app); 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 PORT = 30003;
const server = createServer(app); const server = createServer(app);
@@ -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);
}
+10
View File
@@ -99,6 +99,15 @@
* in SDK v1, see worker-bootstrap.ts), so there is currently no way for a * in SDK v1, see worker-bootstrap.ts), so there is currently no way for a
* worker-tier plugin to make an outbound HTTP request. * 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 * 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 * 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 * 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<string> = new Set([
"network-topology", "network-topology",
"tailscale", "tailscale",
"workspaces", "workspaces",
"web-endpoint",
]); ]);
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner"; export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
+6 -2
View File
@@ -300,11 +300,15 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
// its router with its own dispatcher in database.ts on activate (/ai, // its router with its own dispatcher in database.ts on activate (/ai,
// /proxmox, /guacamole, /fleets, /automations, /network-topology, // /proxmox, /guacamole, /fleets, /automations, /network-topology,
// /workspaces), so disabling any of them makes its routes 404 instead of // /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 // dwell rechecks, docker-event polling, history pruning) also starts
// from its own activate() rather than here. See plugins/ai, // from its own activate() rather than here. See plugins/ai,
// plugins/proxmox, plugins/remote-desktop, plugins/fleets, // 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/tunnel/index.js");
await import("./hosts/file-manager/index.js"); await import("./hosts/file-manager/index.js");
await import("./hosts/tmux/index.js"); await import("./hosts/tmux/index.js");
@@ -415,3 +415,54 @@ describe("bundled workspaces plugin", () => {
expect(source).toMatch(/export async function deactivate/); 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/);
});
});
+20 -2
View File
@@ -39,7 +39,7 @@ function manifest(overrides: Record<string, unknown> = {}) {
} }
describe("first-party allowlist", () => { 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([ expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
"ai", "ai",
"automations", "automations",
@@ -51,11 +51,12 @@ describe("first-party allowlist", () => {
"remote-desktop", "remote-desktop",
"ssh-terminal", "ssh-terminal",
"tailscale", "tailscale",
"web-endpoint",
"workspaces", "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("ssh-terminal")).toBe(true);
expect(isFirstParty("docker")).toBe(true); expect(isFirstParty("docker")).toBe(true);
expect(isFirstParty("host-metrics")).toBe(true); expect(isFirstParty("host-metrics")).toBe(true);
@@ -67,6 +68,7 @@ describe("first-party allowlist", () => {
expect(isFirstParty("network-topology")).toBe(true); expect(isFirstParty("network-topology")).toBe(true);
expect(isFirstParty("tailscale")).toBe(true); expect(isFirstParty("tailscale")).toBe(true);
expect(isFirstParty("workspaces")).toBe(true); expect(isFirstParty("workspaces")).toBe(true);
expect(isFirstParty("web-endpoint")).toBe(true);
expect(isFirstParty("ssh-terminal-pro")).toBe(false); expect(isFirstParty("ssh-terminal-pro")).toBe(false);
expect(isFirstParty("community-plugin")).toBe(false); expect(isFirstParty("community-plugin")).toBe(false);
expect(isFirstParty("")).toBe(false); expect(isFirstParty("")).toBe(false);
@@ -98,6 +100,9 @@ describe("runsInProcess", () => {
expect(runsInProcess("workspaces", [TRANSPORT_OWNER_CAPABILITY])).toBe( expect(runsInProcess("workspaces", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true, 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 // On the list but not asking for it: stays in a worker, so the manifest
// remains an honest description of what the plugin does. // 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("network-topology", ["hosts.read"])).toBe(false);
expect(runsInProcess("tailscale", ["hosts.read"])).toBe(false); expect(runsInProcess("tailscale", ["hosts.read"])).toBe(false);
expect(runsInProcess("workspaces", ["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. // Asking for it but not on the list.
expect( expect(
@@ -251,6 +257,18 @@ describe("manifest gate on the reserved capability", () => {
expect(parsed?.id).toBe("workspaces"); 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", () => { it("still accepts an ordinary manifest that never mentions it", () => {
const { manifest: parsed, errors } = parseManifest( const { manifest: parsed, errors } = parseManifest(
manifest({ permissions: ["hosts.read"] }), manifest({ permissions: ["hosts.read"] }),
@@ -1,8 +1,9 @@
import type express from "express"; import type express from "express";
import { readFile } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const resolveHostById = vi.hoisted(() => vi.fn()); const resolveHostById = vi.hoisted(() => vi.fn());
vi.mock("../../hosts/host-resolver.js", () => ({ resolveHostById })); vi.mock("../../../hosts/host-resolver.js", () => ({ resolveHostById }));
const manager = vi.hoisted(() => ({ const manager = vi.hoisted(() => ({
activeTunnelRuntimes: new Map<string, unknown>(), activeTunnelRuntimes: new Map<string, unknown>(),
@@ -11,12 +12,12 @@ const manager = vi.hoisted(() => ({
connectSSHTunnel: vi.fn(), connectSSHTunnel: vi.fn(),
cleanupTunnelResources: vi.fn(async () => undefined), 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() }))); 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() }) }, 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 () => { it("refuses a host the user cannot resolve, without opening anything", async () => {
resolveHostById.mockResolvedValue(null); resolveHostById.mockResolvedValue(null);
const { handleWebEndpointOpen } = const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js"); await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response(); const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res); 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. // stored enable_web_ui is still 0. The UI reads as off in that state.
resolveHostById.mockResolvedValue(host({ enableWebUi: false })); resolveHostById.mockResolvedValue(host({ enableWebUi: false }));
const { handleWebEndpointOpen } = const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js"); await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response(); const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res); 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 () => { it("refuses an endpoint id that matches nothing", async () => {
const { handleWebEndpointOpen } = const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js"); await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response(); const res = response();
await handleWebEndpointOpen( await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "nope" }), request({ hostId: 7, endpointId: "nope" }),
@@ -137,7 +138,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
it("returns the real bound port", async () => { it("returns the real bound port", async () => {
tunnelConnectsOn(41234); tunnelConnectsOn(41234);
const { handleWebEndpointOpen } = const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js"); await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response(); const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res); 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. // select a different strategy and the forward would never bind locally.
tunnelConnectsOn(41234); tunnelConnectsOn(41234);
const { handleWebEndpointOpen } = const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js"); await import("../../../../../plugins/web-endpoint/backend/routes.js");
await handleWebEndpointOpen( await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }), request({ hostId: 7, endpointId: "e1" }),
response(), response(),
@@ -170,7 +171,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
it("binds loopback by default and the endpoint's choice when given", async () => { it("binds loopback by default and the endpoint's choice when given", async () => {
tunnelConnectsOn(41234); tunnelConnectsOn(41234);
const { handleWebEndpointOpen } = const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js"); await import("../../../../../plugins/web-endpoint/backend/routes.js");
await handleWebEndpointOpen( await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }), request({ hostId: 7, endpointId: "e1" }),
response(), response(),
@@ -199,7 +200,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
it("reuses a live tunnel instead of opening a second one", async () => { it("reuses a live tunnel instead of opening a second one", async () => {
tunnelConnectsOn(41234); tunnelConnectsOn(41234);
const { handleWebEndpointOpen } = const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js"); await import("../../../../../plugins/web-endpoint/backend/routes.js");
const first = response(); const first = response();
await handleWebEndpointOpen( await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }), 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 () => { it("reopens when the endpoint's target changed under a live tunnel", async () => {
tunnelConnectsOn(41234); tunnelConnectsOn(41234);
const { handleWebEndpointOpen } = const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js"); await import("../../../../../plugins/web-endpoint/backend/routes.js");
await handleWebEndpointOpen( await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }), request({ hostId: 7, endpointId: "e1" }),
response(), response(),
@@ -250,7 +251,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
new Error("Channel open failure: connect failed"), new Error("Channel open failure: connect failed"),
); );
const { handleWebEndpointOpen } = const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js"); await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response(); const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res); await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
@@ -275,7 +276,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
}, },
); );
const { handleWebEndpointOpen } = const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js"); await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response(); const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res); 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 () => { it("rejects a malformed request before touching the database", async () => {
const { handleWebEndpointOpen } = const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js"); await import("../../../../../plugins/web-endpoint/backend/routes.js");
for (const body of [ for (const body of [
{}, {},
{ hostId: "7", endpointId: "e1" }, { 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 * The client resolves "/tunnel/web-endpoint/open" against a baseURL that
* already ends in /ssh, and nginx proxies /ssh through with the path intact -- * 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 * so the server must serve the FULL "/ssh/tunnel/web-endpoint/open" path.
* in routes.ts does. Registering the unprefixed form 404s every call, and no * Serving the unprefixed form 404s every call, and no handler unit test
* handler unit test notices because they call the handler directly. * notices because they call the handler directly.
* *
* This was caught only by opening a real tunnel against a deployed build. * 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", () => { describe("route registration", () => {
it("registers the /ssh-prefixed path the client actually calls", async () => { it("registers /open on the router the dispatcher forwards to", async () => {
const posts: string[] = []; const routes =
const app = { await import("../../../../../plugins/web-endpoint/backend/routes.js");
post: (path: string) => { const dispatch =
posts.push(path); 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 } = routes.startWebEndpointService();
await import("../../hosts/tunnel/web-endpoint-routes.js");
registerWebEndpointRoutes(app);
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"');
}); });
}); });
+1
View File
@@ -44,6 +44,7 @@ const BUILT_IN_TABS_BY_PLUGIN: Record<string, string[]> = {
ai: ["ai"], ai: ["ai"],
fleets: ["fleet-inventory"], fleets: ["fleet-inventory"],
"network-topology": ["network_graph"], "network-topology": ["network_graph"],
"web-endpoint": ["web-endpoint"],
}; };
let pluginEnabled = new Map<string, boolean>(); let pluginEnabled = new Map<string, boolean>();
+1 -1
View File
@@ -89,7 +89,7 @@ const loadDockerManager = () =>
})); }));
const DockerManager = lazy(loadDockerManager); const DockerManager = lazy(loadDockerManager);
const loadWebEndpointTab = () => const loadWebEndpointTab = () =>
import("@/features/web-endpoint/WebEndpointTab").then((m) => ({ import("../../../plugins/web-endpoint/frontend/WebEndpointTab").then((m) => ({
default: m.WebEndpointTab, default: m.WebEndpointTab,
})); }));
const WebEndpointTab = lazy(loadWebEndpointTab); const WebEndpointTab = lazy(loadWebEndpointTab);
+2 -2
View File
@@ -4,7 +4,7 @@ import { isElectron } from "@/lib/electron";
import { import {
webEndpointRefusalReason, webEndpointRefusalReason,
type WebEndpointRefusalReason, type WebEndpointRefusalReason,
} from "@/lib/web-endpoint-url"; } from "../../../plugins/web-endpoint/frontend/web-endpoint-url";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Input } from "@/components/input"; import { Input } from "@/components/input";
import { Checkbox } from "@/components/checkbox"; import { Checkbox } from "@/components/checkbox";
@@ -28,7 +28,7 @@ import {
isWebEndpointPortValid, isWebEndpointPortValid,
webEndpointErrorKey, webEndpointErrorKey,
webEndpointRowError, 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 * crypto.randomUUID is undefined outside a secure context, so a plain-http web
+1 -1
View File
@@ -87,7 +87,7 @@ import {
recordHostActionPreference, recordHostActionPreference,
} from "@/lib/local-adaptive-preferences"; } from "@/lib/local-adaptive-preferences";
import type { WebEndpoint } from "@/types/index"; 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"; import { isTabTypeAvailable } from "@/shell/pluginLoader";
export function statusCheckEnabled(host: Host): boolean { export function statusCheckEnabled(host: Host): boolean {
+2 -1
View File
@@ -35,7 +35,8 @@
"plugins/fleets/backend/**/*.ts", "plugins/fleets/backend/**/*.ts",
"plugins/automations/backend/**/*.ts", "plugins/automations/backend/**/*.ts",
"plugins/network-topology/backend/**/*.ts", "plugins/network-topology/backend/**/*.ts",
"plugins/workspaces/backend/**/*.ts" "plugins/workspaces/backend/**/*.ts",
"plugins/web-endpoint/backend/**/*.ts"
], ],
"exclude": ["src/**/*.test.ts"] "exclude": ["src/**/*.test.ts"]
} }