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

+29
View File
@@ -0,0 +1,29 @@
import { isReservedTunnelName, parseReservedTunnelName } from "./utils.js";
/**
* Ownership check for an action addressed by tunnel name alone.
*
* On-demand (forward) tunnels are deliberately never registered as configs,
* so a check that only ran for a registered config would never run for them.
* Host ids are small sequential integers and endpoint ids come from the
* client, so anyone could guess `web:{hostId}:{endpointId}` and force-close
* another user's live tunnel. The host id is read back out of the reserved
* name instead, and a reserved name whose host id cannot be parsed fails
* closed rather than falling through to the unchecked path.
*/
export async function authorizeTunnelAction(
canAccessHost: (hostId: number) => Promise<boolean>,
tunnelName: string,
config: { sourceHostId?: number } | undefined,
): Promise<{ allowed: boolean; hostId?: number }> {
const hostId =
config?.sourceHostId ?? parseReservedTunnelName(tunnelName)?.hostId;
if (hostId === undefined) {
// Not reserved and not registered: nothing is running under this name
// that anyone could own, so there is nothing to protect.
return { allowed: !isReservedTunnelName(tunnelName) };
}
return { allowed: await canAccessHost(hostId), hostId };
}
+94
View File
@@ -0,0 +1,94 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { TunnelManager } from "./manager.js";
import { errorMessage } from "./manager.js";
import {
buildTunnelConfig,
connectionToRequest,
readTunnelConnections,
resolveEndpoint,
} from "./config.js";
export interface HostOwner {
id: number;
userId: string;
}
/** Every host and its owner, straight from ssh_data. */
export async function listHostOwners(ctx: PluginContext): Promise<HostOwner[]> {
/* eslint-disable @typescript-eslint/no-explicit-any */
const { hosts } = await ctx.db.refs<{ hosts: any }>();
const drizzle = await ctx.db.client<any>();
/* eslint-enable @typescript-eslint/no-explicit-any */
const rows = (await drizzle
.select({ id: hosts.id, userId: hosts.userId })
.from(hosts)) as HostOwner[];
return rows.filter((row) => Number.isInteger(row.id) && !!row.userId);
}
/**
* Starts every saved tunnel marked autoStart, across every user.
*
* ctx.hosts only sees the acting user's hosts, so the owners come from
* ssh_data first and each host is then resolved and connected as its owner.
*/
export async function startAutoStartTunnels(
ctx: PluginContext,
manager: TunnelManager,
listOwners: () => Promise<HostOwner[]> = () => listHostOwners(ctx),
): Promise<number> {
let started = 0;
let owners: HostOwner[];
try {
owners = await listOwners();
} catch (error) {
ctx.log.warn(
`Could not list hosts for tunnel autostart: ${errorMessage(error)}`,
);
return 0;
}
for (const owner of owners) {
try {
if (!(await ctx.settings.getHost<boolean>(owner.id, "enableTunnel"))) {
continue;
}
const connections = readTunnelConnections(
await ctx.settings.getHost(owner.id, "tunnelConnections"),
);
if (!connections.some((connection) => connection.autoStart)) continue;
started += await ctx.asUser(owner.userId, async () => {
const host = await ctx.hosts.get(owner.id);
if (!host) return 0;
let count = 0;
for (const [index, connection] of connections.entries()) {
if (!connection.autoStart) continue;
try {
const config = await resolveEndpoint(
ctx,
buildTunnelConfig(
host,
connectionToRequest(host, index, connection),
owner.userId,
),
);
await manager.start(config);
count++;
} catch (error) {
ctx.log.warn(
`Autostart tunnel ${index + 1} on host ${owner.id} skipped: ${errorMessage(error)}`,
);
}
}
return count;
});
} catch (error) {
ctx.log.warn(
`Autostart failed for host ${owner.id}: ${errorMessage(error)}`,
);
}
}
if (started > 0) ctx.log.info(`Started ${started} autostart tunnel(s)`);
return started;
}
+450
View File
@@ -0,0 +1,450 @@
import type { Duplex } from "node:stream";
import type { Client, ClientChannel } from "ssh2";
import type { WebSocket } from "ws";
import type {
PluginContext,
PluginSshConnection,
} from "@termix/plugin-sdk/backend";
import type { TunnelMode } from "./types.js";
import {
bindForwardIn,
forwardOut,
unbindForwardIn,
} from "./ssh-primitives.js";
import { getTunnelMode } from "./utils.js";
/** What the desktop app sends in the first message on the relay socket. */
export interface C2SRelayTunnel {
name?: string;
mode?: TunnelMode;
tunnelType?: "local" | "remote";
localAddress?: string;
remoteAddress?: string;
bindHost?: string;
targetHost?: string;
sourceHostId?: number;
sourceHostSyncId?: string;
sourcePort?: number;
endpointPort?: number;
}
export interface C2SOpenMessage {
type: "open" | "test";
tunnelConfig?: C2SRelayTunnel;
targetHost?: string;
targetPort?: number;
}
const WS_HIGH_WATERMARK = 1024 * 1024;
const WS_LOW_WATERMARK = 256 * 1024;
const STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
export function sendC2SError(ws: WebSocket, message: string): void {
if (ws.readyState === 1) {
ws.send(JSON.stringify({ type: "error", error: message }));
}
}
export function describeC2SRelayError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
const lower = message.toLowerCase();
if (
lower.includes("administratively prohibited") ||
lower.includes("forwarding disabled") ||
lower.includes("open failed")
) {
return `SSH forwarding was rejected by the endpoint server: ${message}`;
}
if (
lower.includes("address already in use") ||
lower.includes("unable to bind") ||
lower.includes("bind")
) {
return `Remote port is not available on the endpoint server: ${message}`;
}
if (
lower.includes("name or service not known") ||
lower.includes("enotfound") ||
lower.includes("econnrefused")
) {
return `Tunnel target is not reachable from the endpoint host: ${message}`;
}
return message || "Failed to open relay";
}
function pauseForBackpressure(ws: WebSocket, source?: Duplex): void {
if (!source || ws.bufferedAmount <= WS_HIGH_WATERMARK) return;
source.pause();
const resumeTimer = setInterval(() => {
if (
ws.readyState !== 1 ||
source.destroyed ||
ws.bufferedAmount <= WS_LOW_WATERMARK
) {
clearInterval(resumeTimer);
if (ws.readyState === 1 && !source.destroyed) source.resume();
}
}, 25);
}
function sendMessage(
ws: WebSocket,
message: Record<string, unknown>,
source?: Duplex,
): void {
if (ws.readyState !== 1) return;
ws.send(JSON.stringify(message), (error) => {
if (error && source && !source.destroyed) source.destroy(error);
});
pauseForBackpressure(ws, source);
}
function writeRemoteChunk(
target: ClientChannel,
chunk: Buffer,
ws: WebSocket,
closeTarget: () => void,
): void {
if (!target || target.destroyed) return;
if (target.writableLength > STREAM_WRITE_LIMIT) {
closeTarget();
return;
}
if (!target.write(chunk)) {
ws.pause();
target.once("drain", () => {
if (ws.readyState === 1) ws.resume();
});
}
}
/**
* The host a client tunnel runs through. A desktop app synced with this
* server knows the host by its sync id, since numeric ids differ between the
* two databases, and must never fall back to a numeric id that could point
* at a different machine here.
*/
export async function resolveC2SSourceHostId(
tunnel: Pick<C2SRelayTunnel, "sourceHostId" | "sourceHostSyncId">,
findHostIdBySyncId: (syncId: string) => Promise<number | null>,
): Promise<number> {
const syncId = tunnel.sourceHostSyncId?.trim();
if (syncId) {
const hostId = await findHostIdBySyncId(syncId);
if (!hostId) {
throw new Error("Endpoint SSH host was not found on the remote server");
}
return hostId;
}
if (!tunnel.sourceHostId) throw new Error("Endpoint SSH host is required");
return tunnel.sourceHostId;
}
function address(value: unknown, fallback: string): string {
const trimmed = typeof value === "string" ? value.trim() : "";
return trimmed || fallback;
}
interface ResolvedRelay {
name: string;
hostId: number;
mode: TunnelMode;
remoteAddress: string;
sourcePort: number;
endpointPort: number;
}
/**
* The client tunnel relay: the desktop app listens locally and streams each
* connection over this socket, and the server forwards it through one of the
* user's hosts. Runs as the socket's user for every host lookup and connect.
*/
export function createC2SRelay(ctx: PluginContext) {
let streamCounter = 0;
async function findHostIdBySyncId(syncId: string): Promise<number | null> {
/* eslint-disable @typescript-eslint/no-explicit-any */
const { hosts } = await ctx.db.refs<{ hosts: any }>();
const drizzle = await ctx.db.client<any>();
/* eslint-enable @typescript-eslint/no-explicit-any */
const { eq } = await import("drizzle-orm");
const rows = await drizzle
.select({ id: hosts.id })
.from(hosts)
.where(eq(hosts.syncId, syncId))
.limit(1);
return rows[0]?.id ?? null;
}
async function resolve(tunnel: C2SRelayTunnel): Promise<ResolvedRelay> {
const hostId = await resolveC2SSourceHostId(tunnel, findHostIdBySyncId);
const access = await ctx.hosts.checkAccess(hostId, "connect");
if (!access.hasAccess) throw new Error("Access denied to this host");
return {
name: tunnel.name || `c2s:${hostId}`,
hostId,
mode: getTunnelMode({
mode: tunnel.mode,
tunnelType: tunnel.tunnelType,
}),
remoteAddress: address(
tunnel.remoteAddress,
address(tunnel.targetHost, "127.0.0.1"),
),
sourcePort: Number(tunnel.sourcePort) || 0,
endpointPort: Number(tunnel.endpointPort) || 0,
};
}
function connectSource(hostId: number): Promise<PluginSshConnection<Client>> {
return ctx.ssh.connect<Client>(hostId, {
purpose: "tunnel",
timeoutMs: 60_000,
});
}
async function openRemote(ws: WebSocket, relay: ResolvedRelay) {
const bindHost = relay.remoteAddress;
const bindPort = relay.sourcePort;
if (!Number.isInteger(bindPort) || bindPort < 1 || bindPort > 65535) {
throw new Error("Invalid remote port");
}
const source = await connectSource(relay.hostId);
const sourceClient = source.client;
let actualPort: number;
try {
actualPort = await bindForwardIn(sourceClient, bindHost, bindPort);
} catch (error) {
source.dispose();
throw error;
}
const streams = new Map<string, ClientChannel>();
let closed = false;
const closeStream = (streamId: string) => {
const stream = streams.get(streamId);
if (!stream) return;
streams.delete(streamId);
try {
stream.destroy();
} catch {
// Already gone.
}
};
const close = () => {
if (closed) return;
closed = true;
for (const streamId of [...streams.keys()]) closeStream(streamId);
unbindForwardIn(sourceClient, bindHost, actualPort, ctx.log);
source.dispose();
};
sourceClient.on("tcp connection", (info, accept, reject) => {
if (info.destPort !== actualPort) {
reject();
return;
}
const inbound = accept();
const streamId = `${Date.now()}-${++streamCounter}`;
streams.set(streamId, inbound);
sendMessage(ws, { type: "connection", streamId });
inbound.on("data", (chunk: Buffer) => {
sendMessage(
ws,
{ type: "data", streamId, data: chunk.toString("base64") },
inbound,
);
});
inbound.on("close", () => {
streams.delete(streamId);
sendMessage(ws, { type: "close", streamId });
});
inbound.on("error", (error: Error) => {
streams.delete(streamId);
sendMessage(ws, { type: "close", streamId, error: error.message });
});
});
ws.on("message", (data, isBinary) => {
if (isBinary) return;
try {
const message = JSON.parse(data.toString()) as {
type?: string;
streamId?: string;
data?: string;
};
if (!message.streamId) return;
const streamId = message.streamId;
if (message.type === "data" && message.data) {
const stream = streams.get(streamId);
if (stream) {
writeRemoteChunk(
stream,
Buffer.from(message.data, "base64"),
ws,
() => closeStream(streamId),
);
}
} else if (message.type === "close") {
closeStream(streamId);
}
} catch (error) {
ctx.log.warn(
`Invalid client tunnel relay message for ${relay.name}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
});
ws.on("close", close);
ws.on("error", close);
sourceClient.on("close", () => {
if (ws.readyState === 1) ws.close();
});
sourceClient.on("error", (error: Error) => {
sendMessage(ws, { type: "error", error: error.message });
if (ws.readyState === 1) ws.close();
});
ctx.log.info(
`Client tunnel ${relay.name} bound ${bindHost}:${actualPort} on host ${relay.hostId}`,
);
sendMessage(ws, { type: "ready", bindHost, bindPort: actualPort });
}
async function open(ws: WebSocket, message: C2SOpenMessage): Promise<void> {
const relay = await resolve(message.tunnelConfig ?? {});
if (relay.mode === "remote") {
await openRemote(ws, relay);
return;
}
const targetHost =
relay.mode === "dynamic" ? message.targetHost : relay.remoteAddress;
const targetPort =
relay.mode === "dynamic"
? Number(message.targetPort)
: relay.endpointPort;
if (!targetHost || !Number.isInteger(targetPort) || targetPort < 1) {
throw new Error("Invalid client tunnel target");
}
const source = await connectSource(relay.hostId);
let outbound: ClientChannel;
try {
outbound = await forwardOut(source.client, targetHost, targetPort);
} catch (error) {
source.dispose();
throw error;
}
const close = () => {
try {
outbound.destroy();
} catch {
// Already gone.
}
source.dispose();
};
outbound.on("data", (chunk: Buffer) => {
if (ws.readyState === 1) ws.send(chunk);
});
outbound.on("close", () => {
if (ws.readyState === 1) ws.close();
});
outbound.on("error", () => {
if (ws.readyState === 1) ws.close();
});
ws.on("close", close);
ws.on("error", close);
ws.on("message", (data, isBinary) => {
if (!isBinary) return;
const chunk = Buffer.isBuffer(data)
? data
: Array.isArray(data)
? Buffer.concat(data)
: Buffer.from(data as ArrayBuffer);
outbound.write(chunk);
});
ws.send(JSON.stringify({ type: "ready" }));
}
async function test(ws: WebSocket, message: C2SOpenMessage): Promise<void> {
const relay = await resolve(message.tunnelConfig ?? {});
const source = await connectSource(relay.hostId);
try {
if (relay.mode === "remote") {
const bindPort = relay.sourcePort;
if (!Number.isInteger(bindPort) || bindPort < 1 || bindPort > 65535) {
throw new Error("Invalid remote port");
}
const actualPort = await bindForwardIn(
source.client,
relay.remoteAddress,
bindPort,
);
unbindForwardIn(source.client, relay.remoteAddress, actualPort);
} else if (relay.mode === "local") {
if (!Number.isInteger(relay.endpointPort) || relay.endpointPort < 1) {
throw new Error("Invalid remote target port");
}
const outbound = await forwardOut(
source.client,
relay.remoteAddress,
relay.endpointPort,
);
outbound.destroy();
}
sendMessage(ws, { type: "ready" });
} finally {
source.dispose();
}
}
/** The ctx.ws.route handler. Core has already authenticated the socket. */
return function handleConnection(ws: WebSocket, userId: string): void {
let opened = false;
ws.on("error", (error: Error) => {
ctx.log.warn(`Client tunnel relay socket error: ${error.message}`);
});
ws.once("message", (raw) => {
void ctx
.asUser(userId, async () => {
if (!(await ctx.rbac.has("use"))) {
throw new Error("Access denied");
}
const message = JSON.parse(raw.toString()) as C2SOpenMessage;
if (message.type !== "open" && message.type !== "test") {
throw new Error("Invalid client tunnel relay request");
}
opened = true;
if (message.type === "test") await test(ws, message);
else await open(ws, message);
})
.catch((error: unknown) => {
const message = describeC2SRelayError(error);
ctx.log.warn(`Failed to open client tunnel relay: ${message}`);
sendC2SError(ws, message);
ws.close();
});
});
ws.on("close", () => {
if (!opened) ctx.log.debug("Client tunnel relay closed before opening");
});
};
}
+172
View File
@@ -0,0 +1,172 @@
import type {
PluginContext,
PluginHostSummary,
} from "@termix/plugin-sdk/backend";
import type {
TunnelConfig,
TunnelConnectRequest,
TunnelConnection,
} from "./types.js";
import { serverTunnelName } from "../shared/tunnel-naming.js";
import {
findHostByTunnelEndpoint,
getTunnelMode,
isSingleHostTunnel,
parseTunnelName,
} from "./utils.js";
export function hostLabel(host: {
name?: string | null;
username: string;
ip: string;
}): string {
return host.name || `${host.username}@${host.ip}`;
}
/** The name a host's saved tunnel runs under. */
export function savedTunnelName(
host: PluginHostSummary,
index: number,
connection: Pick<
TunnelConnection,
"sourcePort" | "endpointHost" | "endpointPort"
>,
): string {
return serverTunnelName(host, index, connection);
}
/**
* Builds the runtime config for a tunnel from what the client or a host's
* saved list says about it. Credentials are never part of it: both legs
* resolve theirs through ctx.ssh as `userId`.
*/
export function buildTunnelConfig(
host: PluginHostSummary,
request: Omit<TunnelConnectRequest, "sourceHostId">,
userId: string,
): TunnelConfig {
const mode = getTunnelMode(request);
return {
name: request.name,
scope: request.scope || "s2s",
mode,
tunnelType: request.tunnelType || (mode === "remote" ? "remote" : "local"),
bindHost: request.bindHost,
targetHost: request.targetHost,
sourceHostId: host.id,
tunnelIndex: request.tunnelIndex,
requestingUserId: userId,
hostName: hostLabel(host),
sourceIP: host.ip,
sourceSSHPort: host.port,
sourceUsername: host.username,
endpointHost: (request.endpointHost ?? "").trim(),
sourcePort: Number(request.sourcePort),
endpointPort: Number(request.endpointPort),
maxRetries: Number(request.maxRetries) || 3,
retryInterval: (Number(request.retryInterval) || 5) * 1000,
autoStart: Boolean(request.autoStart),
};
}
export function connectionToRequest(
host: PluginHostSummary,
index: number,
connection: TunnelConnection,
): Omit<TunnelConnectRequest, "sourceHostId"> {
return {
name: savedTunnelName(host, index, connection),
tunnelIndex: index,
scope: connection.scope || "s2s",
mode: connection.mode,
tunnelType: connection.tunnelType,
bindHost: connection.bindHost,
targetHost: connection.targetHost,
endpointHost: connection.endpointHost,
sourcePort: connection.sourcePort,
endpointPort: connection.endpointPort,
maxRetries: connection.maxRetries,
retryInterval: connection.retryInterval,
autoStart: connection.autoStart,
};
}
/**
* Fills in the endpoint leg from the user's own host list. A local or
* dynamic tunnel whose endpoint is not a saved host just forwards to that
* address through the source; a remote tunnel needs a real SSH host there.
* Must run as the tunnel's user.
*/
export async function resolveEndpoint(
ctx: PluginContext,
config: TunnelConfig,
): Promise<TunnelConfig> {
if (isSingleHostTunnel(config)) return config;
const hosts = await ctx.hosts.list();
const endpoint = findHostByTunnelEndpoint(hosts, config.endpointHost);
if (!endpoint) {
if (getTunnelMode(config) === "remote") {
throw new Error(
`Endpoint host '${config.endpointHost}' not found in database`,
);
}
return { ...config, endpointIP: config.endpointIP || config.endpointHost };
}
const access = await ctx.hosts.checkAccess(endpoint.id, "connect");
if (!access.hasAccess) throw new Error("Endpoint host not found");
return {
...config,
endpointHostId: endpoint.id,
endpointIP: endpoint.ip,
endpointSSHPort: endpoint.port,
endpointUsername: endpoint.username,
};
}
/** A host's saved tunnel list, as its host setting holds it. */
export function readTunnelConnections(value: unknown): TunnelConnection[] {
let parsed = value;
if (typeof parsed === "string") {
try {
parsed = JSON.parse(parsed);
} catch {
return [];
}
}
return Array.isArray(parsed) ? (parsed as TunnelConnection[]) : [];
}
/**
* The saved tunnel a name points at, for starting one by name alone
* (automations). Must run as the user it is for.
*/
export async function findSavedTunnel(
ctx: PluginContext,
name: string,
): Promise<{
host: PluginHostSummary;
index: number;
connection: TunnelConnection;
} | null> {
const parsed = parseTunnelName(name);
if (parsed.isLegacyFormat || parsed.hostId === undefined) return null;
const index = parsed.tunnelIndex ?? -1;
const host = await ctx.hosts.get(parsed.hostId);
if (!host) return null;
if (!(await ctx.settings.getHost<boolean>(host.id, "enableTunnel"))) {
return null;
}
const connections = readTunnelConnections(
await ctx.settings.getHost(host.id, "tunnelConnections"),
);
const connection = connections[index];
if (!connection) return null;
if (savedTunnelName(host, index, connection) !== name) return null;
return { host, index, connection };
}
+50
View File
@@ -0,0 +1,50 @@
import type { Router } from "express";
import type { WebSocket } from "ws";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { presets } from "./tables.js";
import { createPresetRepository } from "./repository.js";
import { createTunnelManager } from "./manager.js";
import { registerTunnelRoutes } from "./routes.js";
import { createC2SRelay } from "./c2s-relay.js";
import { createTunnelsService } from "./service.js";
import { startAutoStartTunnels } from "./autostart.js";
export type {
TunnelsAccess,
TunnelForwardHandle,
TunnelForwardTarget,
} from "./service.js";
// Long enough for core's host settings migration, which runs right after
// plugins activate, to have copied the saved tunnels across on an upgrade.
const AUTOSTART_DELAY_MS = 3000;
export async function activate(ctx: PluginContext) {
const table = await ctx.db.define(presets);
const repository = createPresetRepository(ctx.db, table);
const manager = createTunnelManager(ctx);
ctx.disposables.add(() => manager.dispose());
registerTunnelRoutes(ctx.http.router<Router>(), ctx, manager, repository);
const relay = createC2SRelay(ctx);
ctx.ws.route("/c2s/stream", (connection) =>
relay(connection.socket as WebSocket, connection.userId),
);
ctx.services.provide("tunnels.access", createTunnelsService(ctx, manager));
// Deferred so activation is not held up by SSH handshakes.
const autostart = setTimeout(() => {
void startAutoStartTunnels(ctx, manager);
}, AUTOSTART_DELAY_MS);
autostart.unref?.();
ctx.disposables.add(() => clearTimeout(autostart));
ctx.log.info("Tunnel routes mounted at /plugin-api/tunnels");
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
+838
View File
@@ -0,0 +1,838 @@
import { createServer, Socket, type Server } from "node:net";
import type { Client, ClientChannel } from "ssh2";
import type {
PluginContext,
PluginSshConnection,
} from "@termix/plugin-sdk/backend";
import {
CONNECTION_STATES,
type TunnelConfig,
type TunnelStatus,
} from "./types.js";
import {
bindForwardIn,
forwardOut,
pipeTunnelStreams,
unbindForwardIn,
} from "./ssh-primitives.js";
import { handleSocks5Connect } from "./socks5-relay.js";
import {
classifyTunnelError,
getTunnelBindHost,
getTunnelMode,
getTunnelScope,
isReservedTunnelName,
resolveS2SLocalTargetHost,
shouldEstablishDirectTunnel,
} from "./utils.js";
export interface TunnelRuntime {
sourceClient: Client;
endpointClient?: Client;
bindHost: string;
bindPort: number;
close: () => void;
}
/** A status snapshot as a user sees it, keyed by tunnel name. */
export type TunnelStatusMap = Record<string, TunnelStatus>;
const CONNECT_TIMEOUT_MS = 60_000;
const MANUAL_DISCONNECT_HOLD_MS = 5_000;
const ACCESS_CACHE_MS = 60_000;
export function errorMessage(
error: unknown,
fallback = "Unknown error",
): string {
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error) return error;
return fallback;
}
export type TunnelManager = ReturnType<typeof createTunnelManager>;
/**
* Every running tunnel and its retry state, created fresh in activate so a
* disable-then-enable starts clean.
*
* Both SSH legs go through ctx.ssh.connect, so tunnels get host key checks,
* auth providers, proxies and jump hosts the same way every other transport
* does. The endpoint leg of a source-to-endpoint tunnel is opened over a
* forwardOut channel on the source connection, handed to ctx.ssh as `sock`.
*/
export function createTunnelManager(ctx: PluginContext) {
const log = ctx.log;
const configs = new Map<string, TunnelConfig>();
const statuses = new Map<string, TunnelStatus>();
const runtimes = new Map<string, TunnelRuntime>();
const retryCounters = new Map<string, number>();
const retryTimers = new Map<string, NodeJS.Timeout>();
const countdownTimers = new Map<string, NodeJS.Timeout>();
const manualDisconnects = new Set<string>();
const manualHoldTimers = new Set<NodeJS.Timeout>();
const retryExhausted = new Set<string>();
const cleanupInProgress = new Set<string>();
const connecting = new Set<string>();
const lastErrors = new Map<string, string>();
const lastErrorTypes = new Map<string, TunnelStatus["errorType"]>();
const pendingOperations = new Map<string, Promise<void>>();
const listeners = new Set<() => void>();
const accessCache = new Map<string, { allowed: boolean; at: number }>();
let disposed = false;
function notify(): void {
for (const listener of listeners) {
try {
listener();
} catch (error) {
log.warn(`Tunnel status listener failed: ${errorMessage(error)}`);
}
}
}
function broadcast(name: string, status: TunnelStatus): void {
if (
status.status === CONNECTION_STATES.CONNECTED &&
retryTimers.has(name)
) {
return;
}
const next = { ...status };
if (retryExhausted.has(name) && next.status === CONNECTION_STATES.FAILED) {
const previous = lastErrors.get(name);
next.reason = previous
? `Max retries exhausted: ${previous}`
: "Max retries exhausted";
}
if (next.status === CONNECTION_STATES.FAILED && next.reason) {
lastErrors.set(name, next.reason);
if (next.errorType) lastErrorTypes.set(name, next.errorType);
} else if (
(next.status === CONNECTION_STATES.CONNECTING ||
next.status === CONNECTION_STATES.RETRYING ||
next.status === CONNECTION_STATES.WAITING) &&
!next.reason
) {
next.reason = lastErrors.get(name);
next.errorType = lastErrorTypes.get(name);
} else if (
next.status === CONNECTION_STATES.CONNECTED ||
(next.status === CONNECTION_STATES.DISCONNECTED && next.manualDisconnect)
) {
lastErrors.delete(name);
lastErrorTypes.delete(name);
}
statuses.set(name, next);
ctx.events.emit("plugin.tunnels.status", {
name,
sourceHostId: configs.get(name)?.sourceHostId,
status: next,
});
notify();
}
function clearRetryTimers(name: string): void {
const retry = retryTimers.get(name);
if (retry) {
clearTimeout(retry);
retryTimers.delete(name);
}
const countdown = countdownTimers.get(name);
if (countdown) {
clearInterval(countdown);
countdownTimers.delete(name);
}
}
function resetRetryState(name: string): void {
retryCounters.delete(name);
retryExhausted.delete(name);
lastErrors.delete(name);
lastErrorTypes.delete(name);
cleanupInProgress.delete(name);
connecting.delete(name);
clearRetryTimers(name);
}
async function cleanup(name: string, force = false): Promise<void> {
if (cleanupInProgress.has(name)) return;
if (!force && connecting.has(name)) return;
cleanupInProgress.add(name);
const runtime = runtimes.get(name);
if (runtime) {
runtimes.delete(name);
try {
runtime.close();
} catch (error) {
log.error(
`Error while closing tunnel ${name}`,
error instanceof Error ? error : undefined,
);
}
}
cleanupInProgress.delete(name);
clearRetryTimers(name);
}
/** One SSH leg, as the tunnel's owner, through core's connect pipeline. */
async function openLeg(
userId: string,
hostId: number,
sock?: ClientChannel,
): Promise<PluginSshConnection<Client>> {
return ctx.asUser(userId, () =>
ctx.ssh.connect<Client>(hostId, {
purpose: "tunnel",
timeoutMs: CONNECT_TIMEOUT_MS,
sock,
}),
);
}
function directTargetHost(config: TunnelConfig): string {
const target = config.targetHost?.trim();
if (target) return target;
if (
config.endpointHostId !== undefined &&
config.endpointHostId === config.sourceHostId
) {
return "127.0.0.1";
}
return config.endpointHost || "127.0.0.1";
}
async function establishDirect(
source: PluginSshConnection<Client>,
config: TunnelConfig,
): Promise<TunnelRuntime> {
const sourceClient = source.client;
const name = config.name;
const mode = getTunnelMode(config);
const bindHost = getTunnelBindHost(config);
const targetHost = directTargetHost(config);
const targetPort = config.endpointPort;
if (mode === "remote") {
const remoteBindPort = await bindForwardIn(
sourceClient,
targetHost,
config.sourcePort,
);
const sockets = new Set<Socket>();
const onTcp = (
info: { destPort: number },
accept: () => ClientChannel,
reject: () => void,
) => {
if (info.destPort !== remoteBindPort) {
reject();
return;
}
const inbound = accept();
const local = new Socket();
sockets.add(local);
local.connect(targetPort, bindHost, () => {
pipeTunnelStreams(inbound, Promise.resolve(local), name, log);
});
local.on("error", () => {
inbound.destroy();
sockets.delete(local);
});
local.on("close", () => sockets.delete(local));
};
sourceClient.on("tcp connection", onTcp);
return {
sourceClient,
bindHost: targetHost,
bindPort: remoteBindPort,
close: () => {
sourceClient.off("tcp connection", onTcp);
unbindForwardIn(sourceClient, targetHost, remoteBindPort, log);
for (const socket of sockets) socket.destroy();
sockets.clear();
source.dispose();
},
};
}
// Local and dynamic: listen here, forward through the source.
const sockets = new Set<Socket>();
const server: Server = createServer((socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
socket.on("error", () => {
sockets.delete(socket);
socket.destroy();
});
if (mode === "dynamic") {
handleSocks5Connect(
socket,
(host, port) => forwardOut(sourceClient, host, port),
name,
log,
);
return;
}
forwardOut(sourceClient, targetHost, targetPort)
.then((outbound) =>
pipeTunnelStreams(socket, Promise.resolve(outbound), name, log),
)
.catch(() => socket.destroy());
});
const boundPort = await new Promise<number>((resolve, reject) => {
server.once("error", reject);
server.listen({ host: bindHost, port: config.sourcePort }, () => {
server.removeListener("error", reject);
// With port 0 the kernel picks, so report what was actually bound.
resolve((server.address() as { port: number }).port);
});
});
let idleTimer: NodeJS.Timeout | undefined;
const close = () => {
if (idleTimer) clearInterval(idleTimer);
for (const socket of sockets) socket.destroy();
sockets.clear();
server.close();
source.dispose();
};
// On-demand tunnels close themselves once nothing has used them for a
// while. The socket set only exists in here, so the timer does too.
if (config.idleTimeoutMs && config.idleTimeoutMs > 0) {
const idleTimeoutMs = config.idleTimeoutMs;
let idleSince: number | null = Date.now();
idleTimer = setInterval(
() => {
if (sockets.size > 0) {
idleSince = null;
return;
}
if (idleSince === null) {
idleSince = Date.now();
return;
}
if (Date.now() - idleSince < idleTimeoutMs) return;
// Acts by name, so skip it if a reopen already replaced this one.
if (runtimes.get(name)?.sourceClient !== sourceClient) return;
log.info(`Closing idle tunnel ${name}`);
void cleanup(name, true);
},
Math.min(30_000, idleTimeoutMs),
);
idleTimer.unref();
}
log.info(
`Tunnel ${name} listening on ${bindHost}:${boundPort} (${mode}) to ${targetHost}:${targetPort}`,
);
return { sourceClient, bindHost, bindPort: boundPort, close };
}
async function establishManaged(
source: PluginSshConnection<Client>,
config: TunnelConfig,
): Promise<TunnelRuntime> {
const sourceClient = source.client;
const name = config.name;
if (config.endpointHostId === undefined || !config.endpointIP) {
throw new Error("Endpoint host not found");
}
const channel = await forwardOut(
sourceClient,
config.endpointIP,
config.endpointSSHPort ?? 22,
);
let endpoint: PluginSshConnection<Client>;
try {
endpoint = await openLeg(
config.requestingUserId,
config.endpointHostId,
channel,
);
} catch (error) {
channel.destroy();
throw error;
}
const endpointClient = endpoint.client;
const mode = getTunnelMode(config);
const bindHost = getTunnelBindHost(config);
const bindClient = mode === "remote" ? endpointClient : sourceClient;
const outboundClient = mode === "remote" ? sourceClient : endpointClient;
const bindPort =
mode === "remote" ? config.endpointPort : config.sourcePort;
const targetHost =
mode === "remote"
? config.targetHost || "127.0.0.1"
: resolveS2SLocalTargetHost(config);
const targetPort =
mode === "remote" ? config.sourcePort : config.endpointPort;
let actualPort: number;
try {
actualPort = await bindForwardIn(bindClient, bindHost, bindPort);
} catch (error) {
endpoint.dispose();
throw error;
}
const onTcp = (
info: { destPort: number },
accept: () => ClientChannel,
reject: () => void,
) => {
if (info.destPort !== actualPort) {
reject();
return;
}
const inbound = accept();
if (mode === "dynamic") {
handleSocks5Connect(
inbound,
(host, port) => forwardOut(outboundClient, host, port),
name,
log,
);
return;
}
pipeTunnelStreams(
inbound,
forwardOut(outboundClient, targetHost, targetPort),
name,
log,
);
};
bindClient.on("tcp connection", onTcp);
// Losing the endpoint leg makes the tunnel useless, so let the source
// close too and the usual disconnect handling take it from there.
endpointClient.once("close", () => {
if (runtimes.get(name)?.endpointClient === endpointClient) {
source.dispose();
}
});
log.info(
`Tunnel ${name} bound ${bindHost}:${actualPort} (${mode}) to ${targetHost}:${targetPort}`,
);
return {
sourceClient,
endpointClient,
bindHost,
bindPort: actualPort,
close: () => {
bindClient.off("tcp connection", onTcp);
unbindForwardIn(bindClient, bindHost, actualPort, log);
endpoint.dispose();
source.dispose();
},
};
}
async function establish(config: TunnelConfig): Promise<TunnelRuntime> {
const source = await openLeg(config.requestingUserId, config.sourceHostId);
try {
return shouldEstablishDirectTunnel(config)
? await establishDirect(source, config)
: await establishManaged(source, config);
} catch (error) {
source.dispose();
throw error;
}
}
/** Reacts to the source connection dropping after the tunnel was up. */
function watch(runtime: TunnelRuntime, config: TunnelConfig): void {
const name = config.name;
const client = runtime.sourceClient;
client.on("error", (error: Error) => {
log.warn(`Tunnel ${name} connection error: ${error.message}`);
});
client.once("close", () => {
if (runtimes.get(name)?.sourceClient !== client) return;
if (retryTimers.has(name)) return;
if (!manualDisconnects.has(name)) {
const current = statuses.get(name);
if (current?.status !== CONNECTION_STATES.FAILED) {
broadcast(name, {
connected: false,
status: CONNECTION_STATES.DISCONNECTED,
});
}
}
void handleDisconnect(name, config, !manualDisconnects.has(name), client);
});
}
async function handleDisconnect(
name: string,
config: TunnelConfig | null,
shouldRetry = true,
closingClient?: Client,
): Promise<void> {
if (isReservedTunnelName(name)) {
// On-demand tunnels are reopened by whoever needs them, never retried
// here. A deferred close for a client that a reopen already replaced
// must not tear down the replacement.
const current = runtimes.get(name);
if (closingClient && current && current.sourceClient !== closingClient) {
return;
}
await cleanup(name, true);
return;
}
while (cleanupInProgress.has(name)) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
await cleanup(name);
if (manualDisconnects.has(name)) {
resetRetryState(name);
broadcast(name, {
connected: false,
status: CONNECTION_STATES.DISCONNECTED,
manualDisconnect: true,
});
return;
}
// Past the manual branch, so this is a drop nobody asked for.
if (config?.requestingUserId) {
ctx.events.emit("plugin.tunnels.tunnel_disconnected", {
userId: config.requestingUserId,
hostId: config.sourceHostId,
tunnelName: name,
});
}
if (retryExhausted.has(name)) {
broadcast(name, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: "Max retries already exhausted",
});
return;
}
if (retryTimers.has(name) || disposed) return;
if (!shouldRetry || !config) {
// Keep the reason the failure was just reported with.
broadcast(name, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: lastErrors.get(name),
errorType: lastErrorTypes.get(name),
});
return;
}
const maxRetries = config.maxRetries || 3;
const retryInterval = config.retryInterval || 5000;
const retryCount = (retryCounters.get(name) || 0) + 1;
if (retryCount > maxRetries) {
log.error(`All ${maxRetries} retries failed for ${name}`);
retryExhausted.add(name);
retryCounters.delete(name);
broadcast(name, {
connected: false,
status: CONNECTION_STATES.FAILED,
retryExhausted: true,
reason: "Max retries exhausted",
});
return;
}
retryCounters.set(name, retryCount);
broadcast(name, {
connected: false,
status: CONNECTION_STATES.RETRYING,
retryCount,
maxRetries,
nextRetryIn: retryInterval / 1000,
});
let nextRetryIn = Math.ceil(retryInterval / 1000);
broadcast(name, {
connected: false,
status: CONNECTION_STATES.WAITING,
retryCount,
maxRetries,
nextRetryIn,
});
countdownTimers.set(
name,
setInterval(() => {
nextRetryIn--;
if (nextRetryIn > 0) {
broadcast(name, {
connected: false,
status: CONNECTION_STATES.WAITING,
retryCount,
maxRetries,
nextRetryIn,
});
}
}, 1000),
);
retryTimers.set(
name,
setTimeout(() => {
clearRetryTimers(name);
if (!manualDisconnects.has(name)) {
void connect(config, retryCount);
}
}, retryInterval),
);
}
/**
* Opens a tunnel. Failures are reported over the status broadcast and
* retried there, so by default this never rejects. `throwOnError` is for a
* caller that waits on the result (forward()), which gets the runtime back.
*/
async function connect(
config: TunnelConfig,
retryAttempt = 0,
options: { throwOnError?: boolean } = {},
): Promise<TunnelRuntime | null> {
const name = config.name;
if (manualDisconnects.has(name) || disposed) return null;
connecting.add(name);
await cleanup(name, true);
if (retryAttempt === 0) {
retryExhausted.delete(name);
retryCounters.delete(name);
}
const current = statuses.get(name);
if (!current || current.status !== CONNECTION_STATES.WAITING) {
broadcast(name, {
connected: false,
status: CONNECTION_STATES.CONNECTING,
retryCount: retryAttempt > 0 ? retryAttempt : undefined,
});
}
try {
if (getTunnelScope(config) !== "s2s") {
throw new Error(
"Client tunnels must be started from the desktop app's local configuration",
);
}
const runtime = await establish(config);
connecting.delete(name);
if (manualDisconnects.has(name) || disposed) {
runtime.close();
return null;
}
runtimes.set(name, runtime);
watch(runtime, config);
broadcast(name, { connected: true, status: CONNECTION_STATES.CONNECTED });
return runtime;
} catch (error) {
connecting.delete(name);
const message = errorMessage(error, "Failed to create tunnel");
const errorType = classifyTunnelError(message);
log.error(
`Tunnel ${name} failed to connect: ${message}`,
error instanceof Error ? error : undefined,
);
if (!manualDisconnects.has(name)) {
broadcast(name, {
connected: false,
status: CONNECTION_STATES.FAILED,
errorType,
reason: message,
});
}
const shouldNotRetry =
errorType === "AUTHENTICATION_FAILED" ||
errorType === "CONNECTION_FAILED" ||
manualDisconnects.has(name) ||
options.throwOnError === true;
await handleDisconnect(name, config, !shouldNotRetry);
if (options.throwOnError) throw error;
return null;
}
}
/** Stops a tunnel on request. Holds off retries for a few seconds. */
async function stop(name: string): Promise<void> {
const config = configs.get(name) ?? null;
manualDisconnects.add(name);
retryCounters.delete(name);
retryExhausted.delete(name);
clearRetryTimers(name);
await cleanup(name, true);
broadcast(name, {
connected: false,
status: CONNECTION_STATES.DISCONNECTED,
manualDisconnect: true,
});
await handleDisconnect(name, config, false);
const hold = setTimeout(() => {
manualHoldTimers.delete(hold);
manualDisconnects.delete(name);
}, MANUAL_DISCONNECT_HOLD_MS);
manualHoldTimers.add(hold);
}
/**
* Starts a configured tunnel by name, waiting for any operation already in
* flight for it first. The start itself is not awaited: its outcome comes
* back over the status stream.
*/
async function start(config: TunnelConfig): Promise<void> {
const name = config.name;
const pending = pendingOperations.get(name);
if (pending) {
await pending.catch(() => undefined);
}
const existing = configs.get(name);
if (
existing &&
(existing.sourceHostId !== config.sourceHostId ||
existing.tunnelIndex !== config.tunnelIndex)
) {
throw new Error(`Tunnel name collision detected: ${name}`);
}
manualDisconnects.delete(name);
retryCounters.delete(name);
retryExhausted.delete(name);
configs.set(name, config);
const operation = connect(config, 0).then(() => undefined);
pendingOperations.set(name, operation);
void operation.finally(() => {
if (pendingOperations.get(name) === operation) {
pendingOperations.delete(name);
}
});
}
/** Whether `userId` can reach `hostId`, cached briefly so broadcasts stay cheap. */
async function canAccessHost(
userId: string,
hostId: number,
): Promise<boolean> {
const cacheKey = `${userId}:${hostId}`;
const cached = accessCache.get(cacheKey);
if (cached && Date.now() - cached.at < ACCESS_CACHE_MS) {
return cached.allowed;
}
let allowed = false;
try {
const access =
ctx.currentActor() === userId
? await ctx.hosts.checkAccess(hostId, "connect")
: await ctx.asUser(userId, () =>
ctx.hosts.checkAccess(hostId, "connect"),
);
allowed = access.hasAccess;
} catch {
allowed = false;
}
accessCache.set(cacheKey, { allowed, at: Date.now() });
return allowed;
}
/**
* Status of every tunnel whose source host the user can reach. Names carry
* host labels and endpoints and errors can name internal hosts, so nothing
* else is shown. On-demand tunnels are never listed.
*/
async function statusesFor(userId: string): Promise<TunnelStatusMap> {
const result: TunnelStatusMap = {};
for (const [name, status] of statuses) {
if (isReservedTunnelName(name)) continue;
const hostId = configs.get(name)?.sourceHostId;
if (hostId === undefined) continue;
if (await canAccessHost(userId, hostId)) result[name] = status;
}
return result;
}
async function canAccessTunnel(
userId: string,
name: string,
): Promise<boolean> {
const hostId = configs.get(name)?.sourceHostId;
if (hostId === undefined) return false;
return canAccessHost(userId, hostId);
}
function onChange(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function dispose(): void {
disposed = true;
for (const name of [...runtimes.keys()]) {
const runtime = runtimes.get(name);
runtimes.delete(name);
try {
runtime?.close();
} catch {
// Already gone.
}
}
for (const name of [...retryTimers.keys(), ...countdownTimers.keys()]) {
clearRetryTimers(name);
}
for (const hold of manualHoldTimers) clearTimeout(hold);
manualHoldTimers.clear();
listeners.clear();
}
return {
configs,
statuses,
runtimes,
manualDisconnects,
connecting,
broadcast,
cleanup,
connect,
start,
stop,
handleDisconnect,
statusesFor,
canAccessHost,
canAccessTunnel,
onChange,
dispose,
};
}
+133
View File
@@ -0,0 +1,133 @@
import { and, asc, eq, ne } from "drizzle-orm";
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
export interface PresetRecord {
id: number;
userId: string;
name: string;
config: string;
platform: string | null;
computerName: string | null;
createdAt: string;
updatedAt: string;
}
export interface PresetCreateInput {
name: string;
config: string;
platform?: string | null;
computerName?: string | null;
}
export type PresetUpdateInput = Partial<PresetCreateInput>;
/* eslint-disable @typescript-eslint/no-explicit-any */
// The table comes from ctx.db.define, which the SDK hands back untyped, and
// the drizzle handle is the server's own. Typed at this module's edge instead.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export type PresetRepository = ReturnType<typeof createPresetRepository>;
/**
* Per-user client tunnel presets.
*
* No RETURNING, so it runs the same on all three engines: a new row is read
* back as the newest row for that user and name, which the name check before
* every insert keeps unique.
*/
export function createPresetRepository(db: PluginDatabase, table: Table) {
const client = () => db.client<Drizzle>();
async function findByIdForUser(
userId: string,
id: number,
): Promise<PresetRecord | null> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(table)
.where(and(eq(table.id, id), eq(table.userId, userId)))
.limit(1);
return (rows[0] as PresetRecord) ?? null;
}
return {
findByIdForUser,
async listByUserId(userId: string): Promise<PresetRecord[]> {
const drizzle = await client();
return drizzle
.select()
.from(table)
.where(eq(table.userId, userId))
.orderBy(asc(table.name));
},
async hasNameForUser(
userId: string,
name: string,
excludingId?: number,
): Promise<boolean> {
const drizzle = await client();
const conditions = [eq(table.userId, userId), eq(table.name, name)];
if (excludingId !== undefined) conditions.push(ne(table.id, excludingId));
const rows = await drizzle
.select({ id: table.id })
.from(table)
.where(and(...conditions))
.limit(1);
return rows.length > 0;
},
async createForUser(
userId: string,
input: PresetCreateInput,
now = new Date().toISOString(),
): Promise<PresetRecord> {
const drizzle = await client();
await drizzle.insert(table).values({
userId,
name: input.name,
config: input.config,
platform: input.platform ?? null,
computerName: input.computerName ?? null,
createdAt: now,
updatedAt: now,
});
await db.persist();
const rows = await drizzle
.select()
.from(table)
.where(and(eq(table.userId, userId), eq(table.name, input.name)))
.limit(1);
return rows[0] as PresetRecord;
},
async updateForUser(
userId: string,
id: number,
updates: PresetUpdateInput,
now = new Date().toISOString(),
): Promise<PresetRecord | null> {
const drizzle = await client();
await drizzle
.update(table)
.set({ ...updates, updatedAt: now })
.where(and(eq(table.id, id), eq(table.userId, userId)));
await db.persist();
return findByIdForUser(userId, id);
},
async deleteForUser(userId: string, id: number): Promise<boolean> {
if (!(await findByIdForUser(userId, id))) return false;
const drizzle = await client();
await drizzle
.delete(table)
.where(and(eq(table.id, id), eq(table.userId, userId)));
await db.persist();
return true;
},
};
}
+588
View File
@@ -0,0 +1,588 @@
import type { Request, Response, Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import {
CONNECTION_STATES,
type TunnelConnectRequest,
type TunnelConnection,
} from "./types.js";
import type { TunnelManager } from "./manager.js";
import { errorMessage } from "./manager.js";
import type { PresetRecord, PresetRepository } from "./repository.js";
import {
RESERVED_TUNNEL_NAME_PREFIX,
isReservedTunnelName,
validateTunnelConfig,
} from "./utils.js";
import { authorizeTunnelAction } from "./authorize.js";
import { buildTunnelConfig, resolveEndpoint } from "./config.js";
function actor(ctx: PluginContext): string {
// Core's plugin router authenticates every request and runs it as that user.
return ctx.currentActor() as string;
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function parsePreset(row: PresetRecord) {
let config: TunnelConnection[] = [];
try {
config = JSON.parse(row.config) as TunnelConnection[];
} catch {
config = [];
}
return { ...row, config };
}
/** Every item must be a client tunnel with a known mode and valid ports. */
export function validatePresetConfig(
config: unknown,
): config is TunnelConnection[] {
if (!Array.isArray(config)) return false;
return config.every((item) => {
if (!item || typeof item !== "object") return false;
const tunnel = item as Partial<TunnelConnection>;
const mode = tunnel.mode || tunnel.tunnelType;
return (
tunnel.scope === "c2s" &&
(mode === "local" || mode === "remote" || mode === "dynamic") &&
typeof tunnel.sourcePort === "number" &&
tunnel.sourcePort >= 1 &&
tunnel.sourcePort <= 65535 &&
(mode === "dynamic" ||
(typeof tunnel.endpointPort === "number" &&
tunnel.endpointPort >= 1 &&
tunnel.endpointPort <= 65535))
);
});
}
function trimmedOrNull(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
/**
* Mounts the tunnel routes on the plugin's router, which core serves at
* /plugin-api/tunnels with auth in front. Every route needs tunnels.use.
*/
export function registerTunnelRoutes(
router: Router,
ctx: PluginContext,
manager: TunnelManager,
presets: PresetRepository,
): void {
router.use(ctx.rbac.require("use") as never);
const statusClients = new Map<Response, string>();
const sendSnapshot = (res: Response) => {
const userId = statusClients.get(res);
if (userId === undefined) return;
void manager
.statusesFor(userId)
.then((snapshot) => {
res.write(`event: statuses\ndata: ${JSON.stringify(snapshot)}\n\n`);
})
.catch(() => {
statusClients.delete(res);
});
};
const unsubscribe = manager.onChange(() => {
for (const res of statusClients.keys()) sendSnapshot(res);
});
ctx.disposables.add(() => {
unsubscribe();
for (const res of statusClients.keys()) {
try {
res.end();
} catch {
// Already closed.
}
}
statusClients.clear();
});
const canAccess = (userId: string) => (hostId: number) =>
manager.canAccessHost(userId, hostId);
/**
* @openapi
* /plugin-api/tunnels/status:
* get:
* summary: Get tunnel statuses
* description: Returns the status of every tunnel whose source host the caller can reach.
* tags:
* - Tunnels
* responses:
* 200:
* description: Tunnel statuses keyed by tunnel name.
* 403:
* description: Missing the tunnels.use permission.
*/
router.get("/status", async (_req: Request, res: Response) => {
res.json(await manager.statusesFor(actor(ctx)));
});
/**
* @openapi
* /plugin-api/tunnels/status/stream:
* get:
* summary: Stream tunnel statuses
* description: Server-sent events. Sends a "statuses" event with the full snapshot the caller can see on connect and after every change.
* tags:
* - Tunnels
* responses:
* 200:
* description: An event stream.
* 403:
* description: Missing the tunnels.use permission.
*/
router.get("/status/stream", (req: Request, res: Response) => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-store, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
res.flushHeaders?.();
statusClients.set(res, actor(ctx));
sendSnapshot(res);
const heartbeat = setInterval(() => {
try {
res.write(": keepalive\n\n");
} catch {
closeStream();
}
}, 30_000);
const closeStream = () => {
clearInterval(heartbeat);
statusClients.delete(res);
};
req.on("close", closeStream);
});
/**
* @openapi
* /plugin-api/tunnels/status/{tunnelName}:
* get:
* summary: Get one tunnel's status
* tags:
* - Tunnels
* parameters:
* - in: path
* name: tunnelName
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The tunnel's status.
* 404:
* description: No such tunnel, or the caller cannot see it.
*/
router.get("/status/:tunnelName", async (req: Request, res: Response) => {
const tunnelName = String(req.params.tunnelName);
// 404 rather than 403 for someone else's tunnel: the name itself carries
// host details, so confirming it exists would leak them.
if (!(await manager.canAccessTunnel(actor(ctx), tunnelName))) {
return res.status(404).json({ error: "Tunnel not found" });
}
const status = manager.statuses.get(tunnelName);
if (!status) return res.status(404).json({ error: "Tunnel not found" });
res.json({ name: tunnelName, status });
});
/**
* @openapi
* /plugin-api/tunnels/connect:
* post:
* summary: Connect a tunnel
* description: Starts a server tunnel from one of the caller's hosts. Credentials are resolved on the server. The result arrives over the status stream.
* tags:
* - Tunnels
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* sourceHostId:
* type: integer
* tunnelIndex:
* type: integer
* mode:
* type: string
* endpointHost:
* type: string
* sourcePort:
* type: integer
* endpointPort:
* type: integer
* responses:
* 200:
* description: Connection request received.
* 400:
* description: Invalid tunnel configuration.
* 403:
* description: Access denied to this host.
*/
router.post("/connect", async (req: Request, res: Response) => {
const userId = actor(ctx);
const body = (req.body ?? {}) as Partial<TunnelConnectRequest>;
const tunnelName = body.name;
if (!isNonEmptyString(tunnelName)) {
return res.status(400).json({ error: "Invalid tunnel configuration" });
}
// Reserved for on-demand forwards, which skip retries by name. A saved
// tunnel using it would silently lose its own reconnects.
if (isReservedTunnelName(tunnelName)) {
return res.status(400).json({
error: `Tunnel names beginning with "${RESERVED_TUNNEL_NAME_PREFIX}" are reserved`,
});
}
const sourceHostId = Number(body.sourceHostId);
const tunnelIndex = Number(body.tunnelIndex ?? 0);
if (!Number.isInteger(sourceHostId) || !Number.isInteger(tunnelIndex)) {
return res.status(400).json({ error: "Invalid tunnel configuration" });
}
const request: TunnelConnectRequest = {
...(body as TunnelConnectRequest),
endpointHost: (body.endpointHost ?? "").trim(),
name: tunnelName,
sourceHostId,
tunnelIndex,
};
if (
!validateTunnelConfig(tunnelName, {
sourceHostId,
tunnelIndex,
sourcePort: request.sourcePort,
endpointHost: request.endpointHost ?? "",
endpointPort: request.endpointPort,
})
) {
return res
.status(400)
.json({ error: "Tunnel configuration does not match tunnel name" });
}
const access = await ctx.hosts.checkAccess(sourceHostId, "connect");
const host = access.hasAccess ? await ctx.hosts.get(sourceHostId) : null;
if (!host) {
ctx.log.warn(
`User ${userId} tried to connect tunnel ${tunnelName} without access to host ${sourceHostId}`,
);
return res.status(403).json({ error: "Access denied to this host" });
}
try {
const config = await resolveEndpoint(
ctx,
buildTunnelConfig(host, request, userId),
);
await manager.start(config);
} catch (error) {
const reason = errorMessage(error);
ctx.log.warn(`Tunnel ${tunnelName} could not start: ${reason}`);
manager.broadcast(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason,
});
}
res.json({ message: "Connection request received", tunnelName });
});
const stopHandler =
(message: string) => async (req: Request, res: Response) => {
const userId = actor(ctx);
const tunnelName = (req.body ?? {}).tunnelName;
if (!isNonEmptyString(tunnelName)) {
return res.status(400).json({ error: "Tunnel name required" });
}
const decision = await authorizeTunnelAction(
canAccess(userId),
tunnelName,
manager.configs.get(tunnelName),
);
if (!decision.allowed) {
return res.status(403).json({ error: "Access denied" });
}
await manager.stop(tunnelName);
res.json({ message, tunnelName });
};
/**
* @openapi
* /plugin-api/tunnels/disconnect:
* post:
* summary: Disconnect a tunnel
* tags:
* - Tunnels
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* tunnelName:
* type: string
* responses:
* 200:
* description: Disconnect request received.
* 400:
* description: Tunnel name required.
* 403:
* description: Access denied.
*/
router.post("/disconnect", stopHandler("Disconnect request received"));
/**
* @openapi
* /plugin-api/tunnels/cancel:
* post:
* summary: Cancel a tunnel's connect or retry
* tags:
* - Tunnels
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* tunnelName:
* type: string
* responses:
* 200:
* description: Cancel request received.
* 400:
* description: Tunnel name required.
* 403:
* description: Access denied.
*/
router.post("/cancel", stopHandler("Cancel request received"));
/**
* @openapi
* /plugin-api/tunnels/presets:
* get:
* summary: List client tunnel presets
* description: Returns the caller's saved client-to-server tunnel presets.
* tags:
* - Tunnels
* responses:
* 200:
* description: List of tunnel presets.
* 403:
* description: Missing the tunnels.use permission.
*/
router.get("/presets", async (_req: Request, res: Response) => {
try {
const rows = await presets.listByUserId(actor(ctx));
res.json(rows.map(parsePreset));
} catch (error) {
ctx.log.error(
"Failed to fetch client tunnel presets",
error instanceof Error ? error : undefined,
);
res.status(500).json({ error: "Failed to fetch C2S tunnel presets" });
}
});
/**
* @openapi
* /plugin-api/tunnels/presets:
* post:
* summary: Create a client tunnel preset
* tags:
* - Tunnels
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* config:
* type: array
* items:
* type: object
* platform:
* type: string
* computerName:
* type: string
* responses:
* 201:
* description: Preset created.
* 400:
* description: Invalid name or config.
* 409:
* description: A preset with that name already exists.
*/
router.post("/presets", async (req: Request, res: Response) => {
const userId = actor(ctx);
const { name, config, platform, computerName } = req.body ?? {};
if (!isNonEmptyString(name)) {
return res.status(400).json({ error: "Preset name is required" });
}
if (!validatePresetConfig(config)) {
return res
.status(400)
.json({ error: "Invalid C2S tunnel configuration" });
}
const trimmedName = name.trim();
try {
if (await presets.hasNameForUser(userId, trimmedName)) {
return res.status(409).json({ error: "Preset name already exists" });
}
const created = await presets.createForUser(userId, {
name: trimmedName,
config: JSON.stringify(config),
platform: trimmedOrNull(platform),
computerName: trimmedOrNull(computerName),
});
res.status(201).json(parsePreset(created));
} catch (error) {
ctx.log.error(
"Failed to create client tunnel preset",
error instanceof Error ? error : undefined,
);
res.status(500).json({ error: "Failed to create C2S tunnel preset" });
}
});
/**
* @openapi
* /plugin-api/tunnels/presets/{id}:
* put:
* summary: Update a client tunnel preset
* tags:
* - Tunnels
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Preset updated.
* 400:
* description: Invalid name or config.
* 404:
* description: Preset not found.
* 409:
* description: A preset with that name already exists.
*/
router.put("/presets/:id", async (req: Request, res: Response) => {
const userId = actor(ctx);
const id = Number(req.params.id);
const { name, config, platform, computerName } = req.body ?? {};
if (!Number.isInteger(id)) {
return res.status(400).json({ error: "Invalid request" });
}
try {
const existing = await presets.findByIdForUser(userId, id);
if (!existing) return res.status(404).json({ error: "Preset not found" });
const updates: Parameters<PresetRepository["updateForUser"]>[2] = {};
if (name !== undefined) {
if (!isNonEmptyString(name)) {
return res.status(400).json({ error: "Preset name is required" });
}
const trimmedName = name.trim();
if (await presets.hasNameForUser(userId, trimmedName, id)) {
return res.status(409).json({ error: "Preset name already exists" });
}
updates.name = trimmedName;
}
if (config !== undefined) {
if (!validatePresetConfig(config)) {
return res
.status(400)
.json({ error: "Invalid C2S tunnel configuration" });
}
updates.config = JSON.stringify(config);
}
if (platform !== undefined) updates.platform = trimmedOrNull(platform);
if (computerName !== undefined) {
updates.computerName = trimmedOrNull(computerName);
}
const updated = await presets.updateForUser(userId, id, updates);
res.json(parsePreset(updated ?? existing));
} catch (error) {
ctx.log.error(
"Failed to update client tunnel preset",
error instanceof Error ? error : undefined,
);
res.status(500).json({ error: "Failed to update C2S tunnel preset" });
}
});
/**
* @openapi
* /plugin-api/tunnels/presets/{id}:
* delete:
* summary: Delete a client tunnel preset
* tags:
* - Tunnels
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Preset deleted.
* 404:
* description: Preset not found.
*/
router.delete("/presets/:id", async (req: Request, res: Response) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) {
return res.status(400).json({ error: "Invalid request" });
}
try {
if (!(await presets.deleteForUser(actor(ctx), id))) {
return res.status(404).json({ error: "Preset not found" });
}
res.json({ success: true });
} catch (error) {
ctx.log.error(
"Failed to delete client tunnel preset",
error instanceof Error ? error : undefined,
);
res.status(500).json({ error: "Failed to delete C2S tunnel preset" });
}
});
}
+260
View File
@@ -0,0 +1,260 @@
import type { Client } from "ssh2";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { TunnelConfig, TunnelStatus } from "./types.js";
import type { TunnelManager, TunnelRuntime } from "./manager.js";
import { forwardOut } from "./ssh-primitives.js";
import {
RESERVED_TUNNEL_NAME_PREFIX,
isReservedTunnelName,
parseReservedTunnelName,
} from "./utils.js";
import { authorizeTunnelAction } from "./authorize.js";
import {
buildTunnelConfig,
connectionToRequest,
findSavedTunnel,
hostLabel,
resolveEndpoint,
} from "./config.js";
export interface TunnelForwardTarget {
targetHost: string;
targetPort: number;
/** Where the local listener binds. Defaults to 127.0.0.1. */
bindHost?: string;
/** A fixed local port. Defaults to one the kernel picks. */
bindPort?: number;
}
export interface TunnelForwardHandle {
bindHost: string;
bindPort: number;
close: () => Promise<void>;
}
/** What other plugins get from ctx.services.get("tunnels.access"). */
export interface TunnelsAccess {
/**
* Opens (or reuses) a local forward to target through a source host and
* resolves only once it works: connected, listening and the target
* answered one probe. Rejects with the real reason otherwise.
*/
forward(
sourceHostId: number,
target: TunnelForwardTarget,
options?: { name?: string; idleTimeoutMs?: number },
): Promise<TunnelForwardHandle>;
/** Starts a saved tunnel by name. Its outcome arrives as status. */
start(name: string): Promise<void>;
/** Stops a tunnel by name and holds off its retries. */
stop(name: string): Promise<void>;
status(name: string): Promise<TunnelStatus | null>;
list(): Promise<Record<string, TunnelStatus>>;
}
const TARGET_PROBE_TIMEOUT_MS = 10_000;
/**
* Opens one channel to the target and closes it again. Without it a forward
* reports success the moment its listener is up, and the likeliest failure
* (nothing listening on the target port) only shows up as a blank page.
*/
async function probeTarget(
client: Client,
targetHost: string,
targetPort: number,
): Promise<void> {
let timer: NodeJS.Timeout | undefined;
try {
const channel = await Promise.race([
forwardOut(client, targetHost, targetPort),
new Promise<never>((_resolve, reject) => {
timer = 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.
}
} finally {
if (timer) clearTimeout(timer);
}
}
function isPort(value: unknown, allowZero = false): value is number {
return (
typeof value === "number" &&
Number.isInteger(value) &&
value >= (allowZero ? 0 : 1) &&
value <= 65535
);
}
export function createTunnelsService(
ctx: PluginContext,
manager: TunnelManager,
): TunnelsAccess {
// What each live forward was opened for, so an edited target reopens it
// instead of silently forwarding to the old one.
const fingerprints = new Map<string, string>();
const actor = (): string => {
const userId = ctx.currentActor();
if (!userId) throw new Error("No acting user");
return userId;
};
const handleFor = (
name: string,
runtime: TunnelRuntime,
): TunnelForwardHandle => ({
bindHost: runtime.bindHost,
bindPort: runtime.bindPort,
close: async () => {
fingerprints.delete(name);
await manager.cleanup(name, true);
},
});
const canAccess = (hostId: number) => manager.canAccessHost(actor(), hostId);
return {
async forward(sourceHostId, target, options = {}) {
const userId = actor();
if (!isPort(target.targetPort)) throw new Error("Invalid target port");
if (target.bindPort !== undefined && !isPort(target.bindPort, true)) {
throw new Error("Invalid bind port");
}
const targetHost = target.targetHost?.trim();
if (!targetHost) throw new Error("Invalid target host");
const access = await ctx.hosts.checkAccess(sourceHostId, "connect");
const host = access.hasAccess ? await ctx.hosts.get(sourceHostId) : null;
if (!host) throw new Error("Host not found or access denied");
const name =
options.name ??
`${RESERVED_TUNNEL_NAME_PREFIX}${sourceHostId}:${targetHost}:${target.targetPort}`;
if (
!isReservedTunnelName(name) ||
parseReservedTunnelName(name)?.hostId !== sourceHostId
) {
throw new Error(
`Forward names must be "${RESERVED_TUNNEL_NAME_PREFIX}<hostId>:<id>" for the source host`,
);
}
const bindHost = target.bindHost?.trim() || "127.0.0.1";
const fingerprint = JSON.stringify({
targetHost,
targetPort: target.targetPort,
bindHost,
bindPort: target.bindPort ?? null,
ip: host.ip,
sshPort: host.port,
username: host.username,
});
const existing = manager.runtimes.get(name);
if (existing) {
const recorded = fingerprints.get(name);
if (recorded === undefined || recorded === fingerprint) {
fingerprints.set(name, fingerprint);
return handleFor(name, existing);
}
ctx.log.info(`Reopening tunnel ${name} after its target changed`);
await manager.cleanup(name, true);
fingerprints.delete(name);
}
const config: TunnelConfig = {
name,
scope: "s2s",
mode: "local",
tunnelType: "local",
bindHost,
targetHost,
sourceHostId: host.id,
tunnelIndex: 0,
requestingUserId: userId,
hostName: hostLabel(host),
sourceIP: host.ip,
sourceSSHPort: host.port,
sourceUsername: host.username,
// Loopback marks it single host, which is what picks the
// listen-here-and-forwardOut path.
endpointHost: "127.0.0.1",
sourcePort: target.bindPort ?? 0,
endpointPort: target.targetPort,
maxRetries: 0,
retryInterval: 0,
autoStart: false,
idleTimeoutMs: options.idleTimeoutMs,
};
manager.manualDisconnects.delete(name);
try {
const runtime = await manager.connect(config, 0, {
throwOnError: true,
});
if (!runtime) {
throw new Error("The tunnel closed before it could be used");
}
await probeTarget(runtime.sourceClient, targetHost, target.targetPort);
fingerprints.set(name, fingerprint);
return handleFor(name, runtime);
} catch (error) {
// Nothing half-built may be handed out on the next call.
await manager.cleanup(name, true).catch(() => undefined);
fingerprints.delete(name);
throw error;
}
},
async start(name) {
const existing = manager.configs.get(name);
if (existing) {
if (!(await canAccess(existing.sourceHostId))) {
throw new Error(`Tunnel "${name}" is not configured`);
}
await manager.start(existing);
return;
}
const saved = await findSavedTunnel(ctx, name);
if (!saved) throw new Error(`Tunnel "${name}" is not configured`);
const config = await resolveEndpoint(
ctx,
buildTunnelConfig(
saved.host,
connectionToRequest(saved.host, saved.index, saved.connection),
actor(),
),
);
await manager.start(config);
},
async stop(name) {
const decision = await authorizeTunnelAction(
canAccess,
name,
manager.configs.get(name),
);
if (!decision.allowed) throw new Error("Access denied");
await manager.stop(name);
},
async status(name) {
if (!(await manager.canAccessTunnel(actor(), name))) return null;
return manager.statuses.get(name) ?? null;
},
async list() {
return manager.statusesFor(actor());
},
};
}
+107
View File
@@ -0,0 +1,107 @@
import type { Duplex } from "node:stream";
import type { PluginLogger } from "@termix/plugin-sdk/backend";
function parseSocksAddress(buffer: Buffer): {
address: string;
port: number;
bytesRead: number;
} | null {
if (buffer.length < 7 || buffer[0] !== 0x05 || buffer[1] !== 0x01) {
return null;
}
const addressType = buffer[3];
let offset = 4;
let address: string;
if (addressType === 0x01) {
if (buffer.length < offset + 4 + 2) return null;
address = Array.from(buffer.subarray(offset, offset + 4)).join(".");
offset += 4;
} else if (addressType === 0x03) {
const len = buffer[offset];
offset += 1;
if (buffer.length < offset + len + 2) return null;
address = buffer.subarray(offset, offset + len).toString("utf8");
offset += len;
} else if (addressType === 0x04) {
if (buffer.length < offset + 16 + 2) return null;
const parts: string[] = [];
for (let i = 0; i < 16; i += 2) {
parts.push(buffer.readUInt16BE(offset + i).toString(16));
}
address = parts.join(":");
offset += 16;
} else {
return null;
}
const port = buffer.readUInt16BE(offset);
return { address, port, bytesRead: offset + 2 };
}
/** The server side of a SOCKS5 CONNECT, for dynamic mode tunnels. */
export function handleSocks5Connect(
inbound: Duplex,
openOutbound: (host: string, port: number) => Promise<Duplex>,
tunnelName: string,
log?: PluginLogger,
): void {
let buffer = Buffer.alloc(0);
let stage: "greeting" | "connect" | "piping" = "greeting";
const fail = (code = 0x01) => {
if (!inbound.destroyed) {
inbound.write(Buffer.from([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
inbound.destroy();
}
};
const onData = (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk]);
if (stage === "greeting") {
if (buffer.length < 2) return;
if (buffer[0] !== 0x05) {
fail(0x01);
return;
}
const methodsLength = buffer[1];
if (buffer.length < 2 + methodsLength) return;
inbound.write(Buffer.from([0x05, 0x00]));
buffer = buffer.subarray(2 + methodsLength);
stage = "connect";
}
if (stage === "connect") {
const parsed = parseSocksAddress(buffer);
if (!parsed) return;
stage = "piping";
inbound.off("data", onData);
const remainder = buffer.subarray(parsed.bytesRead);
openOutbound(parsed.address, parsed.port)
.then((outbound) => {
inbound.write(
Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]),
);
if (remainder.length > 0) {
outbound.write(remainder);
}
inbound.pipe(outbound).pipe(inbound);
inbound.on("error", () => outbound.destroy());
outbound.on("error", () => inbound.destroy());
})
.catch((error: unknown) => {
log?.warn(
`SOCKS5 connect to ${parsed.address}:${parsed.port} failed for tunnel ${tunnelName}: ${
error instanceof Error ? error.message : String(error)
}`,
);
fail(0x05);
});
}
};
inbound.on("data", onData);
inbound.on("error", () => inbound.destroy());
}
@@ -0,0 +1,78 @@
import type { Client, ClientChannel } from "ssh2";
import type { Duplex } from "node:stream";
import type { PluginLogger } from "@termix/plugin-sdk/backend";
// Plain ssh2 channel operations on a client that already came from ctx.ssh.
export function forwardOut(
client: Client,
targetHost: string,
targetPort: number,
): Promise<ClientChannel> {
return new Promise((resolve, reject) => {
client.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream) => {
if (err) {
reject(err);
return;
}
resolve(stream);
});
});
}
export function bindForwardIn(
client: Client,
bindHost: string,
bindPort: number,
): Promise<number> {
return new Promise((resolve, reject) => {
client.forwardIn(bindHost, bindPort, (err, actualPort) => {
if (err) {
reject(err);
return;
}
resolve(actualPort || bindPort);
});
});
}
export function unbindForwardIn(
client: Client,
bindHost: string,
bindPort: number,
log?: PluginLogger,
): void {
try {
client.unforwardIn(bindHost, bindPort, (err) => {
if (err) {
log?.warn(
`Failed to unbind tunnel listener ${bindHost}:${bindPort}: ${err.message}`,
);
}
});
} catch {
// The connection may already be gone.
}
}
export function pipeTunnelStreams(
inbound: Duplex,
outboundPromise: Promise<Duplex>,
tunnelName: string,
log?: PluginLogger,
): void {
outboundPromise
.then((outbound) => {
inbound.pipe(outbound).pipe(inbound);
inbound.on("error", () => outbound.destroy());
outbound.on("error", () => inbound.destroy());
})
.catch((error: unknown) => {
log?.warn(
`Tunnel ${tunnelName} could not open its outbound stream: ${
error instanceof Error ? error.message : String(error)
}`,
);
inbound.destroy();
});
}
+32
View File
@@ -0,0 +1,32 @@
import {
adoptLegacyTable,
defineTable,
id,
refUser,
text,
timestamp,
} from "@termix/plugin-sdk/db";
/**
* Client tunnel presets the desktop app saves per user.
*
* Adopted from core's c2s_tunnel_presets, so the column names are the legacy
* ones and the rename carries every existing row across. Core never indexed
* it, so there is no legacy index to keep.
*/
export const presets = adoptLegacyTable(
"c2s_tunnel_presets",
defineTable("presets", {
id: id(),
userId: refUser(),
name: text().notNull(),
// JSON: TunnelConnection[]
config: text().notNull(),
platform: text(),
computerName: text(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
}),
);
export const tables = [presets];
+60
View File
@@ -0,0 +1,60 @@
import type { TunnelMode, TunnelScope } from "../shared/types.js";
export type {
C2STunnelPreset,
ConnectionState,
TunnelConnectRequest,
TunnelConnection,
TunnelErrorType,
TunnelMode,
TunnelScope,
TunnelStatus,
} from "../shared/types.js";
export { CONNECTION_STATES } from "../shared/types.js";
/**
* A tunnel as the manager runs it. Built server side from a saved connection
* or a connect request; it never carries credentials; both SSH legs resolve
* theirs through ctx.ssh.
*/
export interface TunnelConfig {
name: string;
scope?: TunnelScope;
mode?: TunnelMode;
tunnelType?: "local" | "remote";
localAddress?: string;
remoteAddress?: string;
bindHost?: string;
targetHost?: string;
sourceHostId: number;
sourceHostSyncId?: string;
tunnelIndex: number;
/** Who the SSH connections run as. */
requestingUserId: string;
hostName: string;
sourceIP: string;
sourceSSHPort: number;
sourceUsername: string;
endpointHost: string;
/** The Termix host the endpoint leg connects to, when there is one. */
endpointHostId?: number;
endpointIP?: string;
endpointSSHPort?: number;
endpointUsername?: string;
sourcePort: number;
endpointPort: number;
maxRetries: number;
/** Milliseconds. */
retryInterval: number;
autoStart: boolean;
/**
* When set, the tunnel closes itself once it has had no connected sockets
* for this long. forward() uses it for on-demand tunnels.
*/
idleTimeoutMs?: number;
}
+265
View File
@@ -0,0 +1,265 @@
import { buildTunnelName } from "../shared/tunnel-naming.js";
import type { TunnelConfig, TunnelErrorType } from "./types.js";
export function classifyTunnelError(errorMessage: string): TunnelErrorType {
if (!errorMessage) return "UNKNOWN";
const message = errorMessage.toLowerCase();
if (
message.includes("closed by remote host") ||
message.includes("connection reset by peer") ||
message.includes("connection refused") ||
message.includes("broken pipe")
) {
return "NETWORK_ERROR";
}
if (
message.includes("authentication failed") ||
message.includes("authentication methods failed") ||
message.includes("permission denied") ||
message.includes("incorrect password")
) {
return "AUTHENTICATION_FAILED";
}
if (
message.includes("connect etimedout") ||
message.includes("timeout") ||
message.includes("timed out") ||
message.includes("keepalive timeout")
) {
return "TIMEOUT";
}
if (
message.includes("bind: address already in use") ||
message.includes("failed for listen port") ||
message.includes("port forwarding failed")
) {
return "CONNECTION_FAILED";
}
if (message.includes("permission") || message.includes("access denied")) {
return "CONNECTION_FAILED";
}
return "UNKNOWN";
}
/** The name a host's saved tunnel runs under. See shared/tunnel-naming.ts. */
export const normalizeTunnelName = buildTunnelName;
export function getTunnelMode(
tunnelConfig: Pick<TunnelConfig, "mode" | "tunnelType">,
): "local" | "remote" | "dynamic" {
return tunnelConfig.mode || tunnelConfig.tunnelType || "remote";
}
export function getTunnelScope(
tunnelConfig: Pick<TunnelConfig, "scope">,
): "s2s" | "c2s" {
return tunnelConfig.scope || "s2s";
}
export function getTunnelBindHost(
tunnelConfig: Pick<TunnelConfig, "bindHost">,
): string {
return tunnelConfig.bindHost || "127.0.0.1";
}
export function parseTunnelName(tunnelName: string): {
hostId?: number;
tunnelIndex?: number;
displayName: string;
sourcePort: string;
endpointHost: string;
endpointPort: string;
isLegacyFormat: boolean;
} {
const parts = tunnelName.split("::");
if (parts.length === 6) {
return {
hostId: parseInt(parts[0]),
tunnelIndex: parseInt(parts[1]),
displayName: parts[2],
sourcePort: parts[3],
endpointHost: parts[4],
endpointPort: parts[5],
isLegacyFormat: false,
};
}
const legacyParts = tunnelName.split("_");
return {
displayName: legacyParts[0] || "unknown",
sourcePort: legacyParts[legacyParts.length - 3] || "0",
endpointHost: legacyParts[legacyParts.length - 2] || "unknown",
endpointPort: legacyParts[legacyParts.length - 1] || "0",
isLegacyFormat: true,
};
}
export function validateTunnelConfig(
tunnelName: string,
tunnelConfig: Pick<
TunnelConfig,
| "sourceHostId"
| "tunnelIndex"
| "sourcePort"
| "endpointHost"
| "endpointPort"
>,
): boolean {
const parsed = parseTunnelName(tunnelName);
if (parsed.isLegacyFormat) {
return true;
}
return (
parsed.hostId === tunnelConfig.sourceHostId &&
parsed.tunnelIndex === tunnelConfig.tunnelIndex &&
String(parsed.sourcePort) === String(tunnelConfig.sourcePort) &&
parsed.endpointHost === tunnelConfig.endpointHost &&
String(parsed.endpointPort) === String(tunnelConfig.endpointPort)
);
}
/**
* Tunnel names beginning with this prefix are reserved for forward() tunnels
* (web endpoints): they are opened on demand and never retried on disconnect.
* A user-supplied name using the prefix would silently lose its own retry
* behaviour and could collide with a live forward, so POST /connect rejects
* it.
*/
export const RESERVED_TUNNEL_NAME_PREFIX = "web:";
export function isReservedTunnelName(tunnelName: string): boolean {
return tunnelName.startsWith(RESERVED_TUNNEL_NAME_PREFIX);
}
/** The exact inverse of parseReservedTunnelName. */
export function buildWebEndpointTunnelName(
hostId: number,
endpointId: string,
): string {
return `${RESERVED_TUNNEL_NAME_PREFIX}${hostId}:${endpointId}`;
}
/**
* Recovers the host id and endpoint id from a reserved name, so a route that
* only has the name can still check ownership.
*
* Returns null for anything that is not a reserved name or whose host id is
* not a positive integer. Callers must treat null as "cannot verify" and deny.
* The endpoint id is everything after the first ":" following the host id,
* so it may contain colons itself.
*/
export function parseReservedTunnelName(
tunnelName: string,
): { hostId: number; endpointId: string } | null {
if (!isReservedTunnelName(tunnelName)) return null;
const rest = tunnelName.slice(RESERVED_TUNNEL_NAME_PREFIX.length);
const separatorIndex = rest.indexOf(":");
if (separatorIndex === -1) return null;
const hostIdPart = rest.slice(0, separatorIndex);
const endpointId = rest.slice(separatorIndex + 1);
if (!/^[0-9]+$/.test(hostIdPart) || !endpointId) return null;
const hostId = Number(hostIdPart);
if (!Number.isInteger(hostId) || hostId < 1) return null;
return { hostId, endpointId };
}
type EndpointHostLike = {
id: number | string;
name?: string | null;
ip: string;
username?: string | null;
};
/** Matches a saved endpointHost string against a host id, name, ip or user@ip. */
export function findHostByTunnelEndpoint<T extends EndpointHostLike>(
hosts: T[],
endpointHost?: string | null,
): T | undefined {
const value = endpointHost?.trim();
if (!value) return undefined;
return hosts.find((host) => {
const userAtIp = host.username ? `${host.username}@${host.ip}` : "";
return (
String(host.id) === value ||
host.name === value ||
host.ip === value ||
userAtIp === value
);
});
}
export function isSingleHostTunnel(
tunnelConfig: Pick<
TunnelConfig,
| "endpointHost"
| "endpointIP"
| "sourceIP"
| "endpointSSHPort"
| "sourceSSHPort"
| "endpointHostId"
| "sourceHostId"
>,
): boolean {
if (!tunnelConfig.endpointHost && !tunnelConfig.endpointIP) return true;
if (
tunnelConfig.endpointHost === "127.0.0.1" ||
tunnelConfig.endpointHost === "localhost"
) {
return true;
}
if (
tunnelConfig.endpointHostId !== undefined &&
tunnelConfig.endpointHostId === tunnelConfig.sourceHostId
) {
return true;
}
if (
tunnelConfig.endpointIP &&
tunnelConfig.endpointIP === tunnelConfig.sourceIP &&
tunnelConfig.endpointSSHPort === tunnelConfig.sourceSSHPort
) {
return true;
}
return false;
}
/**
* Direct tunnels ride the source connection alone. Anything else needs a
* second SSH leg to a Termix endpoint host, reached through the source.
*/
export function shouldEstablishDirectTunnel(
tunnelConfig: TunnelConfig,
): boolean {
if (isSingleHostTunnel(tunnelConfig)) return true;
const mode = getTunnelMode(tunnelConfig);
return mode !== "remote" && tunnelConfig.endpointHostId === undefined;
}
export function resolveS2SLocalTargetHost(tunnelConfig: TunnelConfig): string {
const targetHost = tunnelConfig.targetHost?.trim();
if (
!targetHost ||
targetHost === tunnelConfig.endpointHost ||
targetHost === tunnelConfig.hostName
) {
return "127.0.0.1";
}
return targetHost;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,372 @@
import { useEffect, useState } from "react";
import { Network, Plus } from "lucide-react";
import {
useHosts,
useToast,
useTranslation,
type HostEditorSectionProps,
} from "@termix/plugin-sdk/frontend";
import {
Button,
FakeSwitch,
Input,
SectionCard,
SettingRow,
} from "@termix/plugin-sdk/ui";
import type { TunnelConnection } from "../shared/types";
import { serverTunnelName } from "../shared/tunnel-naming";
import {
connectTunnel,
disconnectTunnel,
subscribeTunnelStatuses,
type TunnelStatusMap,
} from "./api";
import {
connectRequestFor,
parseConnections,
tunnelMode,
} from "./host-tunnels";
type PluginSettingsForm = Record<string, Record<string, unknown>>;
function newTunnel(): TunnelConnection {
return {
scope: "s2s",
mode: "local",
sourcePort: 8080,
endpointHost: "",
endpointPort: 80,
bindHost: "127.0.0.1",
maxRetries: 3,
retryInterval: 10,
autoStart: false,
};
}
const MODE_DESCRIPTION_KEYS = {
local: "hosts.tunnelModeLocalDesc",
remote: "hosts.tunnelModeRemoteDesc",
dynamic: "hosts.tunnelModeDynamicDesc",
} as const;
const numberInputClass =
"h-7 text-xs [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none";
/**
* The host editor's Tunnels tab. Writes into the form's pluginSettings for
* this plugin, which the editor saves through the plugin's host settings
* route after the host itself.
*/
export function HostTunnelsSection({
form,
updateForm,
host,
}: HostEditorSectionProps) {
const { t } = useTranslation();
const toast = useToast();
const { hosts } = useHosts();
const [statuses, setStatuses] = useState<TunnelStatusMap>({});
const [busyIndex, setBusyIndex] = useState<number | null>(null);
useEffect(() => subscribeTunnelStatuses(setStatuses), []);
const settings = ((form?.pluginSettings as PluginSettingsForm | undefined)
?.tunnels ?? {}) as Record<string, unknown>;
const enabled = settings.enableTunnel === true;
const tunnels = parseConnections(settings.tunnelConnections);
const setSettings = (patch: Record<string, unknown>) =>
updateForm((current) => {
const all = (current.pluginSettings ?? {}) as PluginSettingsForm;
return {
...current,
pluginSettings: {
...all,
tunnels: { ...(all.tunnels ?? {}), ...patch },
},
};
});
const setTunnels = (next: TunnelConnection[]) =>
setSettings({ tunnelConnections: next });
const updateTunnel = (index: number, patch: Partial<TunnelConnection>) =>
setTunnels(
tunnels.map((tunnel, i) =>
i === index ? { ...tunnel, ...patch } : tunnel,
),
);
const sourceHost = host
? {
id: host.id,
name: (form?.name as string) || host.name,
username: (form?.username as string) || host.username,
ip: (form?.ip as string) || host.ip,
}
: null;
const toggle = async (index: number, connected: boolean) => {
if (!sourceHost) return;
const tunnel = tunnels[index];
setBusyIndex(index);
try {
if (connected) {
await disconnectTunnel(serverTunnelName(sourceHost, index, tunnel));
toast.success(t("hosts.tunnelDisconnected"));
} else {
await connectTunnel(connectRequestFor(sourceHost, index, tunnel));
toast.success(t("hosts.tunnelConnecting"));
}
} catch {
toast.error(
connected
? t("hosts.failedToDisconnectTunnel")
: t("hosts.failedToConnectTunnel"),
);
} finally {
setBusyIndex(null);
}
};
return (
<>
<SectionCard
title={t("hosts.tunnelSettings")}
icon={<Network className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableTunneling")}
description={
<>
{t("hosts.enableTunnelingDesc")}{" "}
<a
href="https://docs.termix.site/features/networking/tunnels"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</>
}
>
<FakeSwitch
checked={enabled}
onChange={(value) => setSettings({ enableTunnel: value })}
/>
</SettingRow>
<div className="text-xs text-muted-foreground p-3 bg-muted/30 border border-border space-y-1">
<p>{t("hosts.tunnelRequirementsText")}</p>
</div>
</div>
</SectionCard>
<SectionCard
title={t("hosts.serverTunnelsSection")}
icon={<Network className="size-3.5" />}
action={
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() => setTunnels([...tunnels, newTunnel()])}
>
<Plus className="size-3 mr-1" /> {t("hosts.addTunnelBtn")}
</Button>
}
>
<div className="flex flex-col gap-3 py-3">
{tunnels.length === 0 && (
<p className="text-[10px] text-muted-foreground/50 px-1">
{t("hosts.noTunnelsConfigured")}
</p>
)}
{tunnels.map((tunnel, i) => {
const mode = tunnelMode(tunnel);
const endpointInputId = `server-tunnel-endpoint-${host?.id ?? "new"}-${i}`;
const status = sourceHost
? statuses[serverTunnelName(sourceHost, i, tunnel)]?.status
: undefined;
const isConnected = status === "connected";
return (
<div
key={i}
className="flex flex-col gap-3 p-3 border border-border bg-muted/20 relative group"
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-muted-foreground">
{t("hosts.tunnelLabel", { number: i + 1 })}
</span>
<div
className={`size-1.5 rounded-full shrink-0 ${
isConnected
? "bg-accent-brand shadow-[0_0_4px_rgba(251,146,60,0.4)]"
: status === "failed"
? "bg-red-400"
: "bg-muted-foreground/25"
}`}
title={status ?? t("tunnels.disconnected")}
/>
{sourceHost && (
<Button
variant="outline"
size="sm"
disabled={busyIndex === i}
className={`h-6 text-[10px] px-2 ${isConnected ? "border-destructive/40 text-destructive hover:bg-destructive/10" : "border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"}`}
onClick={() => toggle(i, isConnected)}
>
{busyIndex === i
? "..."
: isConnected
? t("hosts.disconnectBtn")
: t("hosts.connectBtn")}
</Button>
)}
</div>
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] px-2 text-destructive"
onClick={() =>
setTunnels(tunnels.filter((_, idx) => idx !== i))
}
>
{t("common.delete")}
</Button>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.tunnelType")}
</label>
<div className="flex gap-2">
{(["remote", "local", "dynamic"] as const).map((m) => (
<button
key={m}
onClick={() => updateTunnel(i, { mode: m })}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${mode === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{m}
</button>
))}
</div>
<p className="text-[10px] text-muted-foreground/70 mt-0.5">
{t(MODE_DESCRIPTION_KEYS[mode])}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{mode !== "dynamic" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.endpointHost")}
</label>
<Input
className="h-7 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
list={endpointInputId}
placeholder={t("hosts.endpointHostPlaceholder")}
value={tunnel.endpointHost ?? ""}
onChange={(e) =>
updateTunnel(i, { endpointHost: e.target.value })
}
/>
<datalist id={endpointInputId}>
{hosts
.filter((h) => h.enableSsh !== false)
.map((h) => (
<option key={h.id} value={h.ip}>
{h.name || h.ip} ({h.ip})
</option>
))}
</datalist>
</div>
)}
{mode !== "dynamic" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.endpointPort")}
</label>
<Input
className={numberInputClass}
type="number"
value={tunnel.endpointPort}
onChange={(e) =>
updateTunnel(i, {
endpointPort: Number(e.target.value),
})
}
/>
</div>
)}
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.bindHost")}
</label>
<Input
className="h-7 text-xs"
placeholder="127.0.0.1"
value={tunnel.bindHost ?? ""}
onChange={(e) =>
updateTunnel(i, { bindHost: e.target.value })
}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.sourcePort")}
</label>
<Input
className={numberInputClass}
type="number"
value={tunnel.sourcePort}
onChange={(e) =>
updateTunnel(i, { sourcePort: Number(e.target.value) })
}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.maxRetries")}
</label>
<Input
className={numberInputClass}
type="number"
value={tunnel.maxRetries}
onChange={(e) =>
updateTunnel(i, { maxRetries: Number(e.target.value) })
}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.retryIntervalS")}
</label>
<Input
className={numberInputClass}
type="number"
value={tunnel.retryInterval}
onChange={(e) =>
updateTunnel(i, {
retryInterval: Number(e.target.value),
})
}
/>
</div>
</div>
<SettingRow
label={t("hosts.autoStartLabel")}
description={t("hosts.autoStartDesc")}
>
<FakeSwitch
checked={tunnel.autoStart}
onChange={(value) => updateTunnel(i, { autoStart: value })}
/>
</SettingRow>
</div>
);
})}
</div>
</SectionCard>
</>
);
}
+364
View File
@@ -0,0 +1,364 @@
import { useEffect, useState } from "react";
import {
AlertCircle,
Clock,
ExternalLink,
Network,
Play,
RefreshCw,
Settings,
Square,
Wifi,
WifiOff,
} from "lucide-react";
import {
useHost,
useToast,
useTranslation,
type PluginHostRecord,
} from "@termix/plugin-sdk/frontend";
import { Button, Card } from "@termix/plugin-sdk/ui";
import type { TunnelConnection, TunnelStatus } from "../shared/types";
import { serverTunnelName, tunnelHostLabel } from "../shared/tunnel-naming";
import {
cancelTunnel,
connectTunnel,
disconnectTunnel,
subscribeTunnelStatuses,
type TunnelStatusMap,
} from "./api";
import {
connectRequestFor,
hostTunnelSettings,
tunnelMode,
} from "./host-tunnels";
type StatusLabel =
"CONNECTED" | "CONNECTING" | "ERROR" | "WAITING" | "DISCONNECTED";
function statusLabel(status: TunnelStatus | undefined): StatusLabel {
const value = status?.status?.toUpperCase();
if (value === "CONNECTED") return "CONNECTED";
if (value === "CONNECTING" || value === "VERIFYING") return "CONNECTING";
if (value === "FAILED") return "ERROR";
if (value === "RETRYING" || value === "WAITING") return "WAITING";
return "DISCONNECTED";
}
const STATUS_TEXT_KEYS: Record<StatusLabel, string> = {
CONNECTED: "tunnels.connected",
CONNECTING: "tunnels.connecting",
ERROR: "tunnels.error",
WAITING: "tunnels.waiting",
DISCONNECTED: "tunnels.disconnected",
};
function TunnelCard({
host,
tunnel,
status,
isActing,
onAction,
}: {
host: PluginHostRecord;
tunnel: TunnelConnection;
status: TunnelStatus | undefined;
isActing: boolean;
onAction: (action: "connect" | "disconnect" | "cancel") => void;
}) {
const { t } = useTranslation();
const [showSettings, setShowSettings] = useState(false);
const label = statusLabel(status);
const isConnected = label === "CONNECTED";
const isConnecting = label === "CONNECTING";
const isError = label === "ERROR";
const isWaiting = label === "WAITING";
let statusColor = "text-muted-foreground border-border bg-muted/30";
if (isConnected)
statusColor = "text-accent-brand border-accent-brand/40 bg-accent-brand/10";
if (isConnecting)
statusColor = "text-blue-400 border-blue-400/40 bg-blue-400/10";
if (isError)
statusColor = "text-destructive border-destructive/40 bg-destructive/10";
if (isWaiting)
statusColor = "text-yellow-500 border-yellow-500/40 bg-yellow-500/10";
const mode = tunnelMode(tunnel);
const destination =
mode === "dynamic"
? t("tunnels.socksProxy")
: `${tunnel.endpointHost ?? ""}:${tunnel.endpointPort}`;
return (
<Card className="flex flex-col overflow-hidden p-0 gap-0">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-muted/10">
<div className="flex items-center gap-2">
<Network className="size-3.5 text-muted-foreground" />
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("tunnels.port")} {tunnel.sourcePort}
</span>
</div>
<div
className={`flex items-center gap-1.5 px-2 py-0.5 border text-[10px] font-bold uppercase ${statusColor}`}
>
{isConnecting || isActing ? (
<RefreshCw className="size-3 animate-spin" />
) : isConnected ? (
<Wifi className="size-3" />
) : isError ? (
<AlertCircle className="size-3" />
) : isWaiting ? (
<Clock className="size-3" />
) : (
<WifiOff className="size-3" />
)}
{isActing ? t("tunnels.working") : t(STATUS_TEXT_KEYS[label])}
</div>
</div>
<div className="px-4 py-4 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<span className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">
{t("tunnels.destination")}
</span>
<span
className="text-sm font-mono font-semibold truncate"
title={destination}
>
{destination}
</span>
<div className="flex items-center gap-1.5 mt-1">
<span className="text-[10px] font-semibold px-1.5 py-px border border-border text-muted-foreground uppercase">
{mode}
</span>
<span className="text-[10px] text-muted-foreground">
{t("tunnels.listensOn", { port: tunnel.sourcePort })}
</span>
</div>
</div>
{isError && status?.reason && (
<div className="flex items-start gap-2 p-2 bg-destructive/5 border border-destructive/20 text-destructive text-[10px]">
<AlertCircle className="size-3 mt-0.5 shrink-0" />
<span>{status.reason}</span>
</div>
)}
{showSettings && (
<div className="border border-border bg-muted/20 p-3 flex flex-col gap-2 text-xs">
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.host")}
</span>
<span className="font-mono">{tunnelHostLabel(host)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.mode")}
</span>
<span className="uppercase font-bold">{mode}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.localPort")}
</span>
<span className="font-mono">{tunnel.sourcePort}</span>
</div>
{mode !== "dynamic" && (
<>
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.remoteHost")}
</span>
<span className="font-mono">{tunnel.endpointHost}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.remotePort")}
</span>
<span className="font-mono">{tunnel.endpointPort}</span>
</div>
</>
)}
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.maxRetries")}
</span>
<span className="font-mono">{tunnel.maxRetries}</span>
</div>
</div>
)}
<div className="flex gap-2 mt-1">
{isConnected ? (
<Button
variant="outline"
size="sm"
className="flex-1 h-8 text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive gap-1.5"
disabled={isActing}
onClick={() => onAction("disconnect")}
>
<Square className="size-3" />
{t("tunnels.stop")}
</Button>
) : isConnecting || isWaiting ? (
<Button
variant="outline"
size="sm"
className="flex-1 h-8 text-yellow-500 border-yellow-500/40 hover:bg-yellow-500/10 hover:text-yellow-500 gap-1.5"
disabled={isActing}
onClick={() => onAction("cancel")}
>
<Square className="size-3" />
{t("tunnels.cancel")}
</Button>
) : (
<Button
variant="outline"
size="sm"
className="flex-1 h-8 text-accent-brand border-accent-brand/40 hover:bg-accent-brand/10 hover:text-accent-brand gap-1.5"
disabled={isActing}
onClick={() => onAction("connect")}
>
{isActing ? (
<RefreshCw className="size-3 animate-spin" />
) : (
<Play className="size-3" />
)}
{t("tunnels.start")}
</Button>
)}
<Button
variant={showSettings ? "secondary" : "ghost"}
size="icon"
className={`h-8 w-8 ${showSettings ? "bg-accent-brand/10 text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => setShowSettings((s) => !s)}
>
<Settings className="size-3.5" />
</Button>
</div>
</div>
</Card>
);
}
/** A host's saved server tunnels, with live status and start/stop controls. */
export function TunnelTab({ host: given }: { host?: PluginHostRecord }) {
const { t } = useTranslation();
const toast = useToast();
const live = useHost(given?.id);
const host = live ?? given ?? null;
const [statuses, setStatuses] = useState<TunnelStatusMap>({});
const [acting, setActing] = useState<Record<string, boolean>>({});
useEffect(() => subscribeTunnelStatuses(setStatuses), []);
if (!host) {
return (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
<div className="size-10 bg-muted/40 flex items-center justify-center">
<Network className="size-5 text-muted-foreground/30" />
</div>
<span className="text-sm font-semibold text-muted-foreground/60">
{t("tunnels.noHostSelected")}
</span>
</div>
);
}
const tunnels = hostTunnelSettings(host).connections;
const names = tunnels.map((tunnel, index) =>
serverTunnelName(host, index, tunnel),
);
const connectedCount = names.filter(
(name) => statuses[name]?.status === "connected",
).length;
const handleAction = async (
action: "connect" | "disconnect" | "cancel",
index: number,
) => {
const tunnel = tunnels[index];
if (!tunnel) return;
const name = names[index];
setActing((current) => ({ ...current, [name]: true }));
try {
if (action === "connect") {
await connectTunnel(connectRequestFor(host, index, tunnel));
toast.success(t("tunnels.clientTunnelStarted"));
} else if (action === "disconnect") {
await disconnectTunnel(name);
toast.info(t("tunnels.clientTunnelStopped"));
} else {
await cancelTunnel(name);
toast.info(t("tunnels.canceling"));
}
} catch {
toast.error(t("tunnels.manualControlError"));
} finally {
setActing((current) => ({ ...current, [name]: false }));
}
};
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
<div className="flex-1 overflow-y-auto px-3 py-3 flex flex-col gap-3">
<Card className="flex-row items-center justify-between px-3 py-3 shrink-0 gap-0">
<div className="flex items-center gap-3">
<div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0">
<Network className="size-5 text-accent-brand" />
</div>
<div>
<h1 className="text-2xl font-bold">{host.name}</h1>
<div className="flex items-center gap-2">
<span className="size-2 rounded-full bg-accent-brand" />
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("tunnels.activeCount", {
connected: connectedCount,
total: tunnels.length,
})}
</span>
</div>
</div>
</div>
<a
href="https://docs.termix.site/features/networking/tunnels"
target="_blank"
rel="noreferrer"
className="flex items-center justify-center size-9 text-muted-foreground hover:text-foreground transition-colors"
title={t("hosts.docsLink")}
>
<ExternalLink className="size-4" />
</a>
</Card>
{tunnels.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{tunnels.map((tunnel, index) => (
<TunnelCard
key={names[index]}
host={host}
tunnel={tunnel}
status={statuses[names[index]]}
isActing={acting[names[index]] ?? false}
onAction={(action) => handleAction(action, index)}
/>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center h-full gap-4 py-20">
<div className="opacity-10 flex flex-col items-center gap-4">
<Network className="size-16" />
<span className="text-xl font-bold uppercase tracking-widest">
{t("tunnels.noSshTunnels")}
</span>
</div>
<p className="text-sm text-muted-foreground text-center max-w-sm">
{t("tunnels.createFirstTunnelMessage")}
</p>
</div>
)}
</div>
</div>
);
}
+248
View File
@@ -0,0 +1,248 @@
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import type {
C2STunnelPreset,
TunnelConnectRequest,
TunnelConnection,
TunnelStatus,
} from "../shared/types";
export type TunnelStatusMap = Record<string, TunnelStatus>;
let client: PluginApiClient | null = null;
/** Set from activate with app.api, cleared on deactivate. */
export function setTunnelsApi(api: PluginApiClient | null): void {
client = api;
}
function api(): PluginApiClient {
if (!client) throw new Error("The tunnels plugin is not active");
return client;
}
/**
* The server's own reason for a failed call, or the fallback. The routes
* always answer { error: string }.
*/
export function apiErrorMessage(error: unknown, fallback: string): string {
const data = (error as { response?: { data?: { error?: unknown } } })
?.response?.data;
if (typeof data?.error === "string" && data.error) return data.error;
return fallback;
}
// Tunnel status is a process-local, in-memory view, so reading it is always
// safe. connectTunnel/disconnectTunnel/cancelTunnel always target the local
// embedded backend: they address a host by numeric id, and a synced host has
// a different numeric id in each database (only its syncId matches), so
// routing them to a remote backend would need a local-to-remote id lookup
// that does not exist yet.
export async function getTunnelStatuses(): Promise<TunnelStatusMap> {
const response = await api().get<TunnelStatusMap>("/status");
return response.data || {};
}
/**
* Local statuses win a name collision with remote ones, since every control
* targets the local backend.
*/
export function mergeTunnelStatuses(
local: TunnelStatusMap,
remote: TunnelStatusMap,
): TunnelStatusMap {
return { ...remote, ...local };
}
export interface ServerSentEvent {
event: string;
data: string;
}
/** Splits a text/event-stream body into events. Returns what is left over. */
export function parseServerSentEvents(
buffer: string,
onEvent: (event: ServerSentEvent) => void,
): string {
const blocks = buffer.split(/\r?\n\r?\n/);
const rest = blocks.pop() ?? "";
for (const block of blocks) {
let event = "message";
const data: string[] = [];
for (const line of block.split(/\r?\n/)) {
if (line.startsWith(":")) continue;
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
}
if (data.length > 0) onEvent({ event, data: data.join("\n") });
}
return rest;
}
function streamUrl(): string | null {
const base = (client as unknown as { defaults?: { baseURL?: string } })
?.defaults?.baseURL;
if (base === undefined) return null;
return `${base.replace(/\/$/, "")}/plugin-api/tunnels/status/stream`;
}
export interface SubscribeOptions {
/** Replaces the default fetch, for tests. */
fetchImpl?: typeof fetch;
/** Statuses from a connected remote server, polled and merged in. */
fetchRemote?: () => Promise<TunnelStatusMap>;
pollIntervalMs?: number;
}
/**
* Live tunnel statuses over the plugin's event stream, reconnecting after a
* drop. Falls back to polling when the stream URL cannot be worked out.
*/
export function subscribeTunnelStatuses(
onStatuses: (statuses: TunnelStatusMap) => void,
onError?: () => void,
options: SubscribeOptions = {},
): () => void {
const controller = new AbortController();
const fetchImpl = options.fetchImpl ?? fetch;
const pollIntervalMs = options.pollIntervalMs ?? 5000;
let latestLocal: TunnelStatusMap = {};
let latestRemote: TunnelStatusMap = {};
const timers = new Set<ReturnType<typeof setTimeout>>();
const emit = () => onStatuses(mergeTunnelStatuses(latestLocal, latestRemote));
const wait = (ms: number) =>
new Promise<void>((resolve) => {
const timer = setTimeout(() => {
timers.delete(timer);
resolve();
}, ms);
timers.add(timer);
});
const url = streamUrl();
void (async () => {
while (!controller.signal.aborted) {
if (!url) {
try {
latestLocal = await getTunnelStatuses();
emit();
} catch {
onError?.();
}
await wait(pollIntervalMs);
continue;
}
const headers: Record<string, string> = {
Accept: "text/event-stream",
};
if (typeof window !== "undefined" && window.electronAPI?.isElectron) {
headers["X-Electron-App"] = "true";
const jwt = localStorage.getItem("jwt");
if (jwt) headers.Authorization = `Bearer ${jwt}`;
}
try {
const response = await fetchImpl(url, {
credentials: "include",
headers,
signal: controller.signal,
});
if (!response.ok || !response.body) throw new Error("stream failed");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (!controller.signal.aborted) {
const { value, done } = await reader.read();
if (done) break;
buffer = parseServerSentEvents(
buffer + decoder.decode(value, { stream: true }),
(event) => {
if (event.event !== "statuses") return;
try {
latestLocal = JSON.parse(event.data) as TunnelStatusMap;
emit();
} catch {
onError?.();
}
},
);
}
if (!controller.signal.aborted) onError?.();
} catch {
if (!controller.signal.aborted) onError?.();
}
if (!controller.signal.aborted) await wait(1000);
}
})();
if (options.fetchRemote) {
const fetchRemote = options.fetchRemote;
void (async () => {
while (!controller.signal.aborted) {
try {
latestRemote = await fetchRemote();
emit();
} catch {
// A remote that is down just contributes nothing.
}
await wait(pollIntervalMs);
}
})();
}
return () => {
controller.abort();
for (const timer of timers) clearTimeout(timer);
timers.clear();
};
}
export async function connectTunnel(
request: TunnelConnectRequest,
): Promise<void> {
await api().post("/connect", request);
}
export async function disconnectTunnel(tunnelName: string): Promise<void> {
await api().post("/disconnect", { tunnelName });
}
export async function cancelTunnel(tunnelName: string): Promise<void> {
await api().post("/cancel", { tunnelName });
}
export async function getC2STunnelPresets(): Promise<C2STunnelPreset[]> {
const response = await api().get<C2STunnelPreset[]>("/presets");
return response.data || [];
}
export async function createC2STunnelPreset(data: {
name: string;
config: TunnelConnection[];
platform?: string;
computerName?: string;
}): Promise<C2STunnelPreset> {
const response = await api().post<C2STunnelPreset>("/presets", data);
return response.data;
}
export async function updateC2STunnelPreset(
id: number,
data: Partial<{
name: string;
config: TunnelConnection[];
platform: string;
computerName: string;
}>,
): Promise<C2STunnelPreset> {
const response = await api().put<C2STunnelPreset>(`/presets/${id}`, data);
return response.data;
}
export async function deleteC2STunnelPreset(id: number): Promise<void> {
await api().delete(`/presets/${id}`);
}
@@ -0,0 +1,70 @@
import type { TunnelConnection, TunnelMode } from "../shared/types";
export type ClientTunnel = TunnelConnection & {
localAddress: string;
remoteAddress: string;
bindHost: string;
sourceHostId?: number;
sourceHostSyncId?: string;
sourceHostName?: string;
displayName?: string;
lastStartedAt?: string;
lastTestedAt?: string;
lastError?: string;
};
export function getTunnelMode(tunnel: Partial<TunnelConnection>): TunnelMode {
return tunnel.mode || tunnel.tunnelType || "local";
}
export function normalizeClientTunnel(
tunnel: Partial<ClientTunnel>,
): ClientTunnel {
const mode = getTunnelMode(tunnel);
const metadata = tunnel as Partial<ClientTunnel>;
const localAddress =
tunnel.localAddress?.trim() || tunnel.bindHost?.trim() || "";
const remoteAddress =
tunnel.remoteAddress?.trim() || tunnel.targetHost?.trim() || "";
return {
...tunnel,
scope: "c2s",
mode,
tunnelType: mode === "dynamic" ? "local" : mode,
localAddress,
remoteAddress,
bindHost: localAddress,
targetHost: remoteAddress,
sourcePort: Number(tunnel.sourcePort) || 8080,
endpointPort: Number(tunnel.endpointPort) || 22,
sourceHostSyncId: metadata.sourceHostSyncId,
endpointHost: tunnel.endpointHost || tunnel.sourceHostName || "",
maxRetries: Number(tunnel.maxRetries) || 3,
retryInterval: Number(tunnel.retryInterval) || 10,
autoStart: Boolean(tunnel.autoStart),
displayName: metadata.displayName ?? "",
lastStartedAt: metadata.lastStartedAt,
lastTestedAt: metadata.lastTestedAt,
lastError: metadata.lastError,
};
}
export function stripClientTunnelDiagnostics(
tunnel: ClientTunnel,
): TunnelConnection {
const presetTunnel = normalizeClientTunnel(tunnel);
const displayName = presetTunnel.displayName?.trim();
if (displayName) {
presetTunnel.displayName = displayName;
} else {
delete presetTunnel.displayName;
}
delete presetTunnel.lastStartedAt;
delete presetTunnel.lastTestedAt;
delete presetTunnel.lastError;
return presetTunnel;
}
@@ -0,0 +1,65 @@
import type { PluginHostRecord } from "@termix/plugin-sdk/frontend";
import type { TunnelConnectRequest, TunnelConnection } from "../shared/types";
import { serverTunnelName } from "../shared/tunnel-naming";
/** A host's tunnel settings, as the host payload's pluginSettings carries them. */
export function hostTunnelSettings(host: PluginHostRecord | null | undefined): {
enabled: boolean;
connections: TunnelConnection[];
} {
const settings = (
host?.pluginSettings as Record<string, Record<string, unknown>> | undefined
)?.tunnels;
return {
enabled: settings?.enableTunnel === true,
connections: parseConnections(settings?.tunnelConnections),
};
}
export function parseConnections(value: unknown): TunnelConnection[] {
let parsed = value;
if (typeof parsed === "string") {
try {
parsed = JSON.parse(parsed);
} catch {
return [];
}
}
return Array.isArray(parsed) ? (parsed as TunnelConnection[]) : [];
}
export function tunnelMode(
tunnel: Pick<TunnelConnection, "mode" | "tunnelType">,
): "local" | "remote" | "dynamic" {
return tunnel.mode ?? tunnel.tunnelType ?? "local";
}
/** What POST /connect takes for a host's saved tunnel. */
export function connectRequestFor(
host: {
id: string | number;
name?: string | null;
username?: string | null;
ip: string;
},
index: number,
tunnel: TunnelConnection,
): TunnelConnectRequest {
const mode = tunnelMode(tunnel);
return {
name: serverTunnelName(host, index, tunnel),
sourceHostId: Number(host.id),
tunnelIndex: index,
scope: tunnel.scope ?? "s2s",
mode,
tunnelType: mode === "remote" ? "remote" : "local",
bindHost: tunnel.bindHost,
targetHost: tunnel.targetHost,
endpointHost: (tunnel.endpointHost ?? "").trim(),
sourcePort: tunnel.sourcePort,
endpointPort: tunnel.endpointPort ?? 0,
maxRetries: tunnel.maxRetries,
retryInterval: tunnel.retryInterval,
autoStart: tunnel.autoStart,
};
}
+104
View File
@@ -0,0 +1,104 @@
import type { ComponentType } from "react";
import { Network, Plug } from "lucide-react";
import type {
HomepageWidgetContribution,
TabProps,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import { TunnelTab } from "./TunnelTab";
import { HostTunnelsSection } from "./HostTunnelsSection";
import {
PortForwardingPanel,
TunnelStandalone,
TunnelWidget,
TunnelWidgetEditForm,
} from "./views";
import { getTunnelStatuses, setTunnelsApi } from "./api";
import { hostTunnelSettings } from "./host-tunnels";
// Matches the homepage canvas grid.
const GRID_SIZE = 30;
function TunnelTabView({ host }: TabProps) {
return <TunnelTab host={host} />;
}
export function activate(app: TermixApp): void {
setTunnelsApi(app.api);
app.onDispose(() => setTunnelsApi(null));
// Keeps the "tunnel" tab type and ?view=tunnel links from before the
// conversion, so saved layouts and recent activity still open here.
app.registerTab("tunnel", TunnelTabView as ComponentType<TabProps>, {
icon: Network,
titleKey: "nav.tunnels",
persistent: true,
hostless: true,
standalone: TunnelStandalone,
activityTypes: ["tunnel"],
});
app.registerHostAction({
id: "tunnel",
titleKey: "nav.tunnels",
icon: Network,
kind: "open",
order: 40,
tabType: "tunnel",
copyUrlView: "tunnel",
when: (host) =>
host.enableSsh !== false && hostTunnelSettings(host).enabled,
});
app.registerHostEditorSection({
id: "tunnels",
group: "ssh",
titleKey: "hosts.tabTunnels",
icon: Network,
order: 20,
component: HostTunnelsSection,
});
app.registerPanel("port-forwarding", PortForwardingPanel);
app.registerRailItem({
id: "port-forwarding",
icon: Network,
titleKey: "nav.portForwarding",
kind: "panel",
electronOnly: true,
separatorAfter: true,
after: "credentials",
permission: "use",
});
app.registerSlotContribution("onboarding.features", {
actionId: "tunnels.feature",
titleKey: "onboarding.feature_tunnels",
descriptionKey: "onboarding.feature_tunnels_desc",
icon: Plug as ComponentType<{ className?: string }>,
});
app.registerHomepageWidget({
id: "tunnel_widget",
name: "Tunnel Manager",
description: "Embedded SSH tunnel manager for a configured host",
category: "system",
icon: <Network size={14} />,
defaultConfig: { hostId: 0 },
defaultSize: { w: GRID_SIZE * 16, h: GRID_SIZE * 10 },
minSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 6 },
component: TunnelWidget,
editFormComponent: TunnelWidgetEditForm,
} as HomepageWidgetContribution);
// The dashboard's active tunnel counter reads and opens these, and gets
// undefined back while the plugin is off.
app.registerAction("tunnels.statuses", (() => getTunnelStatuses()) as never, {
permission: "use",
});
app.registerAction(
"tunnels.open",
(() => app.tabs.openSingletonTab("tunnel")) as never,
{ permission: "use" },
);
}
@@ -0,0 +1,64 @@
import type { TunnelMode } from "../shared/types";
type Translate = (
key: string,
options?: Record<string, string | number>,
) => string;
export function getTunnelTypeForMode(mode: TunnelMode): "local" | "remote" {
return mode === "remote" ? "remote" : "local";
}
export function getTunnelPortLabels(
scope: "client" | "server",
mode: TunnelMode,
t: Translate,
) {
if (scope === "client") {
return {
sourcePortLabel:
mode === "remote" ? t("tunnels.remotePort") : t("tunnels.localPort"),
endpointPortLabel:
mode === "remote"
? t("tunnels.localPort")
: t("tunnels.destinationPort"),
};
}
return {
sourcePortLabel: t("tunnels.currentHostPort"),
endpointPortLabel: t("tunnels.endpointPort"),
};
}
export function getTunnelModeDescription(
scope: "client" | "server",
mode: TunnelMode,
ports: {
sourcePort: string | number;
endpointPort: string | number;
},
t: Translate,
) {
if (scope === "client") {
if (mode === "dynamic") {
return t("tunnels.forwardDescriptionClientDynamic", {
sourcePort: ports.sourcePort,
});
}
if (mode === "local") {
return t("tunnels.forwardDescriptionClientLocal", ports);
}
return t("tunnels.forwardDescriptionClientRemote", ports);
}
if (mode === "dynamic") {
return t("tunnels.forwardDescriptionServerDynamic", {
sourcePort: ports.sourcePort,
});
}
if (mode === "local") {
return t("tunnels.forwardDescriptionServerLocal", ports);
}
return t("tunnels.forwardDescriptionServerRemote", ports);
}
+151
View File
@@ -0,0 +1,151 @@
import { Network } from "lucide-react";
import {
useHosts,
useTranslation,
type PluginHostRecord,
type StandaloneViewProps,
} from "@termix/plugin-sdk/frontend";
import {
ConnectionScreen,
FullScreenAppWrapper,
Select2,
} from "@termix/plugin-sdk/ui";
import { TunnelTab } from "./TunnelTab";
import { C2STunnelPresetManager } from "./C2STunnelPresetManager";
import { hostTunnelSettings } from "./host-tunnels";
/** The `?view=tunnel` full-screen page for one host. */
export function TunnelStandalone({ hostId }: StandaloneViewProps) {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, phase) => {
if (phase === "loading") {
return (
<div className="relative h-full w-full">
<ConnectionScreen
status="connecting"
message={t("hosts.loadingHost")}
/>
</div>
);
}
if (!hostConfig) {
return (
<div className="relative h-full w-full">
<ConnectionScreen
status="disconnected"
message={t("hosts.hostNotFound")}
/>
</div>
);
}
const host = {
...hostConfig,
id: String(hostConfig.id),
} as unknown as PluginHostRecord;
return <TunnelTab host={host} />;
}}
</FullScreenAppWrapper>
);
}
/** The rail panel for the desktop app's client tunnels. */
export function PortForwardingPanel() {
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto px-2">
<C2STunnelPresetManager />
</div>
);
}
export interface TunnelWidgetConfig {
hostId: number;
}
/** Homepage widget: the tunnel tab for one host. */
export function TunnelWidget({
widget,
config,
}: {
widget: { title?: string | null };
config: TunnelWidgetConfig;
}) {
const { t } = useTranslation();
const { hosts, loaded } = useHosts();
const placeholder = (text: string) => (
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-xs text-muted-foreground/60">
<Network size={20} />
<span>{text}</span>
</div>
);
if (!config.hostId) return placeholder(t("homepage.widgetNoHostSelected"));
if (!loaded) return placeholder(t("homepage.loading"));
const host = hosts.find(
(candidate) =>
String(candidate.id) === String(config.hostId) &&
hostTunnelSettings(candidate).enabled,
);
if (!host) return placeholder(t("homepage.widgetNoHostSelected"));
return (
<div className="flex flex-col w-full h-full overflow-hidden">
{widget.title && (
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-border/50 shrink-0">
<span className="text-accent-brand shrink-0">
<Network size={11} />
</span>
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate">
{widget.title}
</span>
</div>
)}
<div
className="flex-1 overflow-hidden"
onMouseDown={(e) => e.stopPropagation()}
>
<TunnelTab host={host} />
</div>
</div>
);
}
/** The widget's edit form: pick a host with tunnels on. */
export function TunnelWidgetEditForm({
config,
onChange,
}: {
config: TunnelWidgetConfig;
onChange: (config: TunnelWidgetConfig) => void;
}) {
const { t } = useTranslation();
const { hosts } = useHosts();
const options = hosts.filter(
(host) => host.enableSsh !== false && hostTunnelSettings(host).enabled,
);
return (
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.host")}
</label>
<Select2
value={config.hostId || ""}
onChange={(e) =>
onChange({ ...config, hostId: Number(e.target.value) })
}
className="h-8 text-xs border border-border bg-background px-2"
>
<option value="">{t("homepage.selectHost")}</option>
{options.map((host) => (
<option key={host.id} value={host.id}>
{host.name || host.ip}
</option>
))}
</Select2>
</div>
);
}
@@ -0,0 +1,76 @@
/**
* Tunnel names, shared by the backend and every frontend view so the two
* sides can never build the same tunnel under different names.
*/
/** A host's saved server tunnel: `<hostId>::<index>::<label>::<port>::<endpoint>::<port>`. */
export function buildTunnelName(
hostId: number | string,
tunnelIndex: number,
hostLabel: string,
sourcePort: number | string,
endpointHost: string,
endpointPort: number | string,
): string {
return `${hostId}::${tunnelIndex}::${hostLabel}::${sourcePort}::${endpointHost}::${endpointPort}`;
}
/** The label a server tunnel name carries for its source host. */
export function tunnelHostLabel(host: {
name?: string | null;
username?: string | null;
ip: string;
}): string {
return host.name || `${host.username ?? ""}@${host.ip}`;
}
/** A saved server tunnel's name, from its host and position in the list. */
export function serverTunnelName(
host: {
id: number | string;
name?: string | null;
username?: string | null;
ip: string;
},
index: number,
tunnel: {
sourcePort: number | string;
endpointHost?: string | null;
endpointPort?: number | string | null;
},
): string {
return buildTunnelName(
host.id,
index,
tunnelHostLabel(host),
tunnel.sourcePort,
(tunnel.endpointHost ?? "").trim(),
tunnel.endpointPort ?? 0,
);
}
/** A desktop client tunnel's name, as the Electron main process tracks it. */
export function clientTunnelName(
tunnel: {
sourceHostId?: number;
mode?: string;
tunnelType?: string;
localAddress?: string;
remoteAddress?: string;
sourcePort: number;
endpointPort?: number;
},
index: number,
): string {
const address = (value?: string) => value?.trim() || "127.0.0.1";
return [
"c2s",
index,
tunnel.sourceHostId || 0,
tunnel.mode || tunnel.tunnelType || "local",
address(tunnel.localAddress),
address(tunnel.remoteAddress),
tunnel.sourcePort,
tunnel.endpointPort || 0,
].join("::");
}
+88
View File
@@ -0,0 +1,88 @@
export type TunnelScope = "s2s" | "c2s";
export type TunnelMode = "local" | "remote" | "dynamic";
export const CONNECTION_STATES = {
DISCONNECTED: "disconnected",
CONNECTING: "connecting",
CONNECTED: "connected",
VERIFYING: "verifying",
FAILED: "failed",
UNSTABLE: "unstable",
RETRYING: "retrying",
WAITING: "waiting",
DISCONNECTING: "disconnecting",
} as const;
export type ConnectionState =
(typeof CONNECTION_STATES)[keyof typeof CONNECTION_STATES];
export type TunnelErrorType =
| "CONNECTION_FAILED"
| "AUTHENTICATION_FAILED"
| "TIMEOUT"
| "NETWORK_ERROR"
| "UNKNOWN";
/** One saved tunnel, as a host's tunnelConnections setting or a C2S preset holds it. */
export interface TunnelConnection {
scope?: TunnelScope;
mode?: TunnelMode;
tunnelType?: "local" | "remote";
localAddress?: string;
remoteAddress?: string;
bindHost?: string;
sourceHostId?: number;
sourceHostSyncId?: string;
sourceHostName?: string;
sourcePort: number;
endpointPort: number;
endpointHost?: string;
targetHost?: string;
/** Seconds. */
retryInterval: number;
maxRetries: number;
autoStart: boolean;
displayName?: string;
}
/** What POST /connect takes. Credentials are always resolved server side. */
export interface TunnelConnectRequest {
name: string;
sourceHostId: number;
tunnelIndex: number;
scope?: TunnelScope;
mode?: TunnelMode;
tunnelType?: "local" | "remote";
bindHost?: string;
targetHost?: string;
endpointHost?: string;
sourcePort: number;
endpointPort: number;
maxRetries?: number;
/** Seconds. */
retryInterval?: number;
autoStart?: boolean;
}
export interface TunnelStatus {
connected: boolean;
status: ConnectionState;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
reason?: string;
errorType?: TunnelErrorType;
manualDisconnect?: boolean;
retryExhausted?: boolean;
}
export interface C2STunnelPreset {
id: number;
userId: string;
name: string;
config: TunnelConnection[];
platform?: string | null;
computerName?: string | null;
createdAt: string;
updatedAt: string;
}