mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 23:55:23 +00:00
feat: convert ssh tunnels into a plugin
This commit is contained in:
1 parent
c85c1fdb86
commit
1682861853
220 files changed
+36928
-11374
No files matched your search
@@ -0,0 +1,175 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
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 {
|
||||
createTunnelsService,
|
||||
type TunnelsAccess,
|
||||
} from "../../src/backend/service.js";
|
||||
import { host, manifest, withHosts } from "./helpers";
|
||||
|
||||
class FakeClient extends EventEmitter {
|
||||
failForward = false;
|
||||
forwardOut = vi.fn(
|
||||
(
|
||||
_srcIP: string,
|
||||
_srcPort: number,
|
||||
_host: string,
|
||||
_port: number,
|
||||
cb: (err: Error | undefined, stream?: PassThrough) => void,
|
||||
) => {
|
||||
if (this.failForward)
|
||||
cb(new Error("Channel open failure: connect failed"));
|
||||
else cb(undefined, new PassThrough());
|
||||
},
|
||||
);
|
||||
forwardIn = vi.fn();
|
||||
unforwardIn = vi.fn();
|
||||
end = vi.fn(() => this.emit("close"));
|
||||
}
|
||||
|
||||
let manager: ReturnType<typeof createTunnelManager> | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
manager?.dispose();
|
||||
manager = null;
|
||||
});
|
||||
|
||||
function setup(): {
|
||||
mock: MockPluginContext;
|
||||
client: FakeClient;
|
||||
service: TunnelsAccess;
|
||||
} {
|
||||
const client = new FakeClient();
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
actor: "user-1",
|
||||
sshClient: client,
|
||||
});
|
||||
withHosts(mock, { "user-1": [host()], "user-2": [] });
|
||||
manager = createTunnelManager(mock.ctx);
|
||||
return { mock, client, service: createTunnelsService(mock.ctx, manager) };
|
||||
}
|
||||
|
||||
const target = { targetHost: "127.0.0.1", targetPort: 8006 };
|
||||
|
||||
describe("tunnels.access forward()", () => {
|
||||
it("resolves with the bound port once the target answered a probe", async () => {
|
||||
const { service, client } = setup();
|
||||
const handle = await service.forward(7, target, { name: "web:7:e1" });
|
||||
|
||||
expect(handle.bindHost).toBe("127.0.0.1");
|
||||
expect(handle.bindPort).toBeGreaterThan(0);
|
||||
expect(client.forwardOut).toHaveBeenCalledWith(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
"127.0.0.1",
|
||||
8006,
|
||||
expect.any(Function),
|
||||
);
|
||||
await handle.close();
|
||||
expect(manager!.runtimes.has("web:7:e1")).toBe(false);
|
||||
});
|
||||
|
||||
it("reuses a live forward and reopens one whose target changed", async () => {
|
||||
const { service, mock } = setup();
|
||||
const first = await service.forward(7, target, { name: "web:7:e1" });
|
||||
const again = await service.forward(7, target, { name: "web:7:e1" });
|
||||
expect(again.bindPort).toBe(first.bindPort);
|
||||
expect(mock.sshConnections).toHaveLength(1);
|
||||
|
||||
await service.forward(
|
||||
7,
|
||||
{ ...target, targetPort: 9000 },
|
||||
{ name: "web:7:e1" },
|
||||
);
|
||||
expect(mock.sshConnections).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects with the real reason and leaves nothing behind when the target is closed", async () => {
|
||||
const { service, client } = setup();
|
||||
client.failForward = true;
|
||||
|
||||
await expect(
|
||||
service.forward(7, target, { name: "web:7:e1" }),
|
||||
).rejects.toThrow(/connect failed/);
|
||||
expect(manager!.runtimes.has("web:7:e1")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a host the caller cannot reach and a name that is not reserved", async () => {
|
||||
const { service, mock } = setup();
|
||||
|
||||
await mock.ctx.asUser("user-2", async () => {
|
||||
await expect(service.forward(7, target)).rejects.toThrow(/access denied/);
|
||||
});
|
||||
await expect(
|
||||
service.forward(7, target, { name: "my-tunnel" }),
|
||||
).rejects.toThrow(/Forward names/);
|
||||
await expect(
|
||||
service.forward(7, target, { name: "web:8:e1" }),
|
||||
).rejects.toThrow(/Forward names/);
|
||||
expect(mock.sshConnections).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tunnels.access start/stop/status", () => {
|
||||
const saved = {
|
||||
scope: "s2s",
|
||||
mode: "local",
|
||||
sourcePort: 0,
|
||||
endpointHost: "127.0.0.1",
|
||||
endpointPort: 9,
|
||||
maxRetries: 3,
|
||||
retryInterval: 5,
|
||||
autoStart: false,
|
||||
};
|
||||
const name = "7::0::web::0::127.0.0.1::9";
|
||||
|
||||
it("starts a host's saved tunnel by name and stops it again", async () => {
|
||||
const { service, mock } = setup();
|
||||
await mock.ctx.settings.setHost(7, "enableTunnel", true);
|
||||
await mock.ctx.settings.setHost(7, "tunnelConnections", [saved]);
|
||||
|
||||
await service.start(name);
|
||||
await vi.waitFor(async () =>
|
||||
expect((await service.status(name))?.status).toBe("connected"),
|
||||
);
|
||||
expect(Object.keys(await service.list())).toEqual([name]);
|
||||
|
||||
await service.stop(name);
|
||||
expect((await service.status(name))?.status).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("refuses a name that matches no saved tunnel", async () => {
|
||||
const { service, mock } = setup();
|
||||
await mock.ctx.settings.setHost(7, "enableTunnel", false);
|
||||
await mock.ctx.settings.setHost(7, "tunnelConnections", [saved]);
|
||||
|
||||
await expect(service.start(name)).rejects.toThrow(/not configured/);
|
||||
await expect(service.start("nope")).rejects.toThrow(/not configured/);
|
||||
});
|
||||
|
||||
it("hides a tunnel from a user who cannot reach its host", async () => {
|
||||
const { service, mock } = setup();
|
||||
await mock.ctx.settings.setHost(7, "enableTunnel", true);
|
||||
await mock.ctx.settings.setHost(7, "tunnelConnections", [saved]);
|
||||
await service.start(name);
|
||||
// The fake context tracks one actor globally, so let the connect settle
|
||||
// before switching users.
|
||||
await vi.waitFor(async () =>
|
||||
expect((await service.status(name))?.status).toBe("connected"),
|
||||
);
|
||||
|
||||
await mock.ctx.asUser("user-2", async () => {
|
||||
expect(await service.status(name)).toBeNull();
|
||||
expect(await service.list()).toEqual({});
|
||||
await expect(service.stop(name)).rejects.toThrow(/Access denied/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user