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

@@ -146,7 +146,7 @@ function normalizeEndpoint(raw: unknown): WebEndpoint | null {
* Drops any endpoint it refuses rather than rejecting the whole host -- one
* bad row must not make a host unsaveable or unlistable. The editor is
* responsible for telling the user before that happens
* (src/ui/lib/web-endpoint-validation.ts).
* (plugins/web-endpoint/frontend/web-endpoint-validation.ts).
*/
export function normalizeWebEndpoints(raw: unknown): WebEndpoint[] {
if (!Array.isArray(raw)) return [];
+4 -2
View File
@@ -21,7 +21,7 @@ import {
} from "./c2s-relay.js";
import { registerTunnelRoutes } from "./routes.js";
import { registerWebEndpointRoutes } from "./web-endpoint-routes.js";
import webEndpointDispatch from "./web-endpoint-dispatch.js";
import { initializeAutoStartTunnels } from "./manager.js";
import { attachServicePortConflictHandler } from "../../utils/service-listen.js";
@@ -39,7 +39,9 @@ app.use((_req, res, next) => {
});
registerTunnelRoutes(app);
registerWebEndpointRoutes(app);
// Always mounted; the Web Endpoint plugin registers its router behind this on
// activate and clears it on deactivate. See web-endpoint-dispatch.ts.
app.use("/ssh/tunnel/web-endpoint", webEndpointDispatch);
const PORT = 30003;
const server = createServer(app);
@@ -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);
}
@@ -1,310 +0,0 @@
import type express from "express";
import { Client } from "ssh2";
import { AuthManager } from "../../utils/auth-manager.js";
import { tunnelLogger } from "../../utils/logger.js";
import { parseWebUiConfig } from "../../database/routes/host-web-endpoints.js";
import {
activeTunnelRuntimes,
cleanupTunnelResources,
connectSSHTunnel,
connectionStatus,
tunnelConnecting,
} from "./manager.js";
import { forwardOut } from "./ssh-primitives.js";
import { buildWebEndpointTunnelName } from "./utils.js";
import type { TunnelConfig, WebEndpoint } from "../../../types/index.js";
/** Matches the spec's ten minutes. */
const WEB_ENDPOINT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
/**
* Long enough for a slow SSH handshake, short enough that a wedged connection
* does not hold the HTTP request open indefinitely. One second past the
* manager's own connection timeout so the manager reports first.
*/
const TUNNEL_READY_TIMEOUT_MS = 61_000;
const TUNNEL_READY_POLL_MS = 100;
const TARGET_PROBE_TIMEOUT_MS = 10_000;
/**
* What the live forward was built for. Compared on reuse so an endpoint edited
* under a live tunnel is reopened rather than silently forwarding to the old
* target.
*
* JSON rather than a joined string: no separator can be smuggled in by a field
* value, and the result stays readable in a log. The SSH identity is included
* because a host repointed at a different server must also invalidate the
* forward, not just a changed endpoint port.
*/
function targetFingerprint(
endpoint: Pick<
WebEndpoint,
"scheme" | "port" | "access" | "bindHost" | "localPort"
>,
host: { ip?: string; port?: number; username?: string },
): string {
return JSON.stringify({
scheme: endpoint.scheme,
port: endpoint.port,
access: endpoint.access,
bindHost: endpoint.bindHost ?? null,
localPort: endpoint.localPort ?? null,
ip: host.ip ?? null,
sshPort: host.port ?? null,
username: host.username ?? null,
});
}
const openedTargets = new Map<string, string>();
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* connectSSHTunnel(config) returns Promise<void> and NEVER REJECTS: every
* failure path inside it reports over broadcastTunnelStatus and returns
* normally, and its own promise resolves right after conn.connect(), long
* before the SSH "ready" event that would populate activeTunnelRuntimes.
*
* So "awaited" is not "connected". This is what actually waits.
*
* Polling the maps the manager already exports, rather than adding a
* promise-returning variant to manager.ts: every existing caller of
* connectSSHTunnel is fire-and-forget by design (the connect route responds
* "Connection request received" immediately and reports asynchronously over
* the status broadcast), so changing that contract to serve one new caller is
* a far larger blast radius than polling here.
*/
async function waitForTunnelSettled(tunnelName: string): Promise<void> {
const deadline = Date.now() + TUNNEL_READY_TIMEOUT_MS;
while (Date.now() < deadline) {
if (activeTunnelRuntimes.has(tunnelName)) return;
const status = connectionStatus.get(tunnelName);
if (!tunnelConnecting.has(tunnelName) && status && !status.connected) {
throw new Error(status.reason || "The endpoint tunnel failed to connect");
}
await sleep(TUNNEL_READY_POLL_MS);
}
throw new Error("Timed out establishing the endpoint tunnel");
}
/**
* Opens one channel to the target and closes it immediately.
*
* Without this the route returns 200 the moment tcpServer.listen succeeds, and
* forwardOut is only attempted per inbound socket -- where a failure is
* swallowed by the socket's own error handling. The user then gets a blank
* frame and no message for the single likeliest error: nothing is actually
* listening on that port. Costs one round trip per open.
*/
async function probeEndpointTarget(
sourceClient: Client,
targetPort: number,
tunnelName: string,
): Promise<void> {
const channel = await Promise.race([
forwardOut(sourceClient, "127.0.0.1", targetPort, tunnelName),
new Promise<never>((_resolve, reject) =>
setTimeout(
() => reject(new Error("Timed out reaching the endpoint port")),
TARGET_PROBE_TIMEOUT_MS,
),
),
]);
try {
channel.end();
} catch {
// Already gone; the probe succeeded either way.
}
}
export async function handleWebEndpointOpen(
req: express.Request,
res: express.Response,
): Promise<express.Response | void> {
const userId = (req as { userId?: string }).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const { hostId, endpointId } = req.body ?? {};
if (
!Number.isInteger(hostId) ||
hostId < 1 ||
typeof endpointId !== "string"
) {
return res.status(400).json({ error: "Invalid web endpoint request" });
}
// Deliberately NOT gated to the desktop. The forward binds wherever this
// backend runs, exactly as the server tunnels feature does, and the
// endpoint's own bindHost decides whether that is reachable from the
// browser -- loopback for the desktop, an address the server answers on for
// a web deployment.
const { resolveHostById } = await import("../host-resolver.js");
const host = await resolveHostById(hostId, userId);
if (!host) {
return res.status(403).json({ error: "Host not found or access denied" });
}
// All three write paths null the config on disable, so a configured-but-
// disabled host is not normally reachable -- except a bulk update that sends
// only { webUiConfig } while the stored enable_web_ui is still 0. The UI
// reads as off everywhere in that state, so the endpoint merely being
// present in the config is not on its own a licence to open a tunnel.
if (!host.enableWebUi) {
return res
.status(400)
.json({ error: "Web endpoints are not enabled for this host" });
}
// Re-normalized rather than trusted: the stored value predates any later
// tightening of the rules, and this is the value a forward is built from.
const endpoint = parseWebUiConfig(host.webUiConfig).endpoints.find(
(candidate) => candidate.id === endpointId,
);
if (!endpoint) {
return res.status(400).json({ error: "Web endpoint not found" });
}
if (endpoint.access !== "tunnel") {
return res
.status(400)
.json({ error: "This endpoint does not use a tunnel" });
}
const tunnelName = buildWebEndpointTunnelName(hostId, endpointId);
const fingerprint = targetFingerprint(endpoint, host);
const existing = activeTunnelRuntimes.get(tunnelName);
if (existing) {
const recorded = openedTargets.get(tunnelName);
// The runtime is the source of truth and openedTargets is derived from it,
// so an ABSENT fingerprint means reuse, not staleness. A reserved-prefixed
// runtime can only have been created by this route.
if (recorded === undefined || recorded === fingerprint) {
openedTargets.set(tunnelName, fingerprint);
return res.status(200).json({ port: existing.bindPort });
}
tunnelLogger.info("Reopening web endpoint tunnel after config change", {
operation: "web_endpoint_reopen",
tunnelName,
});
// Forced, for the same reason handleDisconnect's reserved-name branch
// forces: cleanupTunnelResources no-ops while tunnelConnecting holds the
// name, and a web tunnel has no retry pass to self-heal a runtime left
// behind. An unforced call could leave a dead listener registered.
await cleanupTunnelResources(tunnelName, true);
openedTargets.delete(tunnelName);
}
const tunnelConfig: TunnelConfig = {
name: tunnelName,
scope: "s2s",
mode: "local",
tunnelType: "local",
// Loopback unless the endpoint asks otherwise. A non-loopback bind
// publishes the target's web UI to anyone who can reach the port, with no
// authentication in front of it -- the same trade the server tunnels
// feature exposes, and only ever an explicit per-endpoint choice. The
// value is validated to a bare host literal by normalizeBindHost.
bindHost: endpoint.bindHost || "127.0.0.1",
sourceHostId: host.id ?? hostId,
tunnelIndex: 0,
requestingUserId: userId,
hostName: host.name || `${host.username}@${host.ip}`,
sourceIP: host.ip,
sourceSSHPort: host.port,
sourceUsername: host.username,
sourcePassword: host.password,
sourceAuthMethod: host.authType,
sourceSSHKey: host.key,
sourceKeyPassword: host.keyPassword,
sourceKeyType: host.keyType,
sourceCredentialId: host.credentialId,
// connectSSHTunnel reads sourceUserId when building its client.
sourceUserId: host.userId,
endpointIP: host.ip,
endpointSSHPort: host.port,
endpointUsername: host.username,
// Load-bearing: connectSSHTunnel picks its strategy via
// shouldEstablishDirectTunnel -> isSingleHostTunnel, which returns true
// when endpointHost is "127.0.0.1". That is what selects
// establishDirectTunnel's local branch -- the bind-locally-and-forwardOut
// path this feature needs. Setting only targetHost would NOT select it.
endpointHost: "127.0.0.1",
endpointAuthMethod: host.authType,
endpointSSHKey: host.key,
endpointKeyPassword: host.keyPassword,
endpointKeyType: host.keyType,
endpointCredentialId: host.credentialId,
endpointUserId: host.userId,
// A fixed port when the endpoint names one, as the server tunnels
// feature's Source Port does -- a container can only publish ports it
// knows in advance. Otherwise 0 and the kernel picks; the manager reports
// back what it actually bound either way.
sourcePort: endpoint.localPort ?? 0,
endpointPort: endpoint.port,
// Documentation only: maxRetries 0 does NOT disable retries, because the
// manager reads `maxRetries || 3`. Retry is actually suppressed by the
// reserved-name early return in handleDisconnect.
maxRetries: 0,
retryInterval: 0,
autoStart: false,
isPinned: Boolean(host.pin),
idleTimeoutMs: WEB_ENDPOINT_IDLE_TIMEOUT_MS,
useSocks5: Boolean(host.useSocks5),
socks5Host: host.socks5Host,
socks5Port: host.socks5Port,
socks5Username: host.socks5Username,
socks5Password: host.socks5Password,
socks5ProxyChain: host.socks5ProxyChain,
} as TunnelConfig;
try {
await connectSSHTunnel(tunnelConfig);
await waitForTunnelSettled(tunnelName);
const runtime = activeTunnelRuntimes.get(tunnelName);
if (!runtime) {
throw new Error("The endpoint tunnel closed before it could be used");
}
await probeEndpointTarget(runtime.sourceClient, endpoint.port, tunnelName);
openedTargets.set(tunnelName, fingerprint);
return res.status(200).json({ port: runtime.bindPort });
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
tunnelLogger.error("Failed to open web endpoint tunnel", error, {
operation: "web_endpoint_open_failed",
tunnelName,
});
// Leave nothing half-built: a listener with no working forward behind it
// would be handed out on the next open as a live tunnel.
await cleanupTunnelResources(tunnelName, true).catch(() => undefined);
openedTargets.delete(tunnelName);
return res.status(502).json({ error: reason });
}
}
export function registerWebEndpointRoutes(app: express.Express): void {
// Constructed here rather than at module scope so importing this module for
// a handler unit test does not construct the auth singleton.
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
// The "/ssh" prefix is part of the path this service serves, exactly as
// every route in routes.ts carries it -- nginx proxies /ssh through with the
// path intact. Registering "/tunnel/..." here 404s every call, since the
// client resolves "/tunnel/web-endpoint/open" against a baseURL that already
// ends in /ssh.
app.post(
"/ssh/tunnel/web-endpoint/open",
authenticateJWT,
handleWebEndpointOpen,
);
}
+10
View File
@@ -99,6 +99,15 @@
* in SDK v1, see worker-bootstrap.ts), so there is currently no way for a
* worker-tier plugin to make an outbound HTTP request.
*
* web-endpoint is here for the same reason ssh-terminal is, on a smaller
* scale: its open route polls the tunnel manager's live maps
* (activeTunnelRuntimes, connectionStatus, tunnelConnecting), builds a
* TunnelConfig from plaintext host credentials that ctx.hosts deliberately
* withholds, and hands the resulting live ssh2.Client to forwardOut to probe
* the target before reporting success. A structured-clone boundary can carry
* none of those three, so this is a genuine transport dependency rather than
* the URL-stability concession network-topology and workspaces make.
*
* workspaces is here for the same reason network-topology is: its routes are
* plain per-user JSON CRUD against a single repository, and would fit the
* worker ctx fine on their own. It stays in-process to keep its existing
@@ -119,6 +128,7 @@ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
"network-topology",
"tailscale",
"workspaces",
"web-endpoint",
]);
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,
// /proxmox, /guacamole, /fleets, /automations, /network-topology,
// /workspaces), so disabling any of them makes its routes 404 instead of
// leaving a dead import here. Automations' scheduler (due schedules,
// leaving a dead import here. Web Endpoint works the same way, except its
// dispatcher is mounted by the tunnel service below rather than by
// database.ts, since its route is served on port 30003. Automations'
// scheduler (due schedules,
// dwell rechecks, docker-event polling, history pruning) also starts
// from its own activate() rather than here. See plugins/ai,
// plugins/proxmox, plugins/remote-desktop, plugins/fleets,
// plugins/automations, plugins/network-topology and plugins/workspaces.
// plugins/automations, plugins/network-topology, plugins/workspaces and
// plugins/web-endpoint.
await import("./hosts/tunnel/index.js");
await import("./hosts/file-manager/index.js");
await import("./hosts/tmux/index.js");
@@ -415,3 +415,54 @@ describe("bundled workspaces plugin", () => {
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled web-endpoint plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "web-endpoint");
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(
readBundledManifest("web-endpoint"),
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("web-endpoint");
expect(manifest?.category).toBe("Networking");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("web-endpoint"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("web-endpoint")).toBe(true);
expect(runsInProcess("web-endpoint", manifest!.permissions)).toBe(true);
});
// The column this points at stays on core's ssh_data table, so a typo here
// would silently detach the host-editor checkbox from its storage.
it("contributes the enableWebUi host capability", () => {
const { manifest } = parseManifest(readBundledManifest("web-endpoint"));
expect(manifest?.contributes?.hostCapability).toMatchObject({
key: "enableWebUi",
});
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"web-endpoint",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
+20 -2
View File
@@ -39,7 +39,7 @@ function manifest(overrides: Record<string, unknown> = {}) {
}
describe("first-party allowlist", () => {
it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale and workspaces", () => {
it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale, workspaces and web-endpoint", () => {
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
"ai",
"automations",
@@ -51,11 +51,12 @@ describe("first-party allowlist", () => {
"remote-desktop",
"ssh-terminal",
"tailscale",
"web-endpoint",
"workspaces",
]);
});
it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale and workspaces and nothing else", () => {
it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale, workspaces and web-endpoint and nothing else", () => {
expect(isFirstParty("ssh-terminal")).toBe(true);
expect(isFirstParty("docker")).toBe(true);
expect(isFirstParty("host-metrics")).toBe(true);
@@ -67,6 +68,7 @@ describe("first-party allowlist", () => {
expect(isFirstParty("network-topology")).toBe(true);
expect(isFirstParty("tailscale")).toBe(true);
expect(isFirstParty("workspaces")).toBe(true);
expect(isFirstParty("web-endpoint")).toBe(true);
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
expect(isFirstParty("community-plugin")).toBe(false);
expect(isFirstParty("")).toBe(false);
@@ -98,6 +100,9 @@ describe("runsInProcess", () => {
expect(runsInProcess("workspaces", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
expect(runsInProcess("web-endpoint", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
// On the list but not asking for it: stays in a worker, so the manifest
// remains an honest description of what the plugin does.
@@ -112,6 +117,7 @@ describe("runsInProcess", () => {
expect(runsInProcess("network-topology", ["hosts.read"])).toBe(false);
expect(runsInProcess("tailscale", ["hosts.read"])).toBe(false);
expect(runsInProcess("workspaces", ["hosts.read"])).toBe(false);
expect(runsInProcess("web-endpoint", ["hosts.read"])).toBe(false);
// Asking for it but not on the list.
expect(
@@ -251,6 +257,18 @@ describe("manifest gate on the reserved capability", () => {
expect(parsed?.id).toBe("workspaces");
});
it("allows it for web-endpoint", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "web-endpoint",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("web-endpoint");
});
it("still accepts an ordinary manifest that never mentions it", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({ permissions: ["hosts.read"] }),
@@ -1,8 +1,9 @@
import type express from "express";
import { readFile } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const resolveHostById = vi.hoisted(() => vi.fn());
vi.mock("../../hosts/host-resolver.js", () => ({ resolveHostById }));
vi.mock("../../../hosts/host-resolver.js", () => ({ resolveHostById }));
const manager = vi.hoisted(() => ({
activeTunnelRuntimes: new Map<string, unknown>(),
@@ -11,12 +12,12 @@ const manager = vi.hoisted(() => ({
connectSSHTunnel: vi.fn(),
cleanupTunnelResources: vi.fn(async () => undefined),
}));
vi.mock("../../hosts/tunnel/manager.js", () => manager);
vi.mock("../../../hosts/tunnel/manager.js", () => manager);
const forwardOut = vi.hoisted(() => vi.fn(async () => ({ end: vi.fn() })));
vi.mock("../../hosts/tunnel/ssh-primitives.js", () => ({ forwardOut }));
vi.mock("../../../hosts/tunnel/ssh-primitives.js", () => ({ forwardOut }));
vi.mock("../../utils/auth-manager.js", () => ({
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => ({ createAuthMiddleware: () => vi.fn() }) },
}));
@@ -102,7 +103,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
it("refuses a host the user cannot resolve, without opening anything", async () => {
resolveHostById.mockResolvedValue(null);
const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
@@ -115,7 +116,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
// stored enable_web_ui is still 0. The UI reads as off in that state.
resolveHostById.mockResolvedValue(host({ enableWebUi: false }));
const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
@@ -125,7 +126,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
it("refuses an endpoint id that matches nothing", async () => {
const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response();
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "nope" }),
@@ -137,7 +138,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
it("returns the real bound port", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
@@ -151,7 +152,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
// select a different strategy and the forward would never bind locally.
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
await import("../../../../../plugins/web-endpoint/backend/routes.js");
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
@@ -170,7 +171,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
it("binds loopback by default and the endpoint's choice when given", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
await import("../../../../../plugins/web-endpoint/backend/routes.js");
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
@@ -199,7 +200,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
it("reuses a live tunnel instead of opening a second one", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const first = response();
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
@@ -218,7 +219,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
it("reopens when the endpoint's target changed under a live tunnel", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
await import("../../../../../plugins/web-endpoint/backend/routes.js");
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
@@ -250,7 +251,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
new Error("Channel open failure: connect failed"),
);
const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
@@ -275,7 +276,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
},
);
const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
@@ -285,7 +286,7 @@ describe("POST /tunnel/web-endpoint/open", () => {
it("rejects a malformed request before touching the database", async () => {
const { handleWebEndpointOpen } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
await import("../../../../../plugins/web-endpoint/backend/routes.js");
for (const body of [
{},
{ hostId: "7", endpointId: "e1" },
@@ -302,25 +303,48 @@ describe("POST /tunnel/web-endpoint/open", () => {
/**
* The client resolves "/tunnel/web-endpoint/open" against a baseURL that
* already ends in /ssh, and nginx proxies /ssh through with the path intact --
* so the server must register the FULL "/ssh/tunnel/..." path, as every route
* in routes.ts does. Registering the unprefixed form 404s every call, and no
* handler unit test notices because they call the handler directly.
* so the server must serve the FULL "/ssh/tunnel/web-endpoint/open" path.
* Serving the unprefixed form 404s every call, and no handler unit test
* notices because they call the handler directly.
*
* This was caught only by opening a real tunnel against a deployed build.
*
* Since the plugin migration that path is composed of two halves: the mount
* point in src/backend/hosts/tunnel/index.ts and the route on the plugin's own
* router. Either half drifting breaks the URL, so both are asserted here.
*/
describe("route registration", () => {
it("registers the /ssh-prefixed path the client actually calls", async () => {
const posts: string[] = [];
const app = {
post: (path: string) => {
posts.push(path);
it("registers /open on the router the dispatcher forwards to", async () => {
const routes =
await import("../../../../../plugins/web-endpoint/backend/routes.js");
const dispatch =
await import("../../../hosts/tunnel/web-endpoint-dispatch.js");
const registered: unknown[] = [];
vi.spyOn(dispatch, "registerWebEndpointRouter").mockImplementation(
(router) => {
registered.push(router);
},
} as unknown as express.Express;
);
const { registerWebEndpointRoutes } =
await import("../../hosts/tunnel/web-endpoint-routes.js");
registerWebEndpointRoutes(app);
routes.startWebEndpointService();
expect(posts).toContain("/ssh/tunnel/web-endpoint/open");
expect(registered).toHaveLength(1);
const paths = (
routes.router as unknown as {
stack: Array<{ route?: { path: string; methods: { post?: boolean } } }>;
}
).stack
.filter((layer) => layer.route?.methods.post)
.map((layer) => layer.route?.path);
expect(paths).toContain("/open");
});
it("is mounted so the full client path resolves", async () => {
const source = await readFile(
new URL("../../../hosts/tunnel/index.ts", import.meta.url),
"utf8",
);
expect(source).toContain('app.use("/ssh/tunnel/web-endpoint"');
});
});