import net from "node:net"; import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createMockCtx, type MockPluginContext, } from "@termix/plugin-sdk/testing"; import { createTunnelManager } from "../../src/backend/manager.js"; import type { TunnelConfig } from "../../src/backend/types.js"; import { host, manifest, pluginDir, withHosts } from "./helpers"; /** Just enough of an ssh2 Client for the manager's channel calls. */ class FakeClient extends EventEmitter { channels: PassThrough[] = []; forwardOut = vi.fn( ( _srcIP: string, _srcPort: number, _host: string, _port: number, cb: (err: Error | undefined, stream?: PassThrough) => void, ) => { const channel = new PassThrough(); this.channels.push(channel); cb(undefined, channel); }, ); forwardIn = vi.fn( (_host: string, port: number, cb: (err?: Error, port?: number) => void) => cb(undefined, port || 40000), ); unforwardIn = vi.fn((_host: string, _port: number, cb?: () => void) => cb?.(), ); end = vi.fn(() => this.emit("close")); } let manager: ReturnType | null = null; afterEach(() => { manager?.dispose(); manager = null; }); function setup(client = new FakeClient()) { const mock: MockPluginContext = createMockCtx({ pluginId: manifest.id, manifest, capabilities: manifest.capabilities, actor: "user-1", sshClient: client, }); withHosts(mock, { "user-1": [host(), host({ id: 9, name: "db", ip: "10.0.0.9" })], }); manager = createTunnelManager(mock.ctx); return { mock, client, manager }; } function config(overrides: Partial = {}): TunnelConfig { return { name: "7::0::web::0::127.0.0.1::9", scope: "s2s", mode: "local", tunnelType: "local", bindHost: "127.0.0.1", sourceHostId: 7, tunnelIndex: 0, requestingUserId: "user-1", hostName: "web", sourceIP: "10.0.0.5", sourceSSHPort: 22, sourceUsername: "root", endpointHost: "127.0.0.1", sourcePort: 0, endpointPort: 9, maxRetries: 3, retryInterval: 5000, autoStart: false, ...overrides, }; } describe("both SSH legs go through ctx.ssh", () => { it("never builds its own ssh2 Client", () => { // The host key check lives in core's connect pipeline. A raw // `new Client()` here would skip it, which is the bug this plugin fixed. const dir = join(pluginDir, "src", "backend"); for (const file of readdirSync(dir)) { const source = readFileSync(join(dir, file), "utf8"); expect(source, file).not.toMatch(/new Client\(/); expect(source, file).not.toMatch( /import \{[^}]*\bClient\b[^}]*\} from "ssh2"/, ); } }); it("opens the endpoint leg over a channel on the source connection", async () => { const { mock, client } = setup(); const runtime = await manager!.connect( config({ name: "7::0::web::8080::db::5432", mode: "remote", tunnelType: "remote", endpointHost: "db", endpointHostId: 9, endpointIP: "10.0.0.9", endpointSSHPort: 22, sourcePort: 8080, endpointPort: 5432, }), 0, { throwOnError: true }, ); expect(runtime).not.toBeNull(); expect(mock.sshConnections).toHaveLength(2); const [source, endpoint] = mock.sshConnections; expect(source.host).toBe(7); expect(source.options?.purpose).toBe("tunnel"); expect(source.options?.sock).toBeUndefined(); expect(client.forwardOut.mock.calls[0].slice(2, 4)).toEqual([ "10.0.0.9", 22, ]); expect(endpoint.host).toBe(9); expect(endpoint.options?.purpose).toBe("tunnel"); expect(endpoint.options?.sock).toBe(client.channels[0]); // Remote mode binds on the endpoint leg. expect(client.forwardIn).toHaveBeenCalledWith( "127.0.0.1", 5432, expect.any(Function), ); }); }); describe("local listeners", () => { it("reports the kernel-assigned port, never the requested 0", async () => { setup(); const runtime = await manager!.connect(config(), 0, { throwOnError: true, }); expect(runtime!.bindPort).toBeGreaterThan(0); await new Promise((resolve, reject) => { const socket = net.connect(runtime!.bindPort, "127.0.0.1", () => { socket.destroy(); resolve(); }); socket.once("error", reject); }); expect(manager!.statuses.get(config().name)?.status).toBe("connected"); }); it("honours a fixed source port", async () => { setup(); const probe = net.createServer(); const wanted = await new Promise((resolve) => { probe.listen(0, "127.0.0.1", () => resolve((probe.address() as { port: number }).port), ); }); await new Promise((resolve) => probe.close(() => resolve())); const runtime = await manager!.connect( config({ name: "web:7:fixed", sourcePort: wanted }), 0, { throwOnError: true }, ); expect(runtime!.bindPort).toBe(wanted); }); it("closes an idle tunnel and drops its runtime entry", async () => { setup(); await manager!.connect( config({ name: "web:7:idle", idleTimeoutMs: 60 }), 0, { throwOnError: true }, ); expect(manager!.runtimes.has("web:7:idle")).toBe(true); await vi.waitFor( () => expect(manager!.runtimes.has("web:7:idle")).toBe(false), { timeout: 4000, interval: 25 }, ); }); it("leaves a tunnel with no idle timeout running", async () => { setup(); await manager!.connect(config({ name: "web:7:forever" }), 0, { throwOnError: true, }); await new Promise((resolve) => setTimeout(resolve, 300)); expect(manager!.runtimes.has("web:7:forever")).toBe(true); }); }); describe("failures and stops", () => { it("reports an auth failure without retrying and tells automations", async () => { const { mock } = setup(); const connect = vi .spyOn(mock.ctx.ssh, "connect") .mockRejectedValue( new Error("All configured authentication methods failed"), ); await manager!.connect(config()); const status = manager!.statuses.get(config().name); expect(status?.status).toBe("failed"); expect(status?.errorType).toBe("AUTHENTICATION_FAILED"); expect(connect).toHaveBeenCalledTimes(1); expect(mock.emitted).toContainEqual({ topic: "plugin.tunnels.tunnel_disconnected", payload: { userId: "user-1", hostId: 7, tunnelName: config().name, }, }); }); it("stops on request and holds off an immediate reconnect", async () => { const { mock } = setup(); manager!.configs.set(config().name, config()); await manager!.connect(config(), 0, { throwOnError: true }); await manager!.stop(config().name); expect(manager!.runtimes.has(config().name)).toBe(false); expect(manager!.statuses.get(config().name)).toMatchObject({ status: "disconnected", manualDisconnect: true, }); expect(await manager!.connect(config())).toBeNull(); expect( mock.emitted.some( (event) => event.topic === "plugin.tunnels.tunnel_disconnected", ), ).toBe(false); }); it("only shows a user the tunnels on hosts they can reach", async () => { setup(); manager!.configs.set(config().name, config()); await manager!.connect(config(), 0, { throwOnError: true }); expect(Object.keys(await manager!.statusesFor("user-1"))).toEqual([ config().name, ]); expect(await manager!.statusesFor("user-2")).toEqual({}); }); });