feat: convert ssh tunnels into a plugin

This commit is contained in:
LukeGus committed 2026-09-23 17:27:28 -05:00
1 parent c85c1fdb86
commit 1682861853
220 files changed
+36928 -11374

No files matched your search

+9 -15
View File
@@ -22,12 +22,9 @@ Each host can define up to 16 endpoints (`MAX_WEB_ENDPOINTS`). An endpoint is ei
## What stays in core
This plugin owns its lifecycle, not all of its code. Four pieces deliberately stay in
This plugin owns its lifecycle, not all of its code. Three 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
@@ -44,21 +41,18 @@ Termix proper:
The `enableWebUi` and `webUiConfig` columns stay on core's `ssh_data` table, declared
here through `contributes.hostCapability`.
## Why it runs in-process
## Tunnels
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`.
A tunnel endpoint opens through the tunnels plugin, a hard dependency. `POST /open`
calls `forward()` on its `tunnels.access` service, which connects through core's SSH
pipeline, binds the local listener, probes the target once and only then returns the
bound port. The forward runs under the reserved `web:<hostId>:<endpointId>` name, so
the tunnels plugin never retries it and closes it after ten idle minutes.
## 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.
Core mounts the route at `/plugin-api/web-endpoint/open` through `ctx.http.router()`,
which the generic `/plugin-api/` block in both nginx configs already proxies.
## Tests
+9 -6
View File
@@ -14,12 +14,15 @@
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": [
"hosts:read",
"credentials:use",
"ssh:connect",
"network:serve",
"ui:surface"
"capabilities": ["hosts:read", "network:serve", "ui:surface"],
"dependencies": {
"tunnels": "^1.0.0"
},
"requires": [
{
"service": "tunnels.access",
"versionRange": "^1.0.0"
}
],
"contributes": {
"tabs": [
+2 -4
View File
@@ -1,11 +1,9 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { startWebEndpointService, stopWebEndpointService } from "./routes.js";
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
startWebEndpointService(ctx.http.router());
startWebEndpointService(ctx.http.router(), ctx);
ctx.disposables.add(() => stopWebEndpointService());
ctx.log.info("Web Endpoint routes mounted at /plugin-api/web-endpoint");
}
+68 -240
View File
@@ -1,133 +1,45 @@
import express, { type Router } from "express";
import { Client } from "ssh2";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { tunnelLogger } from "../../../../src/backend/utils/logger.js";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { parseWebUiConfig } from "../../../../src/backend/database/routes/host-web-endpoints.js";
import {
activeTunnelRuntimes,
cleanupTunnelResources,
connectSSHTunnel,
connectionStatus,
tunnelConnecting,
} from "../../../../src/backend/hosts/tunnel/manager.js";
import { forwardOut } from "../../../../src/backend/hosts/tunnel/ssh-primitives.js";
import { buildWebEndpointTunnelName } from "../../../../src/backend/hosts/tunnel/utils.js";
import type { TunnelConfig, WebEndpoint } from "../../../../src/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;
/** The tunnels plugin, as ctx.services.get("tunnels.access") returns it. */
interface TunnelsAccess {
forward(
sourceHostId: number,
target: {
targetHost: string;
targetPort: number;
bindHost?: string;
bindPort?: number;
},
options?: { name?: string; idleTimeoutMs?: number },
): Promise<{ bindHost: string; bindPort: number }>;
}
/**
* 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.
* Reserved by the tunnels plugin for on-demand forwards: never retried, and
* the host id in it is what the tunnels plugin checks before letting anyone
* stop one by name.
*/
function targetFingerprint(
endpoint: Pick<
WebEndpoint,
"scheme" | "port" | "access" | "bindHost" | "localPort"
>,
host: { ip?: string; port?: number; username?: string },
export function webEndpointTunnelName(
hostId: number,
endpointId: 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,
});
return `web:${hostId}:${endpointId}`;
}
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.
}
}
let current: PluginContext | null = null;
export async function handleWebEndpointOpen(
req: express.Request,
res: express.Response,
): Promise<express.Response | void> {
const userId = (req as { userId?: string }).userId;
if (!userId) {
const ctx = current;
const userId = ctx?.currentActor();
if (!ctx || !userId) {
return res.status(401).json({ error: "Authentication required" });
}
@@ -141,10 +53,8 @@ export async function handleWebEndpointOpen(
}
// 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.
// backend runs, and the endpoint's own bindHost decides whether that is
// reachable from the browser.
const { resolveHostById } =
await import("../../../../src/backend/hosts/host-resolver.js");
@@ -153,11 +63,9 @@ export async function handleWebEndpointOpen(
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.
// A bulk update that sends only { webUiConfig } can leave the endpoint in
// the config while the feature is off. The UI reads as off in that state,
// so the endpoint being listed is not on its own a licence to open a tunnel.
if (!host.enableWebUi) {
return res
.status(400)
@@ -178,118 +86,42 @@ export async function handleWebEndpointOpen(
.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);
let tunnels: TunnelsAccess;
try {
tunnels = ctx.services.get<TunnelsAccess>("tunnels.access", { userId });
} catch {
return res
.status(503)
.json({ error: "The tunnels plugin is not available" });
}
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 });
const handle = await tunnels.forward(
hostId,
{
targetHost: "127.0.0.1",
targetPort: endpoint.port,
// 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, and is only ever an explicit
// per-endpoint choice.
bindHost: endpoint.bindHost || "127.0.0.1",
// A fixed port when the endpoint names one, since a container can only
// publish ports it knows in advance. Otherwise the kernel picks.
bindPort: endpoint.localPort ?? undefined,
},
{
name: webEndpointTunnelName(hostId, endpointId),
idleTimeoutMs: WEB_ENDPOINT_IDLE_TIMEOUT_MS,
},
);
return res.status(200).json({ port: handle.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);
ctx.log.error(
`Failed to open web endpoint tunnel for host ${hostId}: ${reason}`,
error instanceof Error ? error : undefined,
);
return res.status(502).json({ error: reason });
}
}
@@ -298,22 +130,18 @@ export const router = express.Router();
let started = false;
export function startWebEndpointService(mountOn: Router): void {
export function startWebEndpointService(
mountOn: Router,
ctx: PluginContext,
): void {
current = ctx;
if (!started) {
// Built on first start rather than at module scope so importing this
// module for a handler unit test does not construct the auth singleton.
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
// Only "/open" here: the dispatcher this router is registered with is
// mounted at /ssh/tunnel/web-endpoint, so the full path stays
// /ssh/tunnel/web-endpoint/open exactly as before. The "/ssh" prefix is
// part of the path the tunnel service serves -- nginx proxies /ssh/tunnel/
// through with the path intact.
router.post("/open", authenticateJWT, handleWebEndpointOpen);
router.post("/open", handleWebEndpointOpen);
started = true;
}
mountOn.use(router);
}
export function stopWebEndpointService(): void {
// Unmounted by the runtime when the plugin deactivates.
current = null;
}
-36
View File
@@ -1,36 +0,0 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
+7 -1
View File
@@ -12,7 +12,10 @@ import type { Host } from "@/types/ui-types";
import type { WebEndpoint } from "@/types";
import { WebEndpointTab } from "./WebEndpointTab";
import { HostWebUiTab } from "./HostWebUiTab";
import { openWebEndpointExternally } from "./web-endpoint-api";
import {
openWebEndpointExternally,
setWebEndpointApi,
} from "./web-endpoint-api";
const TAB_TYPE = "web-endpoint";
@@ -73,6 +76,9 @@ function WebUiSection({ form, setField, protocols }: HostEditorSectionProps) {
}
export function activate(app: TermixApp): void {
setWebEndpointApi(app.api);
app.onDispose(() => setWebEndpointApi(null));
app.registerTab(TAB_TYPE, EndpointTab, {
icon: Globe,
titleKey: "hosts.tabWebUi",
@@ -1,9 +1,17 @@
import { currentTunnelHost, resolveWebEndpointUrl } from "./web-endpoint-url";
import axios from "axios";
import { handleApiError, tunnelApi } from "@/main-axios";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import { handleApiError } from "@/main-axios";
import { isElectron } from "@/lib/electron";
import type { WebEndpoint } from "@/types/index";
let pluginApi: PluginApiClient | null = null;
/** Set from activate with app.api, cleared on deactivate. */
export function setWebEndpointApi(api: PluginApiClient | null): void {
pluginApi = api;
}
/**
* Thrown when the backend rejects a web endpoint tunnel open with a specific,
* actionable reason -- most commonly a 502 carrying the real cause: SSH auth
@@ -32,10 +40,9 @@ export async function openWebEndpointTunnel(
endpointId: string,
): Promise<number> {
try {
// Relative to the tunnel API base, which already includes /ssh
// (getApiUrl("/ssh", 30003)). A path beginning "/ssh" here would resolve
// to /ssh/ssh/... and 404 on every call.
const response = await tunnelApi.post("/tunnel/web-endpoint/open", {
if (!pluginApi) throw new Error("The web endpoint plugin is not active");
// Relative to the plugin mount, /plugin-api/web-endpoint.
const response = await pluginApi.post("/open", {
hostId,
endpointId,
});
+55 -178
View File
@@ -1,29 +1,14 @@
import type express from "express";
import express from "express";
import { readFile } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createFakeContext } from "@termix/plugin-sdk/testing";
const resolveHostById = vi.hoisted(() => vi.fn());
vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({
resolveHostById,
}));
const manager = vi.hoisted(() => ({
activeTunnelRuntimes: new Map<string, unknown>(),
connectionStatus: new Map<string, unknown>(),
tunnelConnecting: new Set<string>(),
connectSSHTunnel: vi.fn(),
cleanupTunnelResources: vi.fn(async () => undefined),
}));
vi.mock("../../../../src/backend/hosts/tunnel/manager.js", () => manager);
const forwardOut = vi.hoisted(() => vi.fn(async () => ({ end: vi.fn() })));
vi.mock("../../../../src/backend/hosts/tunnel/ssh-primitives.js", () => ({
forwardOut,
}));
vi.mock("../../../../src/backend/utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => ({ createAuthMiddleware: () => vi.fn() }) },
}));
const forward = vi.fn();
function response() {
const res = {
@@ -44,8 +29,8 @@ function response() {
};
}
function request(body: unknown, userId: string | undefined = "u1") {
return { body, userId } as unknown as express.Request;
function request(body: unknown) {
return { body } as unknown as express.Request;
}
const endpoint = (overrides: Record<string, unknown> = {}) => ({
@@ -67,34 +52,29 @@ function host(overrides: Record<string, unknown> = {}) {
port: 22,
username: "root",
authType: "password",
password: "pw",
enableWebUi: true,
webUiConfig: JSON.stringify({ endpoints: [endpoint()] }),
...overrides,
};
}
/** Makes connectSSHTunnel behave like a successful open. */
function tunnelConnectsOn(port: number) {
manager.connectSSHTunnel.mockImplementation(
async (config: { name: string }) => {
manager.activeTunnelRuntimes.set(config.name, {
bindPort: port,
sourceClient: {},
close: vi.fn(),
});
},
);
/** Activates the routes against a fake ctx, with or without the tunnels service. */
async function load(options: { tunnels?: boolean; actor?: string } = {}) {
const fake = createFakeContext({
pluginId: "web-endpoint",
actor: options.actor ?? "u1",
});
if (options.tunnels !== false) {
fake.ctx.services.provide("tunnels.access", { forward });
}
const routes = await import("../../src/backend/routes.js");
routes.startWebEndpointService(express.Router(), fake.ctx);
return routes;
}
beforeEach(() => {
manager.activeTunnelRuntimes.clear();
manager.connectionStatus.clear();
manager.tunnelConnecting.clear();
manager.connectSSHTunnel.mockReset();
manager.cleanupTunnelResources.mockClear();
forwardOut.mockClear();
forwardOut.mockResolvedValue({ end: vi.fn() });
forward.mockReset();
forward.mockResolvedValue({ bindHost: "127.0.0.1", bindPort: 41234 });
resolveHostById.mockReset();
resolveHostById.mockResolvedValue(host());
});
@@ -103,34 +83,29 @@ afterEach(() => {
vi.resetModules();
});
describe("POST /tunnel/web-endpoint/open", () => {
describe("POST /open", () => {
it("refuses a host the user cannot resolve, without opening anything", async () => {
resolveHostById.mockResolvedValue(null);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
const { handleWebEndpointOpen } = await load();
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(res.statusCode).toBe(403);
expect(manager.connectSSHTunnel).not.toHaveBeenCalled();
expect(forward).not.toHaveBeenCalled();
});
it("refuses when the feature is disabled even though the config still lists the endpoint", async () => {
// Reachable via a bulk update that sends only { webUiConfig } while the
// stored enable_web_ui is still 0. The UI reads as off in that state.
resolveHostById.mockResolvedValue(host({ enableWebUi: false }));
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
const { handleWebEndpointOpen } = await load();
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(res.statusCode).toBe(400);
expect(manager.connectSSHTunnel).not.toHaveBeenCalled();
expect(forward).not.toHaveBeenCalled();
});
it("refuses an endpoint id that matches nothing", async () => {
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
const { handleWebEndpointOpen } = await load();
const res = response();
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "nope" }),
@@ -139,10 +114,8 @@ describe("POST /tunnel/web-endpoint/open", () => {
expect(res.statusCode).toBe(400);
});
it("returns the real bound port", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
it("returns the port the tunnels service bound", async () => {
const { handleWebEndpointOpen } = await load();
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
@@ -150,41 +123,26 @@ describe("POST /tunnel/web-endpoint/open", () => {
expect(res.body.port).toBe(41234);
});
it("selects the local-forward path via endpointHost, not targetHost", async () => {
// connectSSHTunnel routes on isSingleHostTunnel, which keys on
// endpointHost === "127.0.0.1". Setting only targetHost would silently
// select a different strategy and the forward would never bind locally.
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
it("asks for a loopback forward under the reserved web name", async () => {
const { handleWebEndpointOpen } = await load();
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
);
const config = manager.connectSSHTunnel.mock.calls[0][0];
expect(config.endpointHost).toBe("127.0.0.1");
expect(config.mode).toBe("local");
expect(config.endpointPort).toBe(8006);
// Carries the source SSH identity -- connectSSHTunnel builds its client
// from these, and reads sourceUserId specifically.
expect(config.sourceIP).toBe("10.0.0.5");
expect(config.sourceUserId).toBe("u1");
expect(forward).toHaveBeenCalledWith(
7,
{
targetHost: "127.0.0.1",
targetPort: 8006,
bindHost: "127.0.0.1",
bindPort: undefined,
},
{ name: "web:7:e1", idleTimeoutMs: 10 * 60 * 1000 },
);
});
it("binds loopback by default and the endpoint's choice when given", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
);
expect(manager.connectSSHTunnel.mock.calls[0][0].bindHost).toBe(
"127.0.0.1",
);
manager.activeTunnelRuntimes.clear();
it("passes the endpoint's own bind address and fixed port through", async () => {
resolveHostById.mockResolvedValue(
host({
webUiConfig: JSON.stringify({
@@ -192,105 +150,39 @@ describe("POST /tunnel/web-endpoint/open", () => {
}),
}),
);
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
);
const config = manager.connectSSHTunnel.mock.calls[1][0];
expect(config.bindHost).toBe("0.0.0.0");
expect(config.sourcePort).toBe(38080);
});
it("reuses a live tunnel instead of opening a second one", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
const first = response();
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
first,
);
const second = response();
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
second,
);
expect(second.body.port).toBe(41234);
expect(manager.connectSSHTunnel).toHaveBeenCalledTimes(1);
});
it("reopens when the endpoint's target changed under a live tunnel", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
const { handleWebEndpointOpen } = await load();
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
);
// Same id, different port: the live forward now points at the wrong place.
resolveHostById.mockResolvedValue(
host({
webUiConfig: JSON.stringify({ endpoints: [endpoint({ port: 9000 })] }),
}),
);
tunnelConnectsOn(51234);
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(manager.cleanupTunnelResources).toHaveBeenCalledWith(
"web:7:e1",
true,
);
expect(res.body.port).toBe(51234);
const target = forward.mock.calls[0][1];
expect(target.bindHost).toBe("0.0.0.0");
expect(target.bindPort).toBe(38080);
});
it("reports the target port being closed rather than returning a dead 200", async () => {
// The likeliest real error: the listener binds fine, but nothing is
// serving on the endpoint's port. Without the probe this returns 200 and
// the user gets a blank frame with no message.
tunnelConnectsOn(41234);
forwardOut.mockRejectedValue(
it("reports the service's reason as a 502 instead of a dead 200", async () => {
forward.mockRejectedValue(
new Error("Channel open failure: connect failed"),
);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
const { handleWebEndpointOpen } = await load();
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(res.statusCode).toBe(502);
expect(String(res.body.error)).toMatch(/connect failed/);
// Nothing half-built is left registered.
expect(manager.cleanupTunnelResources).toHaveBeenCalledWith(
"web:7:e1",
true,
);
});
it("surfaces a connection failure reported only over the status map", async () => {
// connectSSHTunnel never rejects and resolves before the SSH handshake, so
// without waitForTunnelSettled this path would read as success.
manager.connectSSHTunnel.mockImplementation(
async (config: { name: string }) => {
manager.connectionStatus.set(config.name, {
connected: false,
reason: "All authentication methods failed",
});
},
);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
it("answers 503 while the tunnels plugin is not available", async () => {
const { handleWebEndpointOpen } = await load({ tunnels: false });
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(res.statusCode).toBe(502);
expect(String(res.body.error)).toMatch(/authentication methods failed/);
expect(res.statusCode).toBe(503);
});
it("rejects a malformed request before touching the database", async () => {
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
const { handleWebEndpointOpen } = await load();
for (const body of [
{},
{ hostId: "7", endpointId: "e1" },
@@ -305,27 +197,13 @@ describe("POST /tunnel/web-endpoint/open", () => {
});
/**
* The client path and the server path have to agree.
*
* This guards a real bug: the route once served the unprefixed form while the
* client called the full one, so every call 404d and no handler unit test
* noticed, because those call the handler directly. It was caught only by
* opening a tunnel against a deployed build.
*
* The shape changed with A4. The route used to be composed of a mount point in
* the tunnel service (/ssh/tunnel/web-endpoint, port 30003) plus "/open" on
* this router. Now core mounts every plugin at /plugin-api/<id>, so the client
* calls /plugin-api/web-endpoint/open and the only half this plugin owns is
* "/open". Asserting that keeps the guard meaningful without re-encoding a
* prefix the plugin no longer controls.
* The client path and the server path have to agree. Core mounts every
* plugin at /plugin-api/<id>, so the client calls /plugin-api/web-endpoint/open
* and the only half this plugin owns is "/open".
*/
describe("route registration", () => {
it("registers /open on the router core hands it", async () => {
const routes = await import("../../src/backend/routes.js");
const express = (await import("express")).default;
const mountOn = express.Router();
routes.startWebEndpointService(mountOn);
const routes = await load();
const paths = (
routes.router as unknown as {
@@ -344,7 +222,6 @@ describe("route registration", () => {
"utf8",
);
// ctx.http.router() is what puts this plugin at /plugin-api/web-endpoint.
expect(source).toContain("ctx.http.router()");
});
});
@@ -1,136 +0,0 @@
import net from "node:net";
import { afterEach, describe, expect, it, vi } from "vitest";
/**
* Exercises establishDirectTunnel's local branch against a REAL TCP listener.
* The two behaviours here are the ones a mocked test cannot show:
*
* - under sourcePort 0 the kernel assigns the port, so the runtime has to
* record what it actually bound rather than the 0 it was asked for; and
* - an idle tunnel closes itself and REMOVES its runtime entry, so nothing
* keeps handing out a port whose listener is gone.
*
* The SSH half is stubbed -- these behaviours live entirely in the listener
* and the runtime bookkeeping.
*/
const sshPrimitives = vi.hoisted(() => ({
forwardOut: vi.fn(async () => {
throw new Error("not used in these tests");
}),
pipeTunnelStreams: vi.fn(),
handleSocks5Connect: vi.fn(),
}));
vi.mock(
"../../../../src/backend/hosts/tunnel/ssh-primitives.js",
() => sshPrimitives,
);
function tunnelConfig(overrides: Record<string, unknown> = {}) {
return {
name: "web:7:e1",
scope: "s2s",
mode: "local",
tunnelType: "local",
bindHost: "127.0.0.1",
sourcePort: 0,
endpointHost: "127.0.0.1",
endpointPort: 9,
...overrides,
} as never;
}
const fakeClient = () => ({ on: vi.fn(), end: vi.fn() }) as never;
afterEach(() => {
vi.resetModules();
});
describe("establishDirectTunnel records the port it actually bound", () => {
it("reports the kernel-assigned port, never the requested 0", async () => {
const manager =
await import("../../../../src/backend/hosts/tunnel/manager.js");
await manager.establishDirectTunnel(fakeClient(), tunnelConfig());
const runtime = manager.activeTunnelRuntimes.get("web:7:e1");
expect(runtime).toBeDefined();
// The whole point: 0 means "pick one for me", so a runtime advertising 0
// would make every caller that reuses the port fail.
expect(runtime?.bindPort).toBeGreaterThan(0);
// And it is genuinely listening there.
const port = runtime!.bindPort as number;
await new Promise<void>((resolve, reject) => {
const socket = net.connect(port, "127.0.0.1", () => {
socket.destroy();
resolve();
});
socket.once("error", reject);
});
runtime?.close();
manager.activeTunnelRuntimes.delete("web:7:e1");
});
it("honours a fixed source port when one is asked for", async () => {
const manager =
await import("../../../../src/backend/hosts/tunnel/manager.js");
// Borrow a free port, release it, then ask for it explicitly.
const probe = net.createServer();
const wanted = await new Promise<number>((resolve) => {
probe.listen(0, "127.0.0.1", () =>
resolve((probe.address() as { port: number }).port),
);
});
await new Promise<void>((resolve) => probe.close(() => resolve()));
await manager.establishDirectTunnel(
fakeClient(),
tunnelConfig({ name: "web:7:fixed", sourcePort: wanted }),
);
const runtime = manager.activeTunnelRuntimes.get("web:7:fixed");
expect(runtime?.bindPort).toBe(wanted);
runtime?.close();
manager.activeTunnelRuntimes.delete("web:7:fixed");
});
});
describe("idle close", () => {
it("removes the runtime entry, not just the listener", async () => {
// Real timers deliberately: establishDirectTunnel awaits real socket I/O,
// and mixing fake timers with that is a known route to a hanging test.
const manager =
await import("../../../../src/backend/hosts/tunnel/manager.js");
await manager.establishDirectTunnel(
fakeClient(),
tunnelConfig({ name: "web:7:idle", idleTimeoutMs: 60 }),
);
expect(manager.activeTunnelRuntimes.has("web:7:idle")).toBe(true);
await vi.waitFor(
() => {
// Deleting the entry is the assertion. close() alone would stop the
// listener but leave the entry behind, so the open route would keep
// returning a port nothing is listening on.
expect(manager.activeTunnelRuntimes.has("web:7:idle")).toBe(false);
},
{ timeout: 4000, interval: 25 },
);
});
it("leaves a tunnel with no idle timeout running", async () => {
// Without this, an idle-close bug that fires unconditionally would still
// pass the test above.
const manager =
await import("../../../../src/backend/hosts/tunnel/manager.js");
await manager.establishDirectTunnel(
fakeClient(),
tunnelConfig({ name: "web:7:forever" }),
);
await new Promise((resolve) => setTimeout(resolve, 300));
expect(manager.activeTunnelRuntimes.has("web:7:forever")).toBe(true);
manager.activeTunnelRuntimes.get("web:7:forever")?.close();
manager.activeTunnelRuntimes.delete("web:7:forever");
});
});
@@ -6,7 +6,6 @@ vi.mock("@/lib/electron", () => ({ isElectron }));
const tunnelPost = vi.hoisted(() => vi.fn());
vi.mock("@/main-axios", () => ({
tunnelApi: { post: tunnelPost },
handleApiError: (error: unknown) => {
throw new Error(`generic: ${String(error)}`);
},
@@ -32,7 +31,10 @@ let pageHostname = "localhost";
const realLocation = window.location;
const windowOpen = vi.fn();
beforeEach(() => {
beforeEach(async () => {
const { setWebEndpointApi } =
await import("../../src/frontend/web-endpoint-api");
setWebEndpointApi({ post: tunnelPost } as never);
pageHostname = "localhost";
isElectron.mockReturnValue(false);
tunnelPost.mockReset();
@@ -54,17 +56,14 @@ afterEach(() => {
});
describe("openWebEndpointTunnel", () => {
it("posts a path relative to the tunnel base, never one starting /ssh", async () => {
// tunnelApi's baseURL already includes /ssh; a leading "/ssh" here would
// resolve to /ssh/ssh/... and 404 on every call. Asserted on the captured
// runtime argument rather than by scanning source, so quoting style and
// indirection cannot fool it.
it("posts to the plugin's own /open route", async () => {
// app.api is already rooted at /plugin-api/web-endpoint, so the path is
// relative to that and must not repeat the prefix.
const { openWebEndpointTunnel } =
await import("../../src/frontend/web-endpoint-api");
await openWebEndpointTunnel(7, "e1");
expect(tunnelPost.mock.calls[0][0]).not.toMatch(/^\/ssh\//);
expect(tunnelPost).toHaveBeenCalledWith("/tunnel/web-endpoint/open", {
expect(tunnelPost).toHaveBeenCalledWith("/open", {
hostId: 7,
endpointId: "e1",
});