mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: convert ssh tunnels into a plugin
This commit is contained in:
1 parent
c85c1fdb86
commit
1682861853
220 files changed
+36928
-11374
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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("::");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user