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

@@ -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");
});
});
+148
View File
@@ -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",
});
});
});
+101
View File
@@ -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");
});
});