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

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