mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
457 lines
13 KiB
TypeScript
457 lines
13 KiB
TypeScript
import type { Duplex } from "node:stream";
|
|
import type { ClientChannel } from "ssh2";
|
|
import type { WebSocket } from "ws";
|
|
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
|
import { createC2SConnections } from "./c2s-connections.js";
|
|
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;
|
|
sessionId?: 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";
|
|
keepAlive?: boolean;
|
|
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;
|
|
sessionId?: 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;
|
|
const connectSource = createC2SConnections(ctx);
|
|
|
|
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,
|
|
sessionId: tunnel.sessionId,
|
|
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,
|
|
};
|
|
}
|
|
|
|
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(ws, relay.hostId, relay.sessionId);
|
|
const sourceClient = source.client;
|
|
let actualPort: number;
|
|
try {
|
|
actualPort = await bindForwardIn(sourceClient, bindHost, bindPort);
|
|
} catch (error) {
|
|
source.dispose();
|
|
throw error;
|
|
}
|
|
|
|
if (ws.readyState !== 1) {
|
|
unbindForwardIn(sourceClient, bindHost, actualPort, ctx.log);
|
|
source.dispose();
|
|
return;
|
|
}
|
|
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(ws, relay.hostId, relay.sessionId);
|
|
let outbound: ClientChannel;
|
|
try {
|
|
outbound = await forwardOut(source.client, targetHost, targetPort);
|
|
} catch (error) {
|
|
source.dispose();
|
|
throw error;
|
|
}
|
|
|
|
if (ws.readyState !== 1) {
|
|
outbound.destroy();
|
|
source.dispose();
|
|
return;
|
|
}
|
|
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(ws, relay.hostId, relay.sessionId);
|
|
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 {
|
|
if (!message.keepAlive) 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");
|
|
});
|
|
};
|
|
}
|