mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25: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
@@ -28,7 +28,8 @@
|
||||
],
|
||||
"optionalDependencies": {
|
||||
"snippets": "^1.0.0",
|
||||
"fleets": "^1.0.0"
|
||||
"fleets": "^1.0.0",
|
||||
"tunnels": "^1.0.0"
|
||||
},
|
||||
"requires": [
|
||||
{
|
||||
@@ -40,6 +41,11 @@
|
||||
"service": "fleets.access",
|
||||
"versionRange": "^1.0.0",
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"service": "tunnels.access",
|
||||
"versionRange": "^1.0.0",
|
||||
"optional": true
|
||||
}
|
||||
],
|
||||
"contributes": {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { withSshConnection } from "../ssh.js";
|
||||
import { createCurrentNotificationChannelRepository } from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { getSnippet, resolveSnippetCommandFor } from "../snippets.js";
|
||||
import { runTunnelAction } from "../tunnels.js";
|
||||
import { sendAutomationNotification } from "../notify.js";
|
||||
import { automationFetch } from "../http.js";
|
||||
import { renderRecord, renderTemplate } from "../template.js";
|
||||
@@ -241,25 +242,15 @@ async function runTunnel(
|
||||
const name = renderTemplate(step.tunnelName, context.template);
|
||||
if (context.dryRun) return ok(`Would ${step.action} tunnel ${name}`);
|
||||
|
||||
try {
|
||||
const manager =
|
||||
await import("../../../../../src/backend/hosts/tunnel/manager.js");
|
||||
const config = manager.tunnelConfigs?.get(name);
|
||||
if (!config) return fail(`Tunnel "${name}" is not configured`);
|
||||
|
||||
if (step.action === "connect") {
|
||||
await manager.connectSSHTunnel(config);
|
||||
return ok(`Tunnel ${name} connected`);
|
||||
}
|
||||
|
||||
// shouldRetry false, otherwise the manager immediately reconnects the
|
||||
// tunnel the automation just asked it to drop.
|
||||
manager.manualDisconnects.add(name);
|
||||
await manager.handleDisconnect(name, config, false);
|
||||
return ok(`Tunnel ${name} disconnected`);
|
||||
} catch (error) {
|
||||
return fail(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
// A disconnect goes through the tunnels plugin's manual stop, which also
|
||||
// holds off its retries, so the tunnel stays down.
|
||||
const result = await runTunnelAction(context.userId, step.action, name);
|
||||
if (!result.ok) return fail(result.error);
|
||||
return ok(
|
||||
step.action === "connect"
|
||||
? `Tunnel ${name} connected`
|
||||
: `Tunnel ${name} disconnected`,
|
||||
);
|
||||
}
|
||||
|
||||
async function runWol(
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { setPluginSsh } from "./ssh.js";
|
||||
import { setPluginServices } from "./snippets.js";
|
||||
import { setTunnelServices } from "./tunnels.js";
|
||||
import { startAutomationsService, stopAutomationsService } from "./routes.js";
|
||||
import {
|
||||
startAutomationScheduler,
|
||||
stopAutomationScheduler,
|
||||
} from "./scheduler.js";
|
||||
import { onInternalEvent } from "./triggers.js";
|
||||
|
||||
/** What the tunnels plugin emits when a tunnel drops without being asked to. */
|
||||
interface TunnelDisconnectedEvent {
|
||||
userId?: string;
|
||||
hostId?: number;
|
||||
tunnelName?: string;
|
||||
}
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
setPluginSsh(ctx.ssh);
|
||||
ctx.disposables.add(() => setPluginSsh(null));
|
||||
setPluginServices(ctx.services);
|
||||
ctx.disposables.add(() => setPluginServices(null));
|
||||
setTunnelServices(ctx.services);
|
||||
ctx.disposables.add(() => setTunnelServices(null));
|
||||
|
||||
// Nothing arrives here while the tunnels plugin is off, which is the whole
|
||||
// of the optional dependency.
|
||||
ctx.events.on("plugin.tunnels.tunnel_disconnected", (payload) => {
|
||||
const event = payload as TunnelDisconnectedEvent;
|
||||
if (!event?.userId) return;
|
||||
void onInternalEvent({
|
||||
event: "tunnel_disconnected",
|
||||
userId: event.userId,
|
||||
hostId: event.hostId,
|
||||
details: { tunnelName: event.tunnelName },
|
||||
}).catch((error: unknown) => {
|
||||
ctx.log.warn(
|
||||
`Tunnel disconnect trigger failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
startAutomationsService(
|
||||
// The webhook route authenticates on its own per-automation token rather
|
||||
// than a session, which is the point of an inbound webhook.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { PluginServices } from "@termix/plugin-sdk/backend";
|
||||
|
||||
let current: PluginServices | null = null;
|
||||
|
||||
/** Set in activate, cleared on deactivate. */
|
||||
export function setTunnelServices(services: PluginServices | null): void {
|
||||
current = services;
|
||||
}
|
||||
|
||||
/** The tunnels plugin, as ctx.services.get("tunnels.access") returns it. */
|
||||
interface TunnelsAccess {
|
||||
start: (name: string) => Promise<void>;
|
||||
stop: (name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface TunnelActionResult {
|
||||
ok: boolean;
|
||||
/** True when the tunnels plugin is off or the user may not use it. */
|
||||
unavailable?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects or disconnects a saved tunnel as userId. Optional: without the
|
||||
* tunnels plugin the step fails with a clear message rather than the plugin
|
||||
* failing to activate.
|
||||
*/
|
||||
export async function runTunnelAction(
|
||||
userId: string,
|
||||
action: "connect" | "disconnect",
|
||||
name: string,
|
||||
): Promise<TunnelActionResult> {
|
||||
if (!current) {
|
||||
return {
|
||||
ok: false,
|
||||
unavailable: true,
|
||||
error: "The tunnels plugin is not available",
|
||||
};
|
||||
}
|
||||
|
||||
let tunnels: TunnelsAccess;
|
||||
try {
|
||||
tunnels = current.get<TunnelsAccess>("tunnels.access", { userId });
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
unavailable: true,
|
||||
error: "The tunnels plugin is not available",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === "connect") await tunnels.start(name);
|
||||
else await tunnels.stop(name);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
unavailable: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createFakeContext } from "@termix/plugin-sdk/testing";
|
||||
|
||||
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentNotificationChannelRepository: vi.fn(),
|
||||
}));
|
||||
|
||||
const { setTunnelServices } = await import("../../src/backend/tunnels.js");
|
||||
const { executeStep } = await import("../../src/backend/actions/index.js");
|
||||
|
||||
afterEach(() => {
|
||||
setTunnelServices(null);
|
||||
});
|
||||
|
||||
function context(dryRun = false) {
|
||||
return {
|
||||
userId: "user-1",
|
||||
automationId: 1,
|
||||
runId: 1,
|
||||
dryRun,
|
||||
template: {},
|
||||
} as never;
|
||||
}
|
||||
|
||||
const step = (action: "connect" | "disconnect") =>
|
||||
({
|
||||
id: "s1",
|
||||
type: "tunnel",
|
||||
action,
|
||||
tunnelName: "7::0::web::8080::db::5432",
|
||||
}) as never;
|
||||
|
||||
describe("the tunnel step", () => {
|
||||
it("starts and stops the tunnel through tunnels.access as the automation's owner", async () => {
|
||||
const fake = createFakeContext({ pluginId: "automations" });
|
||||
const tunnels = {
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(async () => undefined),
|
||||
};
|
||||
fake.ctx.services.provide("tunnels.access", tunnels);
|
||||
const get = vi.spyOn(fake.ctx.services, "get");
|
||||
setTunnelServices(fake.ctx.services);
|
||||
|
||||
const connected = await executeStep(step("connect"), context());
|
||||
expect(connected.success).toBe(true);
|
||||
expect(tunnels.start).toHaveBeenCalledWith("7::0::web::8080::db::5432");
|
||||
expect(get).toHaveBeenCalledWith("tunnels.access", { userId: "user-1" });
|
||||
|
||||
const stopped = await executeStep(step("disconnect"), context());
|
||||
expect(stopped.success).toBe(true);
|
||||
expect(tunnels.stop).toHaveBeenCalledWith("7::0::web::8080::db::5432");
|
||||
});
|
||||
|
||||
it("reports the tunnel plugin's own error", async () => {
|
||||
const fake = createFakeContext({ pluginId: "automations" });
|
||||
fake.ctx.services.provide("tunnels.access", {
|
||||
start: vi.fn(async () => {
|
||||
throw new Error('Tunnel "x" is not configured');
|
||||
}),
|
||||
stop: vi.fn(),
|
||||
});
|
||||
setTunnelServices(fake.ctx.services);
|
||||
|
||||
const result = await executeStep(step("connect"), context());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error ?? result.output).toMatch(/not configured/);
|
||||
});
|
||||
|
||||
it("fails the step cleanly while the tunnels plugin is off", async () => {
|
||||
// No provider: the optional dependency is missing.
|
||||
setTunnelServices(
|
||||
createFakeContext({ pluginId: "automations" }).ctx.services,
|
||||
);
|
||||
const withoutProvider = await executeStep(step("connect"), context());
|
||||
expect(withoutProvider.success).toBe(false);
|
||||
expect(withoutProvider.error ?? withoutProvider.output).toMatch(
|
||||
/tunnels plugin is not available/,
|
||||
);
|
||||
|
||||
setTunnelServices(null);
|
||||
const inactive = await executeStep(step("disconnect"), context());
|
||||
expect(inactive.success).toBe(false);
|
||||
});
|
||||
|
||||
it("only describes what it would do on a dry run", async () => {
|
||||
const result = await executeStep(step("connect"), context(true));
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toMatch(/Would connect tunnel/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user