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

+7 -1
View File
@@ -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(
+31
View File
@@ -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/);
});
});