mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: move web endpoint into a first-party plugin
This commit is contained in:
1 parent
6f0dff58a3
commit
6e058f6e9e
26 files changed
+496
-121
No files matched your search
@@ -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.
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
-23
@@ -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();
|
||||||
}
|
}
|
||||||
+20
-39
@@ -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(
|
||||||
+2
-2
@@ -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";
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+8
-10
@@ -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";
|
||||||
+1
-1
@@ -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 {
|
||||||
File renamed without changes.
+2
-2
@@ -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 {
|
||||||
File renamed without changes.
@@ -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 [];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"] }),
|
||||||
|
|||||||
+52
-28
@@ -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"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user