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

+2 -4
View File
@@ -1,11 +1,9 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { startWebEndpointService, stopWebEndpointService } from "./routes.js";
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
startWebEndpointService(ctx.http.router());
startWebEndpointService(ctx.http.router(), ctx);
ctx.disposables.add(() => stopWebEndpointService());
ctx.log.info("Web Endpoint routes mounted at /plugin-api/web-endpoint");
}
+68 -240
View File
@@ -1,133 +1,45 @@
import express, { type Router } from "express";
import { Client } from "ssh2";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { tunnelLogger } from "../../../../src/backend/utils/logger.js";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { parseWebUiConfig } from "../../../../src/backend/database/routes/host-web-endpoints.js";
import {
activeTunnelRuntimes,
cleanupTunnelResources,
connectSSHTunnel,
connectionStatus,
tunnelConnecting,
} from "../../../../src/backend/hosts/tunnel/manager.js";
import { forwardOut } from "../../../../src/backend/hosts/tunnel/ssh-primitives.js";
import { buildWebEndpointTunnelName } from "../../../../src/backend/hosts/tunnel/utils.js";
import type { TunnelConfig, WebEndpoint } from "../../../../src/types/index.js";
/** Matches the spec's ten minutes. */
const WEB_ENDPOINT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
/**
* Long enough for a slow SSH handshake, short enough that a wedged connection
* does not hold the HTTP request open indefinitely. One second past the
* manager's own connection timeout so the manager reports first.
*/
const TUNNEL_READY_TIMEOUT_MS = 61_000;
const TUNNEL_READY_POLL_MS = 100;
const TARGET_PROBE_TIMEOUT_MS = 10_000;
/** The tunnels plugin, as ctx.services.get("tunnels.access") returns it. */
interface TunnelsAccess {
forward(
sourceHostId: number,
target: {
targetHost: string;
targetPort: number;
bindHost?: string;
bindPort?: number;
},
options?: { name?: string; idleTimeoutMs?: number },
): Promise<{ bindHost: string; bindPort: number }>;
}
/**
* What the live forward was built for. Compared on reuse so an endpoint edited
* under a live tunnel is reopened rather than silently forwarding to the old
* target.
*
* JSON rather than a joined string: no separator can be smuggled in by a field
* value, and the result stays readable in a log. The SSH identity is included
* because a host repointed at a different server must also invalidate the
* forward, not just a changed endpoint port.
* Reserved by the tunnels plugin for on-demand forwards: never retried, and
* the host id in it is what the tunnels plugin checks before letting anyone
* stop one by name.
*/
function targetFingerprint(
endpoint: Pick<
WebEndpoint,
"scheme" | "port" | "access" | "bindHost" | "localPort"
>,
host: { ip?: string; port?: number; username?: string },
export function webEndpointTunnelName(
hostId: number,
endpointId: string,
): string {
return JSON.stringify({
scheme: endpoint.scheme,
port: endpoint.port,
access: endpoint.access,
bindHost: endpoint.bindHost ?? null,
localPort: endpoint.localPort ?? null,
ip: host.ip ?? null,
sshPort: host.port ?? null,
username: host.username ?? null,
});
return `web:${hostId}:${endpointId}`;
}
const openedTargets = new Map<string, string>();
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* connectSSHTunnel(config) returns Promise<void> and NEVER REJECTS: every
* failure path inside it reports over broadcastTunnelStatus and returns
* normally, and its own promise resolves right after conn.connect(), long
* before the SSH "ready" event that would populate activeTunnelRuntimes.
*
* So "awaited" is not "connected". This is what actually waits.
*
* Polling the maps the manager already exports, rather than adding a
* promise-returning variant to manager.ts: every existing caller of
* connectSSHTunnel is fire-and-forget by design (the connect route responds
* "Connection request received" immediately and reports asynchronously over
* the status broadcast), so changing that contract to serve one new caller is
* a far larger blast radius than polling here.
*/
async function waitForTunnelSettled(tunnelName: string): Promise<void> {
const deadline = Date.now() + TUNNEL_READY_TIMEOUT_MS;
while (Date.now() < deadline) {
if (activeTunnelRuntimes.has(tunnelName)) return;
const status = connectionStatus.get(tunnelName);
if (!tunnelConnecting.has(tunnelName) && status && !status.connected) {
throw new Error(status.reason || "The endpoint tunnel failed to connect");
}
await sleep(TUNNEL_READY_POLL_MS);
}
throw new Error("Timed out establishing the endpoint tunnel");
}
/**
* Opens one channel to the target and closes it immediately.
*
* Without this the route returns 200 the moment tcpServer.listen succeeds, and
* forwardOut is only attempted per inbound socket -- where a failure is
* swallowed by the socket's own error handling. The user then gets a blank
* frame and no message for the single likeliest error: nothing is actually
* listening on that port. Costs one round trip per open.
*/
async function probeEndpointTarget(
sourceClient: Client,
targetPort: number,
tunnelName: string,
): Promise<void> {
const channel = await Promise.race([
forwardOut(sourceClient, "127.0.0.1", targetPort, tunnelName),
new Promise<never>((_resolve, reject) =>
setTimeout(
() => reject(new Error("Timed out reaching the endpoint port")),
TARGET_PROBE_TIMEOUT_MS,
),
),
]);
try {
channel.end();
} catch {
// Already gone; the probe succeeded either way.
}
}
let current: PluginContext | null = null;
export async function handleWebEndpointOpen(
req: express.Request,
res: express.Response,
): Promise<express.Response | void> {
const userId = (req as { userId?: string }).userId;
if (!userId) {
const ctx = current;
const userId = ctx?.currentActor();
if (!ctx || !userId) {
return res.status(401).json({ error: "Authentication required" });
}
@@ -141,10 +53,8 @@ export async function handleWebEndpointOpen(
}
// Deliberately NOT gated to the desktop. The forward binds wherever this
// backend runs, exactly as the server tunnels feature does, and the
// endpoint's own bindHost decides whether that is reachable from the
// browser -- loopback for the desktop, an address the server answers on for
// a web deployment.
// backend runs, and the endpoint's own bindHost decides whether that is
// reachable from the browser.
const { resolveHostById } =
await import("../../../../src/backend/hosts/host-resolver.js");
@@ -153,11 +63,9 @@ export async function handleWebEndpointOpen(
return res.status(403).json({ error: "Host not found or access denied" });
}
// All three write paths null the config on disable, so a configured-but-
// disabled host is not normally reachable -- except a bulk update that sends
// only { webUiConfig } while the stored enable_web_ui is still 0. The UI
// reads as off everywhere in that state, so the endpoint merely being
// present in the config is not on its own a licence to open a tunnel.
// A bulk update that sends only { webUiConfig } can leave the endpoint in
// the config while the feature is off. The UI reads as off in that state,
// so the endpoint being listed is not on its own a licence to open a tunnel.
if (!host.enableWebUi) {
return res
.status(400)
@@ -178,118 +86,42 @@ export async function handleWebEndpointOpen(
.json({ error: "This endpoint does not use a tunnel" });
}
const tunnelName = buildWebEndpointTunnelName(hostId, endpointId);
const fingerprint = targetFingerprint(endpoint, host);
const existing = activeTunnelRuntimes.get(tunnelName);
if (existing) {
const recorded = openedTargets.get(tunnelName);
// The runtime is the source of truth and openedTargets is derived from it,
// so an ABSENT fingerprint means reuse, not staleness. A reserved-prefixed
// runtime can only have been created by this route.
if (recorded === undefined || recorded === fingerprint) {
openedTargets.set(tunnelName, fingerprint);
return res.status(200).json({ port: existing.bindPort });
}
tunnelLogger.info("Reopening web endpoint tunnel after config change", {
operation: "web_endpoint_reopen",
tunnelName,
});
// Forced, for the same reason handleDisconnect's reserved-name branch
// forces: cleanupTunnelResources no-ops while tunnelConnecting holds the
// name, and a web tunnel has no retry pass to self-heal a runtime left
// behind. An unforced call could leave a dead listener registered.
await cleanupTunnelResources(tunnelName, true);
openedTargets.delete(tunnelName);
let tunnels: TunnelsAccess;
try {
tunnels = ctx.services.get<TunnelsAccess>("tunnels.access", { userId });
} catch {
return res
.status(503)
.json({ error: "The tunnels plugin is not available" });
}
const tunnelConfig: TunnelConfig = {
name: tunnelName,
scope: "s2s",
mode: "local",
tunnelType: "local",
// Loopback unless the endpoint asks otherwise. A non-loopback bind
// publishes the target's web UI to anyone who can reach the port, with no
// authentication in front of it -- the same trade the server tunnels
// feature exposes, and only ever an explicit per-endpoint choice. The
// value is validated to a bare host literal by normalizeBindHost.
bindHost: endpoint.bindHost || "127.0.0.1",
sourceHostId: host.id ?? hostId,
tunnelIndex: 0,
requestingUserId: userId,
hostName: host.name || `${host.username}@${host.ip}`,
sourceIP: host.ip,
sourceSSHPort: host.port,
sourceUsername: host.username,
sourcePassword: host.password,
sourceAuthMethod: host.authType,
sourceSSHKey: host.key,
sourceKeyPassword: host.keyPassword,
sourceKeyType: host.keyType,
sourceCredentialId: host.credentialId,
// connectSSHTunnel reads sourceUserId when building its client.
sourceUserId: host.userId,
endpointIP: host.ip,
endpointSSHPort: host.port,
endpointUsername: host.username,
// Load-bearing: connectSSHTunnel picks its strategy via
// shouldEstablishDirectTunnel -> isSingleHostTunnel, which returns true
// when endpointHost is "127.0.0.1". That is what selects
// establishDirectTunnel's local branch -- the bind-locally-and-forwardOut
// path this feature needs. Setting only targetHost would NOT select it.
endpointHost: "127.0.0.1",
endpointAuthMethod: host.authType,
endpointSSHKey: host.key,
endpointKeyPassword: host.keyPassword,
endpointKeyType: host.keyType,
endpointCredentialId: host.credentialId,
endpointUserId: host.userId,
// A fixed port when the endpoint names one, as the server tunnels
// feature's Source Port does -- a container can only publish ports it
// knows in advance. Otherwise 0 and the kernel picks; the manager reports
// back what it actually bound either way.
sourcePort: endpoint.localPort ?? 0,
endpointPort: endpoint.port,
// Documentation only: maxRetries 0 does NOT disable retries, because the
// manager reads `maxRetries || 3`. Retry is actually suppressed by the
// reserved-name early return in handleDisconnect.
maxRetries: 0,
retryInterval: 0,
autoStart: false,
isPinned: Boolean(host.pin),
idleTimeoutMs: WEB_ENDPOINT_IDLE_TIMEOUT_MS,
useSocks5: Boolean(host.useSocks5),
socks5Host: host.socks5Host,
socks5Port: host.socks5Port,
socks5Username: host.socks5Username,
socks5Password: host.socks5Password,
socks5ProxyChain: host.socks5ProxyChain,
} as TunnelConfig;
try {
await connectSSHTunnel(tunnelConfig);
await waitForTunnelSettled(tunnelName);
const runtime = activeTunnelRuntimes.get(tunnelName);
if (!runtime) {
throw new Error("The endpoint tunnel closed before it could be used");
}
await probeEndpointTarget(runtime.sourceClient, endpoint.port, tunnelName);
openedTargets.set(tunnelName, fingerprint);
return res.status(200).json({ port: runtime.bindPort });
const handle = await tunnels.forward(
hostId,
{
targetHost: "127.0.0.1",
targetPort: endpoint.port,
// Loopback unless the endpoint asks otherwise. A non-loopback bind
// publishes the target's web UI to anyone who can reach the port, with
// no authentication in front of it, and is only ever an explicit
// per-endpoint choice.
bindHost: endpoint.bindHost || "127.0.0.1",
// A fixed port when the endpoint names one, since a container can only
// publish ports it knows in advance. Otherwise the kernel picks.
bindPort: endpoint.localPort ?? undefined,
},
{
name: webEndpointTunnelName(hostId, endpointId),
idleTimeoutMs: WEB_ENDPOINT_IDLE_TIMEOUT_MS,
},
);
return res.status(200).json({ port: handle.bindPort });
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
tunnelLogger.error("Failed to open web endpoint tunnel", error, {
operation: "web_endpoint_open_failed",
tunnelName,
});
// Leave nothing half-built: a listener with no working forward behind it
// would be handed out on the next open as a live tunnel.
await cleanupTunnelResources(tunnelName, true).catch(() => undefined);
openedTargets.delete(tunnelName);
ctx.log.error(
`Failed to open web endpoint tunnel for host ${hostId}: ${reason}`,
error instanceof Error ? error : undefined,
);
return res.status(502).json({ error: reason });
}
}
@@ -298,22 +130,18 @@ export const router = express.Router();
let started = false;
export function startWebEndpointService(mountOn: Router): void {
export function startWebEndpointService(
mountOn: Router,
ctx: PluginContext,
): void {
current = ctx;
if (!started) {
// Built on first start rather than at module scope so importing this
// module for a handler unit test does not construct the auth singleton.
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
// Only "/open" here: the dispatcher this router is registered with is
// mounted at /ssh/tunnel/web-endpoint, so the full path stays
// /ssh/tunnel/web-endpoint/open exactly as before. The "/ssh" prefix is
// part of the path the tunnel service serves -- nginx proxies /ssh/tunnel/
// through with the path intact.
router.post("/open", authenticateJWT, handleWebEndpointOpen);
router.post("/open", handleWebEndpointOpen);
started = true;
}
mountOn.use(router);
}
export function stopWebEndpointService(): void {
// Unmounted by the runtime when the plugin deactivates.
current = null;
}
-36
View File
@@ -1,36 +0,0 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
+7 -1
View File
@@ -12,7 +12,10 @@ import type { Host } from "@/types/ui-types";
import type { WebEndpoint } from "@/types";
import { WebEndpointTab } from "./WebEndpointTab";
import { HostWebUiTab } from "./HostWebUiTab";
import { openWebEndpointExternally } from "./web-endpoint-api";
import {
openWebEndpointExternally,
setWebEndpointApi,
} from "./web-endpoint-api";
const TAB_TYPE = "web-endpoint";
@@ -73,6 +76,9 @@ function WebUiSection({ form, setField, protocols }: HostEditorSectionProps) {
}
export function activate(app: TermixApp): void {
setWebEndpointApi(app.api);
app.onDispose(() => setWebEndpointApi(null));
app.registerTab(TAB_TYPE, EndpointTab, {
icon: Globe,
titleKey: "hosts.tabWebUi",
@@ -1,9 +1,17 @@
import { currentTunnelHost, resolveWebEndpointUrl } from "./web-endpoint-url";
import axios from "axios";
import { handleApiError, tunnelApi } from "@/main-axios";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import { handleApiError } from "@/main-axios";
import { isElectron } from "@/lib/electron";
import type { WebEndpoint } from "@/types/index";
let pluginApi: PluginApiClient | null = null;
/** Set from activate with app.api, cleared on deactivate. */
export function setWebEndpointApi(api: PluginApiClient | null): void {
pluginApi = api;
}
/**
* Thrown when the backend rejects a web endpoint tunnel open with a specific,
* actionable reason -- most commonly a 502 carrying the real cause: SSH auth
@@ -32,10 +40,9 @@ export async function openWebEndpointTunnel(
endpointId: string,
): Promise<number> {
try {
// Relative to the tunnel API base, which already includes /ssh
// (getApiUrl("/ssh", 30003)). A path beginning "/ssh" here would resolve
// to /ssh/ssh/... and 404 on every call.
const response = await tunnelApi.post("/tunnel/web-endpoint/open", {
if (!pluginApi) throw new Error("The web endpoint plugin is not active");
// Relative to the plugin mount, /plugin-api/web-endpoint.
const response = await pluginApi.post("/open", {
hostId,
endpointId,
});