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