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

+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",
});