mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +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,83 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import express from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import { activate } from "../../src/backend/index.js";
|
||||
import {
|
||||
host,
|
||||
manifest,
|
||||
pluginDir,
|
||||
startServer,
|
||||
withHosts,
|
||||
type TestServer,
|
||||
} from "./helpers";
|
||||
|
||||
let db: TestDb | null = null;
|
||||
let server: TestServer | null = null;
|
||||
let mock: MockPluginContext | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dispose of [...(mock?.disposals ?? [])].reverse()) await dispose();
|
||||
mock = null;
|
||||
db?.close();
|
||||
db = null;
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
async function activateWithout(capability: string, actor?: string) {
|
||||
db = await createTestDb(pluginDir);
|
||||
mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities.filter((c) => c !== capability),
|
||||
db: db.database,
|
||||
router: () => express.Router(),
|
||||
actor,
|
||||
});
|
||||
return mock;
|
||||
}
|
||||
|
||||
describe("tunnels activate", () => {
|
||||
it("provides tunnels.access and serves the client relay socket", async () => {
|
||||
server = await startServer();
|
||||
const service = server.mock.services.get("tunnels.access") as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
for (const method of ["forward", "start", "stop", "status", "list"]) {
|
||||
expect(typeof service[method]).toBe("function");
|
||||
}
|
||||
expect(server.mock.wsRoutes).toEqual([{ path: "/c2s/stream", raw: false }]);
|
||||
});
|
||||
|
||||
for (const capability of ["db:own", "network:serve"]) {
|
||||
it(`fails closed without ${capability}`, async () => {
|
||||
const ctx = await activateWithout(capability);
|
||||
await expect(activate(ctx.ctx)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
for (const capability of ["ssh:connect", "credentials:use", "hosts:read"]) {
|
||||
it(`cannot open a tunnel without ${capability}`, async () => {
|
||||
const ctx = await activateWithout(capability, "user-1");
|
||||
// Host lookups stay gated when hosts:read is the one missing.
|
||||
if (capability !== "hosts:read") withHosts(ctx, { "user-1": [host()] });
|
||||
await activate(ctx.ctx);
|
||||
const service = ctx.services.get("tunnels.access") as {
|
||||
forward: (id: number, target: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
await expect(
|
||||
service.forward(7, { targetHost: "127.0.0.1", targetPort: 80 }),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { authorizeTunnelAction } from "../../src/backend/authorize.js";
|
||||
|
||||
/**
|
||||
* /disconnect and /cancel are addressed by tunnel name. On-demand tunnels are
|
||||
* never registered as configs, so a check that only ran for a registered
|
||||
* config never ran for them, and `web:{hostId}:{endpointId}` is guessable.
|
||||
* That let any user force-close another user's live tunnel.
|
||||
*/
|
||||
describe("authorizeTunnelAction", () => {
|
||||
it("checks the host encoded in a reserved name when no config exists", async () => {
|
||||
const canAccess = vi.fn().mockResolvedValue(true);
|
||||
const result = await authorizeTunnelAction(
|
||||
canAccess,
|
||||
"web:7:e1",
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ allowed: true, hostId: 7 });
|
||||
expect(canAccess).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it("denies a reserved name whose host the user cannot access", async () => {
|
||||
const canAccess = vi.fn().mockResolvedValue(false);
|
||||
expect(
|
||||
(await authorizeTunnelAction(canAccess, "web:7:e1", undefined)).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed on a reserved name it cannot parse", async () => {
|
||||
const canAccess = vi.fn().mockResolvedValue(true);
|
||||
for (const name of ["web:", "web:abc:e1", "web:0:e1"]) {
|
||||
expect(
|
||||
(await authorizeTunnelAction(canAccess, name, undefined)).allowed,
|
||||
).toBe(false);
|
||||
}
|
||||
expect(canAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still checks a registered config's host id", async () => {
|
||||
const canAccess = vi.fn().mockResolvedValue(true);
|
||||
await authorizeTunnelAction(canAccess, "my-tunnel", { sourceHostId: 42 });
|
||||
expect(canAccess).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it("leaves an ordinary unregistered name allowed", async () => {
|
||||
const canAccess = vi.fn();
|
||||
expect(
|
||||
(await authorizeTunnelAction(canAccess, "my-tunnel", undefined)).allowed,
|
||||
).toBe(true);
|
||||
expect(canAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMockCtx } from "@termix/plugin-sdk/testing";
|
||||
import { createTunnelManager } from "../../src/backend/manager.js";
|
||||
import { startAutoStartTunnels } from "../../src/backend/autostart.js";
|
||||
import { host, manifest, withHosts } from "./helpers";
|
||||
|
||||
class FakeClient extends EventEmitter {
|
||||
forwardOut = vi.fn((_a, _b, _c, _d, cb) => cb(undefined, new PassThrough()));
|
||||
end = vi.fn();
|
||||
}
|
||||
|
||||
let manager: ReturnType<typeof createTunnelManager> | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
manager?.dispose();
|
||||
manager = null;
|
||||
});
|
||||
|
||||
const tunnel = (autoStart: boolean, sourcePort: number) => ({
|
||||
scope: "s2s",
|
||||
mode: "local",
|
||||
sourcePort,
|
||||
endpointHost: "127.0.0.1",
|
||||
endpointPort: 9,
|
||||
maxRetries: 3,
|
||||
retryInterval: 5,
|
||||
autoStart,
|
||||
});
|
||||
|
||||
describe("autostart", () => {
|
||||
it("starts only autostart tunnels on enabled hosts, as each host's owner", async () => {
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
sshClient: new FakeClient(),
|
||||
});
|
||||
withHosts(mock, {
|
||||
"user-1": [host()],
|
||||
"user-2": [host({ id: 8, userId: "user-2", name: "db" })],
|
||||
});
|
||||
await mock.ctx.settings.setHost(7, "enableTunnel", true);
|
||||
await mock.ctx.settings.setHost(7, "tunnelConnections", [
|
||||
tunnel(true, 0),
|
||||
tunnel(false, 0),
|
||||
]);
|
||||
await mock.ctx.settings.setHost(8, "enableTunnel", false);
|
||||
await mock.ctx.settings.setHost(8, "tunnelConnections", [tunnel(true, 0)]);
|
||||
manager = createTunnelManager(mock.ctx);
|
||||
|
||||
const started = await startAutoStartTunnels(mock.ctx, manager, async () => [
|
||||
{ id: 7, userId: "user-1" },
|
||||
{ id: 8, userId: "user-2" },
|
||||
]);
|
||||
|
||||
expect(started).toBe(1);
|
||||
expect([...manager.configs.keys()]).toEqual(["7::0::web::0::127.0.0.1::9"]);
|
||||
expect(
|
||||
manager.configs.get("7::0::web::0::127.0.0.1::9")?.requestingUserId,
|
||||
).toBe("user-1");
|
||||
});
|
||||
|
||||
it("does nothing when the host list cannot be read", async () => {
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
});
|
||||
manager = createTunnelManager(mock.ctx);
|
||||
await expect(
|
||||
startAutoStartTunnels(mock.ctx, manager, async () => {
|
||||
throw new Error("no database");
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveC2SSourceHostId } from "../../src/backend/c2s-relay.js";
|
||||
|
||||
describe("resolveC2SSourceHostId", () => {
|
||||
it("uses the row matching the stable sync id", async () => {
|
||||
const findBySyncId = vi.fn().mockResolvedValue(42);
|
||||
|
||||
await expect(
|
||||
resolveC2SSourceHostId(
|
||||
{ sourceHostId: 7, sourceHostSyncId: "host-sync-id" },
|
||||
findBySyncId,
|
||||
),
|
||||
).resolves.toBe(42);
|
||||
expect(findBySyncId).toHaveBeenCalledWith("host-sync-id");
|
||||
});
|
||||
|
||||
it("keeps a local id when no sync id is available", async () => {
|
||||
await expect(
|
||||
resolveC2SSourceHostId({ sourceHostId: 7 }, vi.fn()),
|
||||
).resolves.toBe(7);
|
||||
});
|
||||
|
||||
it("does not silently fall back to a mismatched id", async () => {
|
||||
await expect(
|
||||
resolveC2SSourceHostId(
|
||||
{ sourceHostId: 7, sourceHostSyncId: "missing" },
|
||||
vi.fn().mockResolvedValue(null),
|
||||
),
|
||||
).rejects.toThrow("not found on the remote server");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type {
|
||||
PluginContext,
|
||||
PluginHostAccess,
|
||||
PluginHostSummary,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
export function host(
|
||||
overrides: Partial<PluginHostSummary> = {},
|
||||
): PluginHostSummary {
|
||||
return {
|
||||
id: 7,
|
||||
userId: "user-1",
|
||||
name: "web",
|
||||
ip: "10.0.0.5",
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: null,
|
||||
folder: null,
|
||||
authType: "password",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts each user can reach, standing in for ctx.hosts. createMockCtx has no
|
||||
* per-user host list, and tunnels checks access as the acting user.
|
||||
*/
|
||||
export function withHosts(
|
||||
mock: MockPluginContext,
|
||||
byUser: Record<string, PluginHostSummary[]>,
|
||||
): void {
|
||||
const reachable = () => byUser[mock.ctx.currentActor() ?? ""] ?? [];
|
||||
const hosts = mock.ctx.hosts as PluginContext["hosts"] & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
Object.assign(hosts, {
|
||||
list: async () => reachable(),
|
||||
get: async (id: number) => reachable().find((h) => h.id === id) ?? null,
|
||||
checkAccess: async (id: number): Promise<PluginHostAccess> => {
|
||||
const found = reachable().find((h) => h.id === id);
|
||||
return found
|
||||
? {
|
||||
hasAccess: true,
|
||||
isOwner: found.userId === mock.ctx.currentActor(),
|
||||
isShared: false,
|
||||
permissionLevel: "manage",
|
||||
}
|
||||
: { hasAccess: false, isOwner: false, isShared: false };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string; body?: unknown },
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin activated against a real in-memory database, its router mounted
|
||||
* on an express app the way core mounts it: JSON parsed, the acting user set
|
||||
* from the request, everything else left to the plugin.
|
||||
*/
|
||||
export async function startServer(
|
||||
options: {
|
||||
permissions?: string[];
|
||||
users?: string[];
|
||||
hosts?: Record<string, PluginHostSummary[]>;
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir);
|
||||
for (const user of options.users ?? ["user-1", "user-2"]) {
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||
.run(user, user);
|
||||
}
|
||||
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: options.permissions ?? ["tunnels.use"],
|
||||
});
|
||||
withHosts(mock, options.hosts ?? { "user-1": [host()] });
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") ?? undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
async request(method, path, { user = "user-1", body: given } = {}) {
|
||||
const body = method === "GET" ? undefined : given;
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"x-test-user": user,
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
},
|
||||
async close() {
|
||||
for (const dispose of [...mock.disposals].reverse()) await dispose();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
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<typeof createTunnelManager> | 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> = {}): 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<void>((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<number>((resolve) => {
|
||||
probe.listen(0, "127.0.0.1", () =>
|
||||
resolve((probe.address() as { port: number }).port),
|
||||
);
|
||||
});
|
||||
await new Promise<void>((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({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
|
||||
import { pluginDir } from "./helpers";
|
||||
|
||||
// c2s_tunnel_presets as core's SQLite bootstrap created it before 2.9.0.
|
||||
// Core never indexed it.
|
||||
const LEGACY_DDL = `
|
||||
CREATE TABLE c2s_tunnel_presets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
platform TEXT,
|
||||
computer_name TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`;
|
||||
|
||||
let db: TestDb | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
db?.close();
|
||||
db = null;
|
||||
});
|
||||
|
||||
const tableExists = (name: string) =>
|
||||
!!db!.sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(name);
|
||||
|
||||
describe("adopting c2s_tunnel_presets", () => {
|
||||
it("keeps every existing row when the legacy table is there", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO c2s_tunnel_presets (user_id, name, config, platform, computer_name) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run("user-1", "Laptop", '[{"scope":"c2s"}]', "win32", "laptop");
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO c2s_tunnel_presets (user_id, name, config) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run("user-2", "Desk", "[]");
|
||||
},
|
||||
});
|
||||
|
||||
expect(db.applied).toEqual(["0001_adopt_c2s_tunnel_presets"]);
|
||||
expect(tableExists("c2s_tunnel_presets")).toBe(false);
|
||||
expect(tableExists("p_tunnels_presets")).toBe(true);
|
||||
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"SELECT user_id, name, config, platform, computer_name FROM p_tunnels_presets ORDER BY id",
|
||||
)
|
||||
.all(),
|
||||
).toEqual([
|
||||
{
|
||||
user_id: "user-1",
|
||||
name: "Laptop",
|
||||
config: '[{"scope":"c2s"}]',
|
||||
platform: "win32",
|
||||
computer_name: "laptop",
|
||||
},
|
||||
{
|
||||
user_id: "user-2",
|
||||
name: "Desk",
|
||||
config: "[]",
|
||||
platform: null,
|
||||
computer_name: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates the table fresh when there is no legacy one", async () => {
|
||||
db = await createTestDb(pluginDir);
|
||||
|
||||
expect(tableExists("c2s_tunnel_presets")).toBe(false);
|
||||
expect(tableExists("p_tunnels_presets")).toBe(true);
|
||||
expect(db.sqlite.prepare("SELECT * FROM p_tunnels_presets").all()).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("removes a user's presets when the user is deleted", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
|
||||
},
|
||||
});
|
||||
const insert = db.sqlite.prepare(
|
||||
"INSERT INTO p_tunnels_presets (user_id, name, config) VALUES (?, ?, '[]')",
|
||||
);
|
||||
insert.run("user-1", "Mine");
|
||||
insert.run("user-2", "Theirs");
|
||||
|
||||
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
|
||||
|
||||
expect(
|
||||
(
|
||||
db.sqlite.prepare("SELECT name FROM p_tunnels_presets").all() as {
|
||||
name: string;
|
||||
}[]
|
||||
).map((row) => row.name),
|
||||
).toEqual(["Theirs"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
RESERVED_TUNNEL_NAME_PREFIX,
|
||||
buildWebEndpointTunnelName,
|
||||
isReservedTunnelName,
|
||||
parseReservedTunnelName,
|
||||
} from "../../src/backend/utils.js";
|
||||
|
||||
describe("reserved tunnel names", () => {
|
||||
it("builds a name from a host id and endpoint id", () => {
|
||||
expect(buildWebEndpointTunnelName(7, "e1")).toBe("web:7:e1");
|
||||
});
|
||||
|
||||
it("recognises its own names and nothing else", () => {
|
||||
expect(isReservedTunnelName(buildWebEndpointTunnelName(7, "e1"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isReservedTunnelName("my-tunnel")).toBe(false);
|
||||
expect(isReservedTunnelName("website")).toBe(false);
|
||||
expect(isReservedTunnelName(RESERVED_TUNNEL_NAME_PREFIX)).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips through parse", () => {
|
||||
expect(
|
||||
parseReservedTunnelName(buildWebEndpointTunnelName(7, "e1")),
|
||||
).toEqual({
|
||||
hostId: 7,
|
||||
endpointId: "e1",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an endpoint id containing colons intact", () => {
|
||||
// Endpoint ids are client-supplied strings with no character restriction,
|
||||
// so splitting on the LAST colon would corrupt them. The host id segment
|
||||
// is digits only, so splitting on the first colon after it is safe.
|
||||
expect(parseReservedTunnelName("web:7:a:b:c")).toEqual({
|
||||
hostId: 7,
|
||||
endpointId: "a:b:c",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for anything it cannot verify", () => {
|
||||
// Callers use this for an ownership check, so null must mean "cannot
|
||||
// verify" and they must fail closed. Never return a partial guess.
|
||||
for (const name of [
|
||||
"my-tunnel",
|
||||
"web:",
|
||||
"web:7",
|
||||
"web::e1",
|
||||
"web:abc:e1",
|
||||
"web:-1:e1",
|
||||
"web:0:e1",
|
||||
"web:7:",
|
||||
"web:7.5:e1",
|
||||
]) {
|
||||
expect(parseReservedTunnelName(name)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { host, startServer, type TestServer } from "./helpers";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
const clientTunnel = (overrides: Record<string, unknown> = {}) => ({
|
||||
scope: "c2s",
|
||||
mode: "local",
|
||||
sourcePort: 8080,
|
||||
endpointPort: 5432,
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("client tunnel presets", () => {
|
||||
it("creates, lists by name, updates and deletes the caller's presets", async () => {
|
||||
server = await startServer();
|
||||
|
||||
const zulu = await server.request("POST", "/presets", {
|
||||
body: {
|
||||
name: " Zulu ",
|
||||
config: [clientTunnel()],
|
||||
platform: "linux",
|
||||
computerName: "workstation",
|
||||
},
|
||||
});
|
||||
expect(zulu.status).toBe(201);
|
||||
expect(zulu.body).toMatchObject({
|
||||
name: "Zulu",
|
||||
config: [clientTunnel()],
|
||||
platform: "linux",
|
||||
computerName: "workstation",
|
||||
});
|
||||
await server.request("POST", "/presets", {
|
||||
body: { name: "Alpha", config: [] },
|
||||
});
|
||||
await server.request("POST", "/presets", {
|
||||
user: "user-2",
|
||||
body: { name: "Other", config: [] },
|
||||
});
|
||||
|
||||
const listed = await server.request("GET", "/presets");
|
||||
expect(listed.body.map((p: { name: string }) => p.name)).toEqual([
|
||||
"Alpha",
|
||||
"Zulu",
|
||||
]);
|
||||
|
||||
const updated = await server.request("PUT", `/presets/${zulu.body.id}`, {
|
||||
body: { name: "Yankee", config: [clientTunnel({ mode: "dynamic" })] },
|
||||
});
|
||||
expect(updated.status).toBe(200);
|
||||
expect(updated.body.name).toBe("Yankee");
|
||||
expect(updated.body.config[0].mode).toBe("dynamic");
|
||||
|
||||
const deleted = await server.request("DELETE", `/presets/${zulu.body.id}`);
|
||||
expect(deleted.body).toEqual({ success: true });
|
||||
expect(
|
||||
(await server.request("GET", "/presets")).body.map(
|
||||
(p: { name: string }) => p.name,
|
||||
),
|
||||
).toEqual(["Alpha"]);
|
||||
expect(server.db.persisted).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects a duplicate name and a config that is not a client tunnel", async () => {
|
||||
server = await startServer();
|
||||
await server.request("POST", "/presets", {
|
||||
body: { name: "Home", config: [] },
|
||||
});
|
||||
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", "/presets", {
|
||||
body: { name: "Home", config: [] },
|
||||
})
|
||||
).status,
|
||||
).toBe(409);
|
||||
|
||||
for (const config of [
|
||||
[clientTunnel({ scope: "s2s" })],
|
||||
[clientTunnel({ mode: "sideways" })],
|
||||
[clientTunnel({ sourcePort: 70000 })],
|
||||
[clientTunnel({ endpointPort: 0 })],
|
||||
"nope",
|
||||
]) {
|
||||
const res = await server.request("POST", "/presets", {
|
||||
body: { name: "Bad", config },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
}
|
||||
|
||||
// Dynamic mode has no destination port to check.
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", "/presets", {
|
||||
body: {
|
||||
name: "Socks",
|
||||
config: [clientTunnel({ mode: "dynamic", endpointPort: 0 })],
|
||||
},
|
||||
})
|
||||
).status,
|
||||
).toBe(201);
|
||||
});
|
||||
|
||||
it("never lets one user touch another user's preset", async () => {
|
||||
server = await startServer();
|
||||
const mine = await server.request("POST", "/presets", {
|
||||
body: { name: "Mine", config: [] },
|
||||
});
|
||||
|
||||
for (const [method, body] of [
|
||||
["PUT", { name: "Stolen" }],
|
||||
["DELETE", undefined],
|
||||
] as const) {
|
||||
const res = await server.request(method, `/presets/${mine.body.id}`, {
|
||||
user: "user-2",
|
||||
body,
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
}
|
||||
expect((await server.request("GET", "/presets")).body[0].name).toBe("Mine");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tunnel control", () => {
|
||||
it("refuses reserved names and a name that does not match its config", async () => {
|
||||
server = await startServer();
|
||||
|
||||
const reserved = await server.request("POST", "/connect", {
|
||||
body: { name: "web:7:e1", sourceHostId: 7, tunnelIndex: 0 },
|
||||
});
|
||||
expect(reserved.status).toBe(400);
|
||||
expect(reserved.body.error).toMatch(/reserved/);
|
||||
|
||||
const mismatched = await server.request("POST", "/connect", {
|
||||
body: {
|
||||
name: "7::0::web::8080::db::5432",
|
||||
sourceHostId: 7,
|
||||
tunnelIndex: 0,
|
||||
sourcePort: 9999,
|
||||
endpointHost: "db",
|
||||
endpointPort: 5432,
|
||||
},
|
||||
});
|
||||
expect(mismatched.status).toBe(400);
|
||||
});
|
||||
|
||||
it("refuses to connect through a host the caller cannot reach", async () => {
|
||||
server = await startServer({ hosts: { "user-1": [host()] } });
|
||||
|
||||
const res = await server.request("POST", "/connect", {
|
||||
user: "user-2",
|
||||
body: {
|
||||
name: "7::0::web::8080::db::5432",
|
||||
sourceHostId: 7,
|
||||
tunnelIndex: 0,
|
||||
sourcePort: 8080,
|
||||
endpointHost: "db",
|
||||
endpointPort: 5432,
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
expect(server.mock.sshConnections).toEqual([]);
|
||||
});
|
||||
|
||||
it("denies stopping someone else's on-demand tunnel by guessing its name", async () => {
|
||||
server = await startServer({
|
||||
hosts: { "user-1": [host()], "user-2": [] },
|
||||
});
|
||||
|
||||
for (const path of ["/disconnect", "/cancel"]) {
|
||||
const res = await server.request("POST", path, {
|
||||
user: "user-2",
|
||||
body: { tunnelName: "web:7:e1" },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
}
|
||||
|
||||
const own = await server.request("POST", "/disconnect", {
|
||||
body: { tunnelName: "web:7:e1" },
|
||||
});
|
||||
expect(own.status).toBe(200);
|
||||
});
|
||||
|
||||
it("answers an empty status map and a 404 for a tunnel nobody can see", async () => {
|
||||
server = await startServer();
|
||||
expect((await server.request("GET", "/status")).body).toEqual({});
|
||||
expect((await server.request("GET", "/status/nope")).status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissions", () => {
|
||||
it("answers 403 on every route without tunnels.use", async () => {
|
||||
server = await startServer({ permissions: [] });
|
||||
|
||||
const routes: Array<[string, string, unknown?]> = [
|
||||
["GET", "/status"],
|
||||
["GET", "/status/stream"],
|
||||
["GET", "/status/some-tunnel"],
|
||||
["POST", "/connect", { name: "x", sourceHostId: 7, tunnelIndex: 0 }],
|
||||
["POST", "/disconnect", { tunnelName: "x" }],
|
||||
["POST", "/cancel", { tunnelName: "x" }],
|
||||
["GET", "/presets"],
|
||||
["POST", "/presets", { name: "x", config: [] }],
|
||||
["PUT", "/presets/1", { name: "x" }],
|
||||
["DELETE", "/presets/1"],
|
||||
];
|
||||
for (const [method, path, body] of routes) {
|
||||
const res = await server.request(method, path, { body });
|
||||
expect(res.status, `${method} ${path}`).toBe(403);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
const declared = (views?: Array<{ id: string }>) =>
|
||||
(views ?? []).map((view) => view.id).sort();
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
const savedTunnel = {
|
||||
scope: "s2s",
|
||||
mode: "local",
|
||||
sourcePort: 8080,
|
||||
endpointHost: "db",
|
||||
endpointPort: 5432,
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: false,
|
||||
};
|
||||
|
||||
const webHost = {
|
||||
id: "7",
|
||||
name: "web",
|
||||
ip: "10.0.0.5",
|
||||
port: 22,
|
||||
username: "root",
|
||||
enableSsh: true,
|
||||
pluginSettings: {
|
||||
tunnels: { enableTunnel: true, tunnelConnections: [savedTunnel] },
|
||||
},
|
||||
};
|
||||
|
||||
function stubApi(overrides: Partial<PluginApiClient> = {}): PluginApiClient {
|
||||
return {
|
||||
get: vi.fn(async () => ({ data: {} })),
|
||||
post: vi.fn(async () => ({ data: {} })),
|
||||
put: vi.fn(async () => ({ data: {} })),
|
||||
patch: vi.fn(async () => ({ data: {} })),
|
||||
delete: vi.fn(async () => ({ data: {} })),
|
||||
...overrides,
|
||||
} as PluginApiClient;
|
||||
}
|
||||
|
||||
describe(`${manifest.id} activate`, () => {
|
||||
it("registers only views its manifest declares", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi(),
|
||||
});
|
||||
const contributes = manifest.contributes as unknown as Record<
|
||||
string,
|
||||
Array<{ id: string }> | undefined
|
||||
>;
|
||||
const tabs = declared(contributes.tabs);
|
||||
const panels = [...declared(contributes.panels), ...tabs];
|
||||
expect(rendered.registered.tabs()).toEqual(["tunnel"]);
|
||||
for (const id of rendered.registered.panels()) expect(panels).toContain(id);
|
||||
expect(rendered.registered.hostEditorSections()).toEqual(["tunnels"]);
|
||||
expect(rendered.registered.hostActions()).toEqual([
|
||||
expect.objectContaining({ id: "tunnel", tabType: "tunnel" }),
|
||||
]);
|
||||
expect(rendered.registered.actions().sort()).toEqual([
|
||||
"tunnels.open",
|
||||
"tunnels.statuses",
|
||||
]);
|
||||
const [rail] = rendered.registered.railItems();
|
||||
expect(rail).toMatchObject({
|
||||
id: "port-forwarding",
|
||||
permission: "tunnels.use",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes everything it registered on deactivate", async () => {
|
||||
const app = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi(),
|
||||
});
|
||||
await app.deactivate();
|
||||
expect(app.registered.tabs()).toEqual([]);
|
||||
expect(app.registered.panels()).toEqual([]);
|
||||
expect(app.registered.railItems()).toEqual([]);
|
||||
expect(app.registered.hostActions()).toEqual([]);
|
||||
expect(app.registered.hostEditorSections()).toEqual([]);
|
||||
expect(app.registered.actions()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tunnel tab", () => {
|
||||
it("lists a host's saved tunnels and starts one by name through the plugin api", async () => {
|
||||
const api = stubApi();
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api,
|
||||
hosts: [webHost],
|
||||
});
|
||||
|
||||
rendered.renderTab("tunnel", { host: webHost });
|
||||
expect(await screen.findByText("db:5432")).toBeTruthy();
|
||||
|
||||
// "tunnels.start" is still a core string, reached through the fallback.
|
||||
fireEvent.click(screen.getByText(/start/i));
|
||||
await waitFor(() =>
|
||||
expect(api.post).toHaveBeenCalledWith(
|
||||
"/connect",
|
||||
expect.objectContaining({
|
||||
name: "7::0::web::8080::db::5432",
|
||||
sourceHostId: 7,
|
||||
tunnelIndex: 0,
|
||||
endpointHost: "db",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host editor section", () => {
|
||||
it("writes the tunnel list into the form's tunnels plugin settings", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi(),
|
||||
});
|
||||
let form: Record<string, unknown> = {
|
||||
name: "web",
|
||||
pluginSettings: { tunnels: { enableTunnel: true } },
|
||||
};
|
||||
const updateForm = vi.fn(
|
||||
(
|
||||
patch: (current: Record<string, unknown>) => Record<string, unknown>,
|
||||
) => {
|
||||
form = patch(form);
|
||||
},
|
||||
);
|
||||
|
||||
rendered.renderHostEditorSection("tunnels", {
|
||||
form,
|
||||
setField: vi.fn(),
|
||||
updateForm,
|
||||
protocols: { ssh: true },
|
||||
});
|
||||
fireEvent.click(screen.getByText(locales.hosts.addTunnelBtn));
|
||||
|
||||
const settings = (form.pluginSettings as Record<string, unknown>)
|
||||
.tunnels as { enableTunnel: boolean; tunnelConnections: unknown[] };
|
||||
expect(settings.enableTunnel).toBe(true);
|
||||
expect(settings.tunnelConnections).toHaveLength(1);
|
||||
expect(settings.tunnelConnections[0]).toMatchObject({
|
||||
scope: "s2s",
|
||||
mode: "local",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
mergeTunnelStatuses,
|
||||
parseServerSentEvents,
|
||||
setTunnelsApi,
|
||||
subscribeTunnelStatuses,
|
||||
} from "../../src/frontend/api";
|
||||
|
||||
afterEach(() => {
|
||||
setTunnelsApi(null);
|
||||
});
|
||||
|
||||
describe("mergeTunnelStatuses", () => {
|
||||
it("keeps local state when a remote tunnel with the same name differs", () => {
|
||||
// Controls always target the local backend, so its view wins.
|
||||
expect(
|
||||
mergeTunnelStatuses(
|
||||
{ shared: { connected: true, status: "connected" } },
|
||||
{
|
||||
shared: { connected: false, status: "disconnected" },
|
||||
remoteOnly: { connected: true, status: "connected" },
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
shared: { connected: true, status: "connected" },
|
||||
remoteOnly: { connected: true, status: "connected" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseServerSentEvents", () => {
|
||||
it("splits complete events and hands back a partial one", () => {
|
||||
const events: Array<{ event: string; data: string }> = [];
|
||||
const rest = parseServerSentEvents(
|
||||
': keepalive\n\nevent: statuses\ndata: {"a":1}\n\nevent: stat',
|
||||
(event) => events.push(event),
|
||||
);
|
||||
expect(events).toEqual([{ event: "statuses", data: '{"a":1}' }]);
|
||||
expect(rest).toBe("event: stat");
|
||||
});
|
||||
});
|
||||
|
||||
function streamOf(chunks: string[]): Response {
|
||||
const encoder = new TextEncoder();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
|
||||
},
|
||||
});
|
||||
return { ok: true, body } as unknown as Response;
|
||||
}
|
||||
|
||||
describe("subscribeTunnelStatuses", () => {
|
||||
it("streams statuses from the plugin's event stream and merges remote ones", async () => {
|
||||
setTunnelsApi({
|
||||
defaults: { baseURL: "http://localhost:30001" },
|
||||
} as never);
|
||||
const fetchImpl = vi.fn(async (_url: string) =>
|
||||
streamOf([
|
||||
`event: statuses\ndata: ${JSON.stringify({ shared: { connected: true, status: "connected" } })}\n\n`,
|
||||
]),
|
||||
);
|
||||
const onStatuses = vi.fn();
|
||||
|
||||
const stop = subscribeTunnelStatuses(onStatuses, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
fetchRemote: async () => ({
|
||||
shared: { connected: false, status: "disconnected" },
|
||||
remoteOnly: { connected: true, status: "connected" },
|
||||
}),
|
||||
});
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(onStatuses).toHaveBeenLastCalledWith({
|
||||
shared: { connected: true, status: "connected" },
|
||||
remoteOnly: { connected: true, status: "connected" },
|
||||
}),
|
||||
);
|
||||
expect(fetchImpl.mock.calls[0][0]).toBe(
|
||||
"http://localhost:30001/plugin-api/tunnels/status/stream",
|
||||
);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("falls back to polling the status route when there is no stream url", async () => {
|
||||
const get = vi.fn(async () => ({
|
||||
data: { t: { connected: true, status: "connected" } },
|
||||
}));
|
||||
setTunnelsApi({ get } as never);
|
||||
const onStatuses = vi.fn();
|
||||
|
||||
const stop = subscribeTunnelStatuses(onStatuses);
|
||||
await vi.waitFor(() =>
|
||||
expect(onStatuses).toHaveBeenCalledWith({
|
||||
t: { connected: true, status: "connected" },
|
||||
}),
|
||||
);
|
||||
expect(get).toHaveBeenCalledWith("/status");
|
||||
stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeClientTunnel,
|
||||
stripClientTunnelDiagnostics,
|
||||
type ClientTunnel,
|
||||
} from "../../src/frontend/c2s-tunnel-config-utils";
|
||||
|
||||
describe("C2STunnelPresetManager tunnel normalization", () => {
|
||||
it("preserves explicit local and remote address fields in saved config", () => {
|
||||
const tunnel = normalizeClientTunnel({
|
||||
scope: "c2s",
|
||||
mode: "local",
|
||||
tunnelType: "local",
|
||||
localAddress: " 127.0.0.2 ",
|
||||
remoteAddress: " 10.10.0.25 ",
|
||||
sourceHostId: 42,
|
||||
sourceHostSyncId: "host-sync-42",
|
||||
sourcePort: 8080,
|
||||
endpointPort: 5432,
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: true,
|
||||
});
|
||||
|
||||
const saved = stripClientTunnelDiagnostics(tunnel);
|
||||
|
||||
expect(saved).toMatchObject({
|
||||
localAddress: "127.0.0.2",
|
||||
remoteAddress: "10.10.0.25",
|
||||
bindHost: "127.0.0.2",
|
||||
targetHost: "10.10.0.25",
|
||||
sourceHostId: 42,
|
||||
sourceHostSyncId: "host-sync-42",
|
||||
sourcePort: 8080,
|
||||
endpointPort: 5432,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves source host sync identity for cross-device C2S presets", () => {
|
||||
const tunnel = normalizeClientTunnel({
|
||||
scope: "c2s",
|
||||
mode: "local",
|
||||
tunnelType: "local",
|
||||
localAddress: "127.0.0.1",
|
||||
remoteAddress: "10.0.0.25",
|
||||
sourceHostId: 5,
|
||||
sourceHostSyncId: "shared-host-sync-id",
|
||||
sourcePort: 8080,
|
||||
endpointPort: 80,
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: false,
|
||||
});
|
||||
|
||||
const saved = stripClientTunnelDiagnostics(tunnel);
|
||||
|
||||
expect(saved).toMatchObject({
|
||||
sourceHostId: 5,
|
||||
sourceHostSyncId: "shared-host-sync-id",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes legacy bindHost and targetHost configs into address fields", () => {
|
||||
const tunnel = normalizeClientTunnel({
|
||||
scope: "c2s",
|
||||
mode: "local",
|
||||
tunnelType: "local",
|
||||
bindHost: "127.0.0.3",
|
||||
targetHost: "172.16.1.50",
|
||||
sourceHostId: 7,
|
||||
sourcePort: 9000,
|
||||
endpointPort: 9443,
|
||||
maxRetries: 1,
|
||||
retryInterval: 5,
|
||||
autoStart: false,
|
||||
});
|
||||
|
||||
expect(tunnel).toMatchObject({
|
||||
localAddress: "127.0.0.3",
|
||||
remoteAddress: "172.16.1.50",
|
||||
bindHost: "127.0.0.3",
|
||||
targetHost: "172.16.1.50",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves tunnel name spacing while editing", () => {
|
||||
const tunnel = normalizeClientTunnel({
|
||||
scope: "c2s",
|
||||
mode: "local",
|
||||
tunnelType: "local",
|
||||
displayName: "Axolot ",
|
||||
});
|
||||
|
||||
expect(tunnel.displayName).toBe("Axolot ");
|
||||
});
|
||||
|
||||
it("trims tunnel names only when saving", () => {
|
||||
const tunnel = normalizeClientTunnel({
|
||||
scope: "c2s",
|
||||
mode: "local",
|
||||
tunnelType: "local",
|
||||
localAddress: "127.0.0.1",
|
||||
remoteAddress: "10.0.0.10",
|
||||
sourcePort: 8080,
|
||||
endpointPort: 80,
|
||||
displayName: " Axolot MO ",
|
||||
});
|
||||
|
||||
const saved = stripClientTunnelDiagnostics(tunnel);
|
||||
|
||||
expect(saved).toMatchObject({
|
||||
displayName: "Axolot MO",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes diagnostics without dropping routing fields", () => {
|
||||
const tunnel: ClientTunnel = {
|
||||
scope: "c2s",
|
||||
mode: "local",
|
||||
tunnelType: "local",
|
||||
localAddress: "127.0.0.1",
|
||||
remoteAddress: "10.0.0.10",
|
||||
bindHost: "127.0.0.1",
|
||||
targetHost: "10.0.0.10",
|
||||
sourceHostId: 1,
|
||||
sourcePort: 8080,
|
||||
endpointPort: 80,
|
||||
endpointHost: "edge",
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: false,
|
||||
displayName: "web",
|
||||
lastStartedAt: "2026-07-20T00:00:00.000Z",
|
||||
lastTestedAt: "2026-07-20T00:01:00.000Z",
|
||||
lastError: "previous error",
|
||||
};
|
||||
|
||||
const saved = stripClientTunnelDiagnostics(tunnel);
|
||||
|
||||
expect(saved).not.toHaveProperty("lastStartedAt");
|
||||
expect(saved).not.toHaveProperty("lastTestedAt");
|
||||
expect(saved).not.toHaveProperty("lastError");
|
||||
expect(saved).toMatchObject({
|
||||
localAddress: "127.0.0.1",
|
||||
remoteAddress: "10.0.0.10",
|
||||
sourceHostId: 1,
|
||||
sourcePort: 8080,
|
||||
endpointPort: 80,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
getTunnelTypeForMode,
|
||||
getTunnelPortLabels,
|
||||
getTunnelModeDescription,
|
||||
} from "../../src/frontend/tunnel-form-utils";
|
||||
|
||||
// A fake translate that echoes the key plus any interpolation args so we can
|
||||
// assert which translation key + payload the helpers chose.
|
||||
const t = (key: string, opts?: Record<string, string | number>) =>
|
||||
opts ? `${key}:${JSON.stringify(opts)}` : key;
|
||||
|
||||
describe("getTunnelTypeForMode", () => {
|
||||
it("maps remote to remote and everything else to local", () => {
|
||||
expect(getTunnelTypeForMode("remote")).toBe("remote");
|
||||
expect(getTunnelTypeForMode("local")).toBe("local");
|
||||
expect(getTunnelTypeForMode("dynamic")).toBe("local");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTunnelPortLabels", () => {
|
||||
it("labels client local mode source/endpoint ports", () => {
|
||||
const labels = getTunnelPortLabels("client", "local", t);
|
||||
expect(labels.sourcePortLabel).toBe("tunnels.localPort");
|
||||
expect(labels.endpointPortLabel).toBe("tunnels.destinationPort");
|
||||
});
|
||||
|
||||
it("swaps labels for client remote mode", () => {
|
||||
const labels = getTunnelPortLabels("client", "remote", t);
|
||||
expect(labels.sourcePortLabel).toBe("tunnels.remotePort");
|
||||
expect(labels.endpointPortLabel).toBe("tunnels.localPort");
|
||||
});
|
||||
|
||||
it("uses host/endpoint labels in server scope", () => {
|
||||
const labels = getTunnelPortLabels("server", "local", t);
|
||||
expect(labels.sourcePortLabel).toBe("tunnels.currentHostPort");
|
||||
expect(labels.endpointPortLabel).toBe("tunnels.endpointPort");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTunnelModeDescription", () => {
|
||||
const ports = { sourcePort: 8080, endpointPort: 9090 };
|
||||
|
||||
it("selects the client dynamic description with only the source port", () => {
|
||||
const desc = getTunnelModeDescription("client", "dynamic", ports, t);
|
||||
expect(desc).toContain("tunnels.forwardDescriptionClientDynamic");
|
||||
expect(desc).toContain("8080");
|
||||
expect(desc).not.toContain("9090");
|
||||
});
|
||||
|
||||
it("selects the client local description with both ports", () => {
|
||||
const desc = getTunnelModeDescription("client", "local", ports, t);
|
||||
expect(desc).toContain("tunnels.forwardDescriptionClientLocal");
|
||||
expect(desc).toContain("8080");
|
||||
expect(desc).toContain("9090");
|
||||
});
|
||||
|
||||
it("selects the server remote description", () => {
|
||||
const desc = getTunnelModeDescription("server", "remote", ports, t);
|
||||
expect(desc).toContain("tunnels.forwardDescriptionServerRemote");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
clientTunnelName,
|
||||
serverTunnelName,
|
||||
} from "../../src/shared/tunnel-naming";
|
||||
import { connectRequestFor } from "../../src/frontend/host-tunnels";
|
||||
|
||||
const host = { id: "7", name: "web", username: "root", ip: "10.0.0.5" };
|
||||
const tunnel = {
|
||||
scope: "s2s" as const,
|
||||
mode: "local" as const,
|
||||
sourcePort: 8080,
|
||||
endpointHost: " db ",
|
||||
endpointPort: 5432,
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: false,
|
||||
};
|
||||
|
||||
describe("tunnel names", () => {
|
||||
it("builds a server tunnel name the connect request carries unchanged", () => {
|
||||
const name = serverTunnelName(host, 0, tunnel);
|
||||
expect(name).toBe("7::0::web::8080::db::5432");
|
||||
const request = connectRequestFor(host, 0, tunnel);
|
||||
expect(request.name).toBe(name);
|
||||
// The backend checks the name against these fields, so they must agree.
|
||||
expect(request.endpointHost).toBe("db");
|
||||
expect(request.sourceHostId).toBe(7);
|
||||
});
|
||||
|
||||
it("falls back to user@ip for an unnamed host", () => {
|
||||
expect(serverTunnelName({ ...host, name: "" }, 1, tunnel)).toBe(
|
||||
"7::1::root@10.0.0.5::8080::db::5432",
|
||||
);
|
||||
});
|
||||
|
||||
it("names a client tunnel the way the desktop app tracks it", () => {
|
||||
expect(
|
||||
clientTunnelName(
|
||||
{
|
||||
sourceHostId: 3,
|
||||
mode: "remote",
|
||||
localAddress: "",
|
||||
remoteAddress: "10.0.0.1",
|
||||
sourcePort: 9000,
|
||||
endpointPort: 80,
|
||||
},
|
||||
2,
|
||||
),
|
||||
).toBe("c2s::2::3::remote::127.0.0.1::10.0.0.1::9000::80");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user