mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 15:45:23 +00:00
feat: convert ssh tunnels into a plugin
This commit is contained in:
1 parent
c85c1fdb86
commit
1682861853
220 files changed
+36928
-11374
No files matched your search
@@ -0,0 +1,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